Web ではマイクロサービスの話をよく聞くようになり、また Android でもたまにマルチモジュールの話を聞くようになりました。
iOS でも企業で採用している話をチラチラ聞くようになり、また今回のアドベントカレンダーにもマルチモジュールについての話が上がっています。
iOSでもマルチモジュール化したい!
https://qiita.com/hironytic/items/3fcd825cc1ef135f5b0f
ということで私もプライベートで作成している iOS アプリをモジュール分割してみたらハマりまくった話。
環境
- Swift: 5.1
- Xcode: 11.3
- Deployment Target: iOS 13.1
今回はサンプルとして、
- インターフェースなどを提供する
Common
モジュール -
Common
モジュールを実装するCore
モジュール - 画面などの機能を提供する
ModuleA
・ModuleB
モジュール - アプリのエントリポイントとなる
MultiModuleSample
モジュール
のモジュールに分割してみたいと思います。
依存関係は以下。
この構成では MultiModuleSample
が全てのモジュールを参照していて、逆に Common
モジュールは全てのモジュールから参照されています。
また、 Core
モジュールではみんな大好き Firebase を CocoaPods を使って参照してみました。
さらに、ModuleA
と ModuleB
について、
- これから機能が増えてモジュール分割していったとき、このまま Dynamic Library のままだとアプリの起動時間が増えそう
-
ModuleA
・ModuleB
などの機能のモジュールはCommon
モジュールのように複数から参照される想定ではない - じゃあ全部 Static Library にしようと思ったけど今度はビルドのキャッシュが効きにくくなったりしてビルド時間が増えそう(多分)
- じゃあ Release ビルド時は Static Library、 Debug ビルド時は Dynamic Libary ならちょうどいいのでは?(錯乱)
ということで、ModuleA
と ModuleB
は
- Mach-O Type
- Debug: Dynamic Library
- Release: Static Library
でいきます。
実際のプロジェクトは以下に置きました。
ただ、あくまでサンプルでいろいろ適当です
Firebase が見つからない
さっそくプロジェクトをマルチモジュール化したのですが、エラーが出ました。
import Core
で Firebase が見つからないと言われてますね。
この解決策はいろいろあると思うのですが、Firebase を CocoaPods で導入したことにより毎回のビルド時間がかなり肥大化してしまったこともあり、
以下の記事を参考に CocoaPods の自動統合をやめて手動リンクする道を選びました。
CocoaPodsをWorkspaceに自動統合せずに利用する - 24/7 twenty-four seven
最初にプロジェクトの CocoaPods 参照を全て消します。
次に CocoaPods をあらかじめビルドしておくためのスクリプトを用意しました。
そして Core モジュールで参照するための .xcconfig を用意します。
Firebase はリンカー設定がいろいろ増えて管理が大変なので CocoaPods が生成してくれるファイルを参照し、必要箇所だけ上書きする方針にしました。
#include "../Pods/Target Support Files/Pods-Core/Pods-Core.release.xcconfig"
PODS_ROOT = $(SRCROOT)/../Pods
PODS_BUILD_DIR = $(PODS_ROOT)/Build
PODS_CONFIGURATION_BUILD_DIR = $(PODS_BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)
最後に MultiModuleSample に参照を追加すれば終わりです。
今回も Core
モジュールの .xcconfig を参照し、 OTHER_LDFLAGS
(Xcode の Build Settings > Other Linker Flags) がいらないので何も指定しないようにしました。
#include "../Core/Core/build.xcconfig"
OTHER_LDFLAGS = ""
これでとりあえずビルドは通るようになりました!
Framework が見つからない
上記の対応をすればビルドは通るようになるのですが、シミュレータで実行すると以下のようなエラーを吐いて死にます。
dyld: Library not loaded: @rpath/GTMSessionFetcher.framework/GTMSessionFetcher
Referenced from: ~/Library/Developer/CoreSimulator/Devices/C9744A7D-E41D-4759-9741-8BDF4AFA516A/data/Containers/Bundle/Application/46366FAE-BA12-4645-858A-A93477C7BDF1/MultiModuleSample.app/MultiModuleSample
Reason: image not found
Carthage を使ったことがある人は1度は見たことあるエラーですね(過言)
原因は、上で CocoaPods をやめたときに リンカーの設定をしたものの、 .framework をアプリに含めるようにしてないからです。
従来は CocoaPods がよしなに追加してくれていたのですが、 CocoaPods の自動統合をやめた結果、自分でアプリに含めるようにする必要があります。
なので MultiModuleSample プロジェクトに以下の Build Phase Script を用意しました。
# ほぼ https://blog.kishikawakatsumi.com/entry/2019/06/17/090724 の「フレームワークのコピー」と同じです 🙇♂️
code_sign() {
# Use the current code_sign_identitiy
echo "Code Signing $1 with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME}"
echo "/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} --preserve-metadata=identifier,entitlements $1"
/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} --preserve-metadata=identifier,entitlements "$1"
}
if [ "$ACTION" = "install" ]; then
echo "Copy .bcsymbolmap files to .xcarchive"
find . -name '*.bcsymbolmap' -type f -exec mv {} "${CONFIGURATION_BUILD_DIR}" \;
fi
echo 'Copying frameworks'
# ${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/ がないときにエラーが起きるので作るようにする
ls "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/" || mkdir "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/"
if [ $SCRIPT_INPUT_FILE_LIST_COUNT -ne 0 ]; then
for i in $(seq 0 $(expr $SCRIPT_INPUT_FILE_LIST_COUNT - 1)); do
inputFileListVar="SCRIPT_INPUT_FILE_LIST_${i}"
inputFileList="${!inputFileListVar}"
cat "${inputFileList}" | while read inputFile; do
cp -rf "$inputFile" "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/"
for file in $(find ${inputFile} -type f -perm +111); do
# Skip non-dynamic libraries
if ! [[ "$(file "$file")" == *"dynamically linked shared library"* ]]; then
continue
fi
if [ "${CODE_SIGNING_REQUIRED}" = "YES" ]; then
code_sign "${file}"
fi
done
done
done
fi
あとは Carthage と同じように Input File Lists
の欄にコピーしたい Framework のパスを書いた .xcfilelist を用意すれば OK です。
$(PODS_CONFIGURATION_BUILD_DIR)/GTMSessionFetcher/GTMSessionFetcher.framework
$(PODS_CONFIGURATION_BUILD_DIR)/GoogleUtilities/GoogleUtilities.framework
$(PODS_CONFIGURATION_BUILD_DIR)/gRPC-Core/grpc.framework
$(PODS_CONFIGURATION_BUILD_DIR)/gRPC-C++/grpcpp.framework
$(PODS_CONFIGURATION_BUILD_DIR)/leveldb-library/leveldb.framework
$(PODS_CONFIGURATION_BUILD_DIR)/nanopb/nanopb.framework
$(PODS_CONFIGURATION_BUILD_DIR)/BoringSSL-GRPC/openssl_grpc.framework
$(PODS_CONFIGURATION_BUILD_DIR)/abseil/absl.framework
これで Debug できるようになりました!
ちなみに
本当は モジュールを使う MultiModuleSample
ではなく、各 Framework に依存している Core
モジュールにコピーする処理を書きたかったのですが、
dyld: Library not loaded: @rpath/GTMSessionFetcher.framework/GTMSessionFetcher
Referenced from: /private/var/containers/Bundle/Application/7B9A24BA-DFC7-4EBB-A5ED-D098BBB3D7F5/MultiModuleSample.app/Frameworks/Core.framework/Core
Reason: no suitable image found. Did find:
/private/var/containers/Bundle/Application/7B9A24BA-DFC7-4EBB-A5ED-D098BBB3D7F5/MultiModuleSample.app/Frameworks/Core.framework/Frameworks/GTMSessionFetcher.framework/GTMSessionFetcher: code signature in (/private/var/containers/Bundle/Application/7B9A24BA-DFC7-4EBB-A5ED-D098BBB3D7F5/MultiModuleSample.app/Frameworks/Core.framework/Frameworks/GTMSessionFetcher.framework/GTMSessionFetcher) not valid for use in process using Library Validation: mapped file has no cdhash, completely unsigned? Code has to be at least ad-hoc signed.
のようなエラーが発生して死にました。
code signature in (フレームワーク名) not valid for use in process using Library Validation: mapped file has no cdhash, completely unsigned? Code has to be at least ad-hoc signed.
とあるので、おそらく署名関連で落ちてると思うのですがどう解決したものか悩ましかったので諦めて MultiModuleSample
でコピーする処理を書きました。
Archive できない
これで実行できるようになったので、試しに Archive をしてみると以下のようなエラーが発生します。
エラー画面に一切の情報がない上にログの中にある IDEDistribution.critical.log
も空っぽです。
ただ、 IDEDistributionPipeline.log
の中をよく見てみると、
Assertion failed: Duplicate symbols for 17B1BDAC-7347-380D-B067-67115D777DF5
という記述があり、 17B1BDAC-7347-380D-B067-67115D777DF5
を検索すると
bitcode-build-tool built /var/folders/sb/{省略}/MultiModuleSample.app/Frameworks/Core.framework/Frameworks/Common.framework/Common arm64:17B1BDAC-7347-380D-B067-67115D777DF5
というログが見つかります。
どうやら Common.framework
が .app に複数含まれているせいで bitcode コンパイルに失敗しているみたいです。
この原因ですが、各モジュールでの Common.framework
の参照方法は以下のようになっています。
はい、 Embed になっていますね。
この場合、各モジュールはそれぞれ モジュール名.framework/Frameworks/
内に Common.framework
をコピーします。
なので Common.framework
が .app に複数含まれてエラーになっていたっぽいです。
この解消法は単純に、 エントリポイントである MultiModuleSample プロジェクト以外の全ての Common モジュールを参照するプロジェクトの Embed を 「Do Not Embed」 にするだけです。
結果・・・
Archive に成功しました!
ちなみに
Xcode 10 までは Embed と Link Framework の欄が別々で、見るところだけ気をつければいいだけでした。
Xcode 11 で Embed と Link Framework が合体してデフォルトが Embed になったので、ついポンポン Link させると罠にハマります(ハマりました)。
余計なモジュールが含まれている
上の Archive を見ると必要そうなモジュールのみ含まれているように見えますが、実際にできた .ipa をバラしてみると ModuleA.framework
と ModuleB.framework
が含まれています。
上記2つは最初に書いた通り、 Release ビルド時は Static Library としてビルドしているはずなので .framework が含まれる必要はありません。
なので ModuleA ・ ModuleB についても上記のように Embed をやめます。
ただ、このままでは Debug ビルド時に逆に .framework が含まれなくなってしまい、また image not found が発生してしまいます。
なので、今回も前回のように手動コピーさせます。
https://blog.kishikawakatsumi.com/entry/2019/06/17/090724 の「フレームワークのコピー」と同じです 🙇♂️
if [ ${CONFIGURATION} != 'Debug' ];then
exit 0
fi
# あとは上と同じなので略
$(BUILT_PRODUCTS_DIR)/ModuleA.framework
$(BUILT_PRODUCTS_DIR)/ModuleB.framework
ただ、このまま実機でデバッグを実行した際、以下のようなメッセージが出てインストールに失敗します。
またもや優しさのかけらもないメッセージですね。
ちなみに Details をクリックすると以下のように詳細を教えてもらえました。
なるほど、わからん。
ただ、 Console.app でインストールしたときのログを見たところ、以下のようなものがありました。
0x16ef17000 +[MICodeSigningVerifier _validateSignatureAndCopyInfoForURL:withOptions:error:]: 183: Failed to verify code signature of /private/var/installd/Library/Caches/com.apple.mobile.installd.staging/temp.5JSbAi/extracted/MultiModuleSample.app/Frameworks/ModuleB.framework : 0xe800801c (No code signature found.)
まあなんのことやらさっぱりわかりませんが、なんとなく ModuleB で問題が発生してそうです。
また、 code signature で問題が起きているので署名周りで文句を言われてるのかなと思いました。
とりあえず、 ModuleA ・ ModuleB の Signing が 「Automatic」 になっていたので 「Manual」 に変更してみました。
うまくいきました。
なるほど、わからん。
リソースをコピーしたい
ここまでで実行できるようになりましたが、 Release ビルドを Static Library にしたせいでもし ModuleA
で .xib などリソースファイルを使った場合、 .app に含まれません。
なのでこれも手動でコピーしてやる必要があります。
これも上に書いた .framework をコピーするのと同じ要領でいけます。
if [ $CONFIGURATION != "Release" ];then
exit 0
fi
echo 'Copying Bundle Resources'
if [ $SCRIPT_INPUT_FILE_LIST_COUNT -ne 0 ]; then
for i in $(seq 0 $(expr $SCRIPT_INPUT_FILE_LIST_COUNT - 1)); do
inputFileListVar="SCRIPT_INPUT_FILE_LIST_${i}"
inputFileList="${!inputFileListVar}"
cat "${inputFileList}" | while read inputFile; do
echo "copy $inputFile"
cp -rf "$inputFile" "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_FOLDER_PATH}/"
done
done
fi
あとは Input File List に
$(BUILT_PRODUCTS_DIR)/ModuleA.framework/ModuleAXib.nib
のようにコピーしたいファイルを書いた .xcfilelist を指定すれば OK です。
ここまでで
これでようやく開発をしていけるくらいには体裁が整ったのかなと思います。
ただ、露見してないだけでまだ問題が潜んでいるかもしれませんし、上に書いたようにこの手の問題は非常にわかりにくいです。
しかし、あくまで個人の見解ですが、大きなプロジェクト、特に大人数で長期間メンテするようなプロジェクトはマルチモジュールの恩恵が大きいと思います。
個人的にもマルチモジュールな構成は好きなのでいろいろ試していきたいです!
学んだこと
- ログをちゃんと見る
- 大抵の問題はログで吐き出されている。 Console.app だったり Xcode の Report Navigator(⌘9)のビルドログに結構ヒントが転がっている
- Diff 大事
- git commit をこまめにしていこう(反省)
- 最後に頼りになるのはやっぱり勘
- なんやかんや頼りになる