App Extensionのバイナリサイズの肥大化とその対処法
こんにちは!普段iOSアプリを個人開発しているりゅう(@ryu_hu03)です。
App Extensionを含むiOSアプリを開発していると、バイナリサイズがそのアプリの機能数からは考えられないほど大きくなってしまうことがあります。
1年前、StudyLegendsというアプリを開発していた際にバイナリサイズが膨らんでしまう問題が起こりました (その後200MBから100MBへの削減に成功しました🎉)。
また、最近新たに個人開発しているアプリでもこのような問題が起こってしまったので、なぜこの問題が起こるのか、どのように解決するのかを解説していきます。
肥大化が起きたアプリの構成
Swift Package Managerを用いて、以下のような構成でアプリを構築していました。

本アプリでは、ビルド時間を短縮したり、Xcode Previewsを使用するために、マルチモジュールを採用しています。
アプリ本体には、ルートモジュールであるAppFeatureのみに依存するようになっており、
各App Extensionは、コンテンツ表示に必要なロジックがあるClientのLive実装に依存しています。
だいぶ簡略化をしていますが、大きく分けて、以下のモジュールがあります。
- Feature (各機能)
- Client (DBの操作をするobjectのInterface)
- ClientLive (DBの操作をするobjectの実装本体)
- Core (Entitiesや, Utilitiesなどのアプリ共通実装)
外部依存に、firebase/firebase-ios-sdkや、pointfreeco/sqlite-dataとその子依存のパッケージなどがあり、それらはFeatureモジュールや、FooClientのLiveモジュールでも使用されています。
問題点
この構成のままアーカイブをしてみると、以下のようになります。

90.9MBあり、なかなかのボリュームです。
そして、以下がAppバンドル内の画像です。



App本体のバイナリと同じくらい、App Extensionのバイナリのサイズがあることがわかります。
なぜApp Extensionのバイナリがこれほど大きくなってしまうのでしょうか?
その原因は、AppFeatureやFooClientLiveがApp本体やApp Extensionに静的リンクされていることにあります。
リンクとは
リンクとは、ソースコードをコンパイルすることで生成されたオブジェクトファイルや外部ライブラリを結合し、バイナリを生成することを指します。
そしてリンクには、「静的リンク (Static linking)」「動的リンク (Dynamic linking)」の2種類あります。
静的リンクでは、依存するライブラリの処理のすべてが実行ファイルに組み込まれます。
動的リンクでは、実行可能ファイルに組み込まれず、外部ライブラリの関数への参照情報のみ書き込まれます。そして、起動時に実際のライブラリの処理がロードされます。
詳しくは以下の資料を閲覧するとわかりやすいです。
つまり、先ほどの構成だと、依存ツリーの頂点にいるAppFeatureがアプリ本体のバイナリにコピーされます。また、さまざまな外部ライブラリを利用しているFooClientLiveは、
3つのApp Extensionバイナリそれぞれにコピーされてしまいます。結果として、アプリ全体のサイズが大きくなってしまうのです。

解決方法
解決策として、AppFeatureでFooClientLiveを外部に公開するようにしました(@_exported importを使用)。そして、AppFeatureを動的ライブラリとして定義し、App本体とApp Extensionの両方で共通してAppFeatureを使用することで、この問題を解決しました。

Package側の設定
Swift Package ManagerのPackage.swiftでは、typeを指定しないとデフォルトで静的ライブラリとなってしまいます。そこで以下のようにtypeを.dynamicに指定してあげます。
let package = Package(
name: "SomePackage",
products: [
.library(
name: "AppFeature",
type: .dynamic,
targets: ["AppFeature"]
),
],
...
)
ライブラリの埋め込み
そして、ライブラリの埋め込みの設定についてですが、以下のように設定します。
-
AppFeature: Embed & Sign

-
App Extension: Do Not Embed

Embedをするとどうなるかというと、以下のように、アプリバンドルのFrameworksディレクトリにAppFeature.frameworkが組み込まれます。
App.app/
├── App (実行ファイル)
├── Extensions/
│ └── Shortcut.appex/
│ └── Shortcut (実行ファイル)
├── Plugins/
│ ├── AppWidgetsExtension.appex/
│ │ └── AppWidgetsExtension (実行ファイル)
│ └── AppLiveActivityExtension.appex/
│ └── AppLiveActivityExtension (実行ファイル)
└── Frameworks/
└── AppFeature.framework ← ここにAppFeature.frameworkが埋め込まれる
しかし、App ExtensionでEmbed & Signをしてしまうと、*.appex内にもAppFeature.frameworkが埋め込まれてしまい、
アプリサイズが増えてしまうため、必ずDo Not Embedにする必要があります。
リンカの設定
動的ライブラリは起動時にロードされます。App ExtensionとApp本体は、独立した実行ファイルを持っています。Appバンドルには、Frameworksディレクトリがあるため、デフォルトの設定のままであれば、App本体はAppFeature.frameworkを見つけ出すことができますが、App Extensionは起動時にそれを見つけることができずにクラッシュしてしまいます。
つまり、リンカにどのパスを見れば目的の動的ライブラリがロードできるかを教えてあげる必要があります。
以下の値を、各App ExtensionのBuild SettingsのRunpath Search Pathsという項目に追加します
@executable_path/../../Frameworks

@executable_path は、実行可能ファイルがあるパスです。
*.appexの中にある実行ファイルからみて、AppバンドルのFrameworksディレクトリは、@executable_path/../../Frameworks にあるためです。
以上で設定完了です🎉
注意
Firebaseを使用していて、AppFeatureが静的ライブラリである場合、Xcode ProjectにあるApp本体のターゲットや、App ExtensionターゲットのBuild SettingsのOther Linker Flagsに-ObjCをつけなければなりませんでした。しかし、AppFeatureを動的ライブラリにすると、Package.swiftでAppFeatureのターゲットの定義の際に、以下のように-ObjCを設定する必要があります。
.target(
name: "AppFeature",
dependencies: [...],
linkerSettings: [
.unsafeFlags(["-ObjC"]),
]
),
これは、Firebaseを静的リンクするバイナリが変わるためです。AppFeatureが静的ライブラリの場合は、App本体のバイナリにAppFeatureとFirebaseが一緒に静的リンクされますが、AppFeatureが動的ライブラリの場合はAppFeature.framework/AppFeature内にFirebaseが静的リンクされるため、AppFeatureのlinkerSettingsに-ObjCを設定しなければなりません。
結果
90.9MBあったアプリサイズが、、

31.4MBに大幅に削減されました!🎉
Appバンドル内のバイナリサイズも見てみます。




20MB近くあったApp本体とApp Extensionのバイナリサイズが大幅に削減され、Frameworks以下にAppFeature.frameworkが生成されていることがわかります。
結論
静的リンクから動的リンクに切り替えることで、大幅なバイナリサイズの削減をすることに成功しました!
また、動的リンクは、アプリの起動時にリンクが行われるため、起動時間が長くなる恐れがあります(自分は全く長くなったと感じなかった)。
そこで、Luupさんの記事のように、モジュールの依存関係を見直し、App Extensionの依存モジュールを最小限にする手法も有効であると考えられます。
Discussion