iOS でマルチモジュールを試したときにハマったこと

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` モジュール

のモジュールに分割してみたいと思います。  
依存関係は以下。

![依存関係.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282900/a740137d-6bb5-fa51-78c4-b0cfbfa53abc.png)


この構成では `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

でいきます。

実際のプロジェクトは以下に置きました。

https://github.com/k-ymmt/MultiModuleSample

ただ、あくまでサンプルでいろいろ適当です :bow:

## Firebase が見つからない
さっそくプロジェクトを[マルチモジュール化](https://github.com/k-ymmt/MultiModuleSample/tree/99446b2d94bec849c6c920de69c6c9fccaf7bcdb)したのですが、エラーが出ました。

![Screen Shot 2019-12-15 at 18.45.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282900/bb7320be-3f67-bdfd-0997-606c3e63e453.png)


`import Core` で Firebase が見つからないと言われてますね。  

この解決策はいろいろあると思うのですが、Firebase を CocoaPods で導入したことにより毎回のビルド時間がかなり肥大化してしまったこともあり、
以下の記事を参考に CocoaPods の自動統合をやめて手動リンクする道を選びました。

[CocoaPodsをWorkspaceに自動統合せずに利用する - 24/7 twenty-four seven](https://blog.kishikawakatsumi.com/entry/2019/06/17/090724)

最初にプロジェクトの [CocoaPods 参照を全て消します](https://github.com/k-ymmt/MultiModuleSample/commit/4d85524e2bcc0af5729fb9ce58f5391c424dd605)。

次に [CocoaPods をあらかじめビルドしておくためのスクリプトを用意しました](https://github.com/k-ymmt/MultiModuleSample/commit/30c46eafbbc48b9011daa8dcd5ca572410381221)。


そして [Core モジュールで参照するための .xcconfig を用意します](https://github.com/k-ymmt/MultiModuleSample/commit/14346ff0c698c0d04a8a0ba0de45378b7e5be130)。

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 に参照を追加すれば終わりです](https://github.com/k-ymmt/MultiModuleSample/commit/a427c3d288d240ffa2f009a462796bc01c23cd68)。

今回も `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://github.com/k-ymmt/MultiModuleSample/commit/8abdf8f034c41780c2f75dd6c7088f0a718df674)。

```sh
# ほぼ 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 できるようになりました！

<details>
<summary>ちなみに</summary>
<div>

本当は モジュールを使う `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` でコピーする処理を書きました。
</div>
</details>

## Archive できない
これで実行できるようになったので、試しに Archive をしてみると以下のようなエラーが発生します。

![Screen Shot 2019-12-16 at 0.00.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282900/7ffb31b3-c6a4-a06e-8684-4fc4e3af6eda.png)


エラー画面に一切の情報がない上にログの中にある `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` の参照方法は以下のようになっています。

![Screen Shot 2019-12-16 at 0.10.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282900/ce9c5c12-2c96-95e8-2c9f-1b4a379b2c75.png)


はい、 Embed になっていますね。  
この場合、各モジュールはそれぞれ `モジュール名.framework/Frameworks/` 内に `Common.framework` をコピーします。
なので `Common.framework` が .app に複数含まれてエラーになっていたっぽいです。

この解消法は単純に、 [エントリポイントである MultiModuleSample プロジェクト以外の全ての Common モジュールを参照するプロジェクトの Embed を 「Do Not Embed」 にするだけです](https://github.com/k-ymmt/MultiModuleSample/commit/a7114bcac2486169202d26042e6f1cd84c085922)。

結果・・・

![Screen Shot 2019-12-16 at 0.22.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282900/919718fc-604d-e457-434f-c530224f866c.png)


Archive に成功しました！

<details>
<summary>ちなみに</summary>
<div>

Xcode 10 までは Embed と Link Framework の欄が別々で、見るところだけ気をつければいいだけでした。

Xcode 11 で Embed と Link Framework が合体してデフォルトが Embed になったので、ついポンポン Link させると罠にハマります(ハマりました)。
</div>
</details>

## 余計なモジュールが含まれている
上の Archive を見ると必要そうなモジュールのみ含まれているように見えますが、実際にできた .ipa をバラしてみると `ModuleA.framework` と `ModuleB.framework` が含まれています。

![Screen Shot 2019-12-16 at 0.50.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282900/19712ff5-609b-8233-1f48-6b9e4b962dc6.png)


上記2つは最初に書いた通り、 Release ビルド時は Static Library としてビルドしているはずなので .framework が含まれる必要はありません。

なので [ModuleA ・ ModuleB についても上記のように Embed をやめます](https://github.com/k-ymmt/MultiModuleSample/commit/becf04600c4da51c5b7e00e90e643d16db97836b)。

ただ、このままでは Debug ビルド時に逆に .framework が含まれなくなってしまい、また image not found が発生してしまいます。
なので、今回も前回のように手動コピーさせます。

```sh
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
```

ただ、このまま実機でデバッグを実行した際、以下のようなメッセージが出てインストールに失敗します。

![Screen Shot 2019-12-16 at 0.54.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282900/f00b595e-b4ba-9a2e-e037-d551a353c4ce.png)


またもや優しさのかけらもないメッセージですね。
ちなみに Details をクリックすると以下のように詳細を教えてもらえました。

![Screen Shot 2019-12-16 at 0.59.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282900/4b658d19-05a1-b626-a6ff-2f649d9ca515.png)


なるほど、わからん。

ただ、 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」 に変更してみました](https://github.com/k-ymmt/MultiModuleSample/commit/bbbf397a74ab5a82af0eec40708e4fa7523bd2d0)。

うまくいきました。

なるほど、わからん。

## リソースをコピーしたい
ここまでで実行できるようになりましたが、 Release ビルドを Static Library にしたせいでもし `ModuleA` で .xib などリソースファイルを使った場合、 .app に含まれません。
なので[これも手動でコピーしてやる必要があります](https://github.com/k-ymmt/MultiModuleSample/commit/de0a7bcf789c97e65fa3195bed0cb7adcda0d60d)。

これも上に書いた .framework をコピーするのと同じ要領でいけます。

```sh
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 をこまめにしていこう(反省)
- 最後に頼りになるのはやっぱり勘
  - なんやかんや頼りになる