iOSアプリの起動速度を2倍にするために、複数のDynamic FrameworkをStaticにして、ひとつのDynamic Frameworkを作る with Swift

Muukii (Hiroshi Kimura)
Eureka Engineering
17 min readJan 13, 2019

--

[追記 : 2020/12/27]

本記事で説明している手順がもう少しシンプルになったので、そのサンプルプロジェクトをこちらに用意しました。

対応の比較動画 (体感2倍)

エウレカ iOS エンジニアの muukii (Twitter) です🥃

私が開発を担当しているPairs Global (Pairsの海外向けアプリ)はアプリの起動がとても遅いのです。

一体なぜなのか。

OSはアプリを起動してAppDelegate (厳密にはmain関数)が呼び出されるまでには様々な処理を行います。
この部分の処理を最適化することでアプリが起動していない状態からの起動の高速化が期待できます。

アプリ起動高速化のための前置き (長め)

まず、用語について、厳密には FrameworkとLibaryは異なるものですが、性質は近いので本記事では次のように用語を用います。

Static Framework または Static Libraryを まとめて 「Static Framework」と呼びます
Dynamic Framework または Dynamic Libraryを まとめて「 Dynamic Framework」と呼びます。

OSはアプリを起動してAppDelegate (厳密にはmain関数)が呼び出されるまでには様々な処理を行います。
この部分の処理を最適化することでアプリが起動していない状態からの起動の高速化が期待できます。

起動までにどのような処理が行われているのかはWWDCの資料で知ることができます。

起動時間(App Startup Time)の最適化

今回取り組みたいことは起動時間に含まれるDynamic Frameworkの動的リンク時間の削減です。

WWDCの資料ではdylibは使用は高価であり少ないほど良いと示しています。

あまり具体的な話はここでは触れませんが、つまりはModuleをDynamic Frameworkではなく Static Framework としてビルドして使用することで起動時間の最適化が可能ということです。

しかし、Swiftの登場以降 moduleを使用する上ではDynamic Frameworkの使用を避けることは難しいことでした。
XcodeがSwiftを含むコードベースをStatic Frameworkとしてビルドすることに対応していなかったからです。

この問題はXcode10では解消されており、Static Framework または Static Libraryとしてビルドすることが可能になっています。

Carthageのドキュメントでも起動時間の高速化について検討されアイデアが掲載されています。

では、Dynamic Frameworkを利用するメリットは何かと言うと、
実行可能なTargetが複数ある場合において、ソースコードを共有するケースです。

具体的にはアプリ本体とApp Extensionなどが挙げられます。

ざっくりとした説明にしておきますが、次のような構成だとします。

  • App (Executable) 1MB
  • Extension (Executable) 1MB

これらに共通のコードを使用したい場合、共通のコードを一緒にコンパイルしても良いですが、Moduleとして包むことでInternalのメソッド呼び出しを防ぐなどの疎結合性を保つことが可能になります。

なので、MyCoreというModuleを用意します。

  • MyCore 1MB

MyCoreがStatic Frameworkだと中身(シンボルなど)がExecutable Targetと合体するので、各サイズが次のようになります。

  • App (Executable) + MyCore(Static Framework) = 2MB
  • Extension (Executable) + MyCore (Static Framework) = 2MB

これでAppStoreからダウンロードするときには4MBの転送が必要になります。

そこでMyCoreをDynamic Frameworkにすると、中身(シンボルなど)は合体せずにアプリ起動時に結合されるので、構成は次のようになり、

  • App (Executable) 1MB
  • Extension (Executable) 1MB
  • MyCore 1MB

合計サイズは3MBで済むことになります。

このようにすることでリソースサイズの削減が可能となります。

もっと身近な例でいうと UIKit.frameworkはアプリにバンドルされていません。
これもアプリ起動のタイミングに動的に結合してアプリが立ち上がっているのです。

また、実行可能ファイルがどんなDynamic Frameworkを必要としているかは otoolを用いて調べることができます。

実行可能ファイルが必要とするDynamic Frameworkを調べる

Xcodeプロジェクトでビルドを行うと Productsフォルダからビルドによる成果物にアクセスができるようになります。

Umbrellaファイルが実行可能ファイルです。

これを `otool`に渡してみます。

otool -Lで動的リンクを行うDynamic Frameworkの一覧が表示されます。
UIKit.frameworkも対象であることが読み取れます。

また fileコマンドというものもあります。

と、64-bit x86_64 用の executableファイルであるということがわかります。
Xcodeでシミュレータ用にビルドしているので x86_64 となっています。

Mach-Oとは実行ファイルのフォーマットです

Pairs Globalでは

Pairs Global (Pairsを海外向けに展開しているアプリ)では多くのOSSを利用し、アプリ内でもレイヤーや責務ごとにModuleを分割しています。

とても多くの動的リンクが行われるためアプリの起動時間が長くなっています。

この問題に対応するため、一部のFrameworkのStatic化の対応を行いました。

対応における課題は
「Static Frameworkによる静的リンクとDynamic Frameworkによる動的リンクが混在すること」
です。

これはリンクする構成を間違えると、メソッドなどのシンボルが重複が発生する可能性があるということです。(重複なのでバイナリサイズの増加にもつながる可能性はあります)

どういうことかというと、前置きでも話しましたが、Static FrameworkとFrameworkがリンクを行うとメソッドなどのシンボルはすべて一箇所に集まります。

Utilityが持つシンボルはAとBの両方に含まれることになり、 Appから見ると同じシンボルが重複してしまうのです。

これは一枚のDynamic Frameworkのレイヤーを挟むことで対策が行えます。

このような複数のFrameworkをまとめたFrameworkのことをUmbrella Frameworkと呼ぶことがあります。
(Umbrella Headerとかもありますね)

Umbrella Frameworkを作る

Pairs Globalプロジェクトに似たサンプルプロジェクトを用いて説明を行います。

リポジトリも作って置いてあります。

スクリーンショットをたくさん使って説明していきます。

Workspaceを開いた様子
MyApp.xcodeproj内の依存関係
ディレクトリの構成
Podfile

この時点での実際のファイルは上のリンクから見ることができます。

UmbrellaプロジェクトにインストールするPodはStaticになるようにする

この時点でプロジェクト構成の大枠が出来上がっているので、次はUmbrellaプロジェクトが持つUmbrella.frameworkに対して指定している

  • Alamofire
  • RxSwift
  • RxCocoa

これらをStatic Frameworkとしてビルドし、Umbrella.frameworkに静的リンクされるようにします。

すこしゴチャついたpost_installスクリプトを用意しました。

Podfileに付け足すpost_installの部分

肝心な部分は config.build_settings['MACH_O_TYPE'] = "staticlib" だけです。

それより上の部分はバリデーションを行うコードです。

例えばもし pod 'Alamofire' がMyAppに対しても書かれていると静的リンク先が複数になってしまいシンボルの重複が発生してしまうため、 target 'Umbrella' 内に書かれたPodはそこだけにしか定義されてないことを確認しています。

これで pod install を行い、Podsプロジェクトで対象のTargetがStatic Frameworkとしてビルドされるようになっていれば成功です。

Mach-O Type が Static Libraryとなっていることを確認する
MyAppのEmbbeded BinariesにUmbrella.frameworkを追加する
Umbrellaを使用するFrameworkにリンクするように設定します

次にUmbrella.frameworkの OTHER_LDFLAGS-Objc -all_load を追加します。

これでシンボルをすべてUmbrella.frameworkに読み込まれるようになります。

Umbrella.frameworkを使う

ここからは僕自身の知識不足で経験上うまく行った方法を紹介します。

この時点のプロジェクト構成でビルドを行うと、 AlmofireやRxSwiftはUmbrella.frameworkにすべて静的にリンクされておりすべてのシンボルが存在します。

もし、アプリ側のModule郡でAlamofire利用する場合、 import Umbrella は不要で、Alamofire moduleがSearchPath上で見つかれば import Alamofire と記述することが可能です。

import 構文は実際にframeworkをリンクするわけではなくコンパイル時にシンボルの解決に使用されるだけです。

つまり、実行時にはUmbrella.frameworkが持つAlamofire関連のシンボルによって解決されます。

しかし、このSearchPathの設定で良い方法が見つからなかったため、次のような対応を行っていきます。

Umbrella.frameworkにexport.swiftファイルを追加し、画像のように Umbrellaが含むModuleを@_exported を用いてexportします。

@_exported は公式なものではないので今後どうなるかはなんとも言えません。

すると、AppUtility内では画像のように記述が可能になります。

この時点のtagはこちら

ビルドを行った成果物の中身を覗いてみます。

Umbrellaファイルが一番大きなサイズとなっており、なんとも合体している感が出ています。

stringsでシンボルを見た結果 (Alamofireに関するシンボルを探してみています)
otoolでdylibの情報を見た結果 AlamofireやRxSwiftは見当たりません。

Static Frameworkとして使用する際の注意点

Dynamic FrameworkをStatic Frameworkとして使用することの注意点はFrameworkがassetsなどのリソースを持っている場合、それらを適切にUmbrellaにコピーしてあげる必要があります。

おわりに

アプリ起動時間の短縮にはなんとか成功した雰囲気が出ているものの、個人的に難しさや知識不足だと感じている部分としてはUmbrella.frameworkを使う側の設定だったり、ビルドやリンカのレイヤーの知識です。

おそらく @_exported を使わない適切な方法はあるはずなので、今後その辺りのインプットや試行錯誤を行っていきたいと思います。

もし本題に関して情報交換して頂ける方いらっしゃいましたらエウレカで定期開催している「Android iOS もくもく会」でお会いしましょう!

2018年 11月に開催した Android&iOS もくもく会

と、宣伝もしてしまいましたが、僕はどこでも伺います笑

追記 (2019/1/14)

Dynamic Frameworkの読み込みにかかっている時間を見る

今回紹介した手法が自分の持つプロジェクトで必要なのかを判断することは難しいことだと思います。

目安として、XcodeのSchemeに次のような設定をすることで、アプリ起動時にXcodeのコンソールにアプリ起動にかかった時間が出力されるようになります。

DYLD_PRINT_STATISTICS
コンソールにこのように分析結果が表示されます。

参考にした記事まとめ

--

--