こんにちは。ソフトウェアエンジニアの@kitasukeです。
今回は、マルチモジュール化されたアプリでも上手く Static Framework を利用してアプリ起動時間を 約30% 短縮した方法を紹介します。 起動時間はプロジェクトの構成や使用するフレームワークに依存するので、必ずしも同じ効果が得られるかは保証しません。
ある程度大きなコードベースなアプリを想定していて、依存管理ツールはCocoaPodsを使用しています。 またマルチモジュール化というのは、1つのワークスペースに複数のXcodeプロジェクトを用いてマルチモジュール化している状態を想定しています。 ただ、1つのXcodeプロジェクトに複数のターゲットがある状態でも、今回のアプローチは十分適用できると思います。
Dynamic Framework を多用すると、一般的にアプリ起動速度は遅くなります。 特に、モジュールが複数ありそれぞれのモジュールで同じフレームワークを使っている場合などは影響が大きいです。 その代わりにStatic Frameworkにすれば起動速度が向上しますが、モジュール間で同じフレームワークを使うとシンボルの重複が起こるので注意が必要です。
そこで、 Umbrella Framework を利用して上記の問題を解決します。 下記の記事の内容をかなり参考にして作りました。
https://medium.com/eureka-engineering/create-merged-framework-to-cut-appstartuptime-72ee67b2bbab
Umbrella Frameworkとは、複数のフレームワークをまとめたフレームワークです。 Dynamic FrameworkであるUmbrella Frameworkに複数モジュールで使うStatic Frameworkをまとめて、Umbrella Framework自体を各モジュールにリンクすることでStatic Frameworkを最大限に活用しながらシンボルの重複を防ぎます。
依存関係は下記の画像のようになります。 今回のアプリは、RxSwiftを利用してリアクティブプログラミングを各モジュールで活用して、通信処理やモデル定義でProtocol Buffersを広く活用しているので、それらのフレームワークをUmbrella Frameworkにまとめて入れています。 それを AppCore と AppEntity にリンクして、それらをメインターゲットの App にリンクしています。
!!!画像!!!
注意すべき点として、純粋なUmbrella Framework、つまりEmbedded framework in embedded frameworkは下記のドキュメントの通りiOSでサポートされていません。 フレームワークパスの設定を適切にすればビルド自体はできますが、アプリのアーカイブ・アップロードでバリデーションエラーになります。 具体的にどう対応したかを次で解説します。
フレームワークパスの設定やStatic Frameworkの生成は基本的にCocoaPodsに任せて、追加で必要な設定をマニュアルで行います。 それらの設定はxcconfigで行うのをお勧めしますが、今回はその説明を省きます。
上記の依存関係の図のように、プロジェクト構成は以下のとおりです。
// UmbrellaApp.xcworkspace
- UmbrellaApp // メインターゲット
- UmbrellaCore // 通信処理などのコア機能
- UmbrellaEntity // protobufで生成するモデル
- UmbrellaFramework
- Pods
Podfileは下記のとおりです。 UmbrellaCoreとUmbrellaEntityでは、UmbrellaFramework以外のフレームワークは不要なのでPodfileでやることは特にないです。
source "https://cdn.cocoapods.org/"
platform :ios, '13.0'
use_frameworks!
workspace 'UmbrellaApp'
target 'UmbrellaApp' do
pod 'Firebase/Analytics'
pod 'Firebase/Crashlytics'
pod 'Firebase/Messaging'
end
target 'UmbrellaFramework' do
project 'UmbrellaFramework/UmbrellaFramework'
pod 'RxSwift'
pod 'RxRelay'
pod 'RxCocoa'
pod 'SwiftGRPC'
end
Static Frameworkの生成はCocoaPodsプラグインの cocoapods-static-swift-framework で行います。
利用方法は、gem install cocoapods-static-swift-framework
でインストールして、Podfileに plugin 'cocoapods-static-swift-framework'
を追加するだけです。
https://github.com/leavez/cocoapods-static-swift-framework
フレームワークパスの設定はCocoaPodsが面倒を見てくれますが、それだけでは不十分です。
まずは export.swift
ファイルを追加して、各ライブラリのシンボルを外部モジュールに公開します。
@_exported import RxCocoa
@_exported import RxRelay
@_exported import RxSwift
@_exported import SwiftGRPC
@_exported import SwiftProtobuf
@_exported import xxx は、他のモジュールから参照する際に、あたかも自身のモジュールから参照するのと同等にシンボルを公開できる機能です。
https://forums.swift.org/t/exported-and-fixing-import-visibility/9415
次に、Static FrameworkのRxSwiftを利用するので、 Other Linker Flags に -all_load を追加します。 このオプションで全オブジェクトファイルをロードを明示的に行えます。 これがないと下記のようなランタイムエラーが発生するはずです。
ld: warning: Could not find or use auto-linked library 'RxSwift'
ld: warning: Could not find or use auto-linked library 'RxCocoa'
ld: warning: Could not find or use auto-linked library 'RxRelay'
RxSwiftでなくても同じ現象が発生する可能性があるので、どちらにせよ追加するのも良いかもしれません。
次に、UmbrellaFrameworkのリンク先での設定です。
上記で説明した通り、メインターゲットに追加するEmbedded Frameworkに対してEmbedded Frameworkは追加できません。 したがって、 Do Not Embed にしてUmbrella Frameworkを両モジュールに追加しましょう。
!!!画像!!!
Do Not Embedにする場合は自身でフレームワークパスの設定が必要です。
Framework Search Paths に ${BUILT_PRODUCTS_DIR}/UmbrellaFramework.framework
を、 Header Search Paths に ${BUILT_PRODUCTS_DIR}/UmbrellaFramework.framework/Headers
を追加します。
ここでも同様の目的で export.swift
を追加して、UmbrellaFrameworkを外部モジュールに公開します。
@_exported import UmbrellaFramework
今回のアプリはUmbrellaCore、UmbrellaEntity共にメインターゲットにしかリンクしないので、 Mach-O Type を Static Library
に変更してStatic Frameworkにしました。
上記の通りEmbedded framework in embedded frameworkはサポートされていないので、代わりにメインターゲットであるUmbrellaAppにUmbrellaFrameworkをEmbedします。 Embedded frameworkとして追加する場合はフレームワークパス設定もすべて面倒を見てくれるので、特にマニュアルで設定することはないです。
!!!画像!!!
RxTestなど、UmbrellaFrameworkにまとめたフレームワークに依存を持つテストフレームワークを使う場合に注意する点があります。 それは両方のビルドを同じXcodeプロジェクト内で行わないと、下記のようにフレームワークを使用する側でランタイムのデマングルエラーが発生することです。
failed to demangle superclass of xxx from mangled name '�xxx'
それを回避するための、下記のようにUmbrellaFrameworkを継承するUmbrellaTestsFrameworkを作成して、そこにRxTestを追加します。
target 'UmbrellaFramework' do
project 'UmbrellaFramework/UmbrellaFramework'
pod 'RxSwift'
pod 'RxRelay'
pod 'RxCocoa'
pod 'SwiftGRPC'
target 'UmbrellaTestsFramework' do
inherit! :search_paths
pod 'RxTest'
end
end
UmbrellaTestsFrameworkを直接メインターゲットのUmbrellaAppにリンクするとシンボルの重複でコンパイルエラーになります。
そこで、RxTestだけをUmbrellaAppのテストターゲットであるUmbrellaAppTestsの Link Binaries With Libraries に追加します。
Framework Search Paths に ${PODS_CONFIGURATION_BUILD_DIR}/RxTest
を、 Header Search Paths に ${PODS_CONFIGURATION_BUILD_DIR}/RxTest/Headers
を追加してフレームワークパスを設定します。
これが正攻法かどうかは不明ですが、これ以外にやり方が思いつきませんでした。
DYLD_PRINT_STATISTICS という環境変数を利用すれば、アプリ起動時の処理時間の計測が可能です。 下記のように、 Environment Variables のNameとValueに追加してアプリを起動すればコンソールに結果が表示されます。
!!!画像!!!
使うフレームワークが多くなればなるほどアプリ起動速度が気になると思います。 その問題に対して、今回のアプローチで完全に解決できるわけではなく、また効果もアプリ次第です。 ただそれなりの効果は期待できるので、試して見る価値はあると思います。 若干ハックのようなことをやっているので、その辺に関して質問あればいつでもどうぞ。