Expo の prebuild は、流し忘れても流しても壊れる——ネイティブ生成まわりの4つの落とし穴
ネイティブ依存を package.json に足しただけで配信すると起動即クラッシュする。原因が3層に分かれるうえ、prebuild を流せば流したで Info.plist の直書きが消える。バージョンが pbxproj ではなく Info.plist のリテラルで決まる件と、ライブラリ差し替えで申告義務が抜ける件も含めて4つ。
Expo の managed 構成でネイティブ依存を扱うと、prebuild を境に静かに壊れる箇所がいくつかある。流し忘れても壊れるし、流したら流したで消えるものがある。実際に踏んだ4つを、原因の層ごとに分けて残す。
1. 流し忘れて配信すると、起動即クラッシュする
事象
ネイティブ依存を package.json に足し、JS 側の実装も書いた。型チェックは通る。ビルドも通る。配信したアプリを開くとスプラッシュの直後に落ちる。
原因
prebuild と pod install を流していないと、ネイティブ側にその依存が存在しない。厄介なのは、原因が3層に分かれることだ。
まず Podfile.lock に pod が入っていない。JS からは import できるように見えるが、ネイティブのモジュールが無いので実行時に落ちる。
次に Info.plist に必須キーが無い。広告 SDK などは、必要なキーが無いと意図的にアプリを落とす。これは仕様であってバグではないので、SDK 側のドキュメントを読むまで「なぜクラッシュするのか」が分からない。
3つ目が config plugin に null を渡した場合だ。プラグインの分岐が === undefined で書かれていると、JSON の null は素通りする。結果、値の無い <key> だけが書かれた不正な Info.plist が生成される。
<!-- 生成されてしまう壊れた形 -->
<key>SomeSDKAppID</key>
<!-- 対応する値が無い -->
解決策
依存を足したら prebuild を流す。当たり前だが、CI に載せていないと忘れる。
3層目は静的に検出できる。生成後の plist を lint にかければ一発で分かる。
plutil -lint ios/YourApp/Info.plist
config plugin に値を渡すときは、未設定を null ではなく「キーごと渡さない」で表現する。=== undefined の分岐に null が引っかからないのは、プラグイン側の実装依存なので、渡す側で避けるのが早い。
2. prebuild を流すと、Info.plist の直書きが消える
事象
ios/ を直接編集して Info.plist にキーを足していた。prebuild を流したら、そのキーが消えていた。輸出コンプライアンスの申告など、一度書いたら忘れているものほど気づかない。
原因
prebuild は ios/ をテンプレートから作り直す。手で足したものは残らない。ios/ は生成物であって、編集対象ではない。
解決策
Info.plist に入れたいキーは app.json(または app.config.js)の ios.infoPlist 側へ移す。
{
"expo": {
"ios": {
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
}
}
}
一度移してしまえば、以後の prebuild で消えない。
3. バージョンは Info.plist のリテラルで、pbxproj を直しても効かない
事象
新しいビルドを配信しようとして project.pbxproj の MARKETING_VERSION と CURRENT_PROJECT_VERSION を上げた。それでもアップロードが弾かれる。
CFBundleShortVersionString ... must contain a higher version
原因
expo prebuild が生成する Info.plist は、CFBundleShortVersionString と CFBundleVersion を数値のリテラルで直書きする。Xcode の build 設定($(MARKETING_VERSION) を参照する形)を使っていないので、pbxproj をいくら直しても反映されない。
解決策
Info.plist を直接書き換える。
plutil -replace CFBundleShortVersionString -string "1.0.1" ios/YourApp/Info.plist
plutil -replace CFBundleVersion -string "2" ios/YourApp/Info.plist
そもそもは app.json の version と ios.buildNumber を上げてから prebuild を流すのが本筋だ。急ぎで直すときだけ上のコマンドを使う。
あわせて踏みやすいのが、承認済みバージョンの pre-release train が閉じている件だ。同じバージョン番号では新しいビルドを受け付けてくれない。
Invalid Pre-Release Train ... is closed (90186)
パッチ番号を1つ上げれば通る。
4. ライブラリを差し替えると、旧側が黙って満たしていた申告義務が抜ける
事象
音の再生ライブラリを別のものに載せ替えた。アプリはマイクを一切使っていない。それでもアップロード後の処理で弾かれた。
ITMS-90683: Missing purpose string in Info.plist
NSMicrophoneUsageDescription
原因
新しい音声ライブラリが録音 API(録音カテゴリ、録音許可、キャプチャの実装)をリンクしている。使っていなくても、リンクされていれば用途文字列の申告が要る。
肝はここからで、旧ライブラリの config plugin が microphonePermission: false の指定でこれを黙って満たしていた。撤去した瞬間に欠落した。追加した側ではなく、外した側が原因だったので、差分を見ても最初は分からなかった。
解決策
app.json の infoPlist に用途文字列を足すだけで通る。実際に権限を要求しなければ、ユーザーにダイアログは出ない。
{
"expo": {
"ios": {
"infoPlist": {
"NSMicrophoneUsageDescription": "この App はマイクを使用しません。"
}
}
}
}
一般化すると、ライブラリの差し替えでは「入れたもの」だけでなく「外したものが担っていた責務」を見る必要がある。config plugin は Info.plist を書き換えるので、撤去すると書き換えごと消える。
補足
ios/ を触る前にバックアップを取っておくと、上の1と2から復帰しやすい。ios/ は gitignore されているので、git では戻せない。
# 除外パスは「転送ルートからの相対」なので、ios/ をルートにするなら /Pods/ と書く
rsync -a --exclude='/Pods/' ios/ /path/to/backup-ios/
除外パターンの先頭の / は転送ルート基準を意味する。ios/ をソースに指定した時点でルートは ios/ の中なので、ここで /ios/Pods/ と書くと何にもマッチせず、Pods ごと丸ごとコピーされる。
もう一つ、plist を読むときの罠がある。plutil -extract は抽出した値でそのファイルを上書きする。値を見るだけなら -o - で標準出力に出す必要がある。
❌ plutil -extract CFBundleVersion json ios/YourApp/Info.plist
✅ plutil -extract CFBundleVersion json -o - ios/YourApp/Info.plist
たちが悪いのは raw 形式のときだけ標準出力に出ることだ。plutil -extract CFBundleVersion raw Info.plist は無事故で通るので油断する。同じ調子で json を使った瞬間に Info.plist が「配列1個」の中身に変わる。壊れた plist はビルドツール側も落とすので、そこで初めて気づく。
復旧できたかは差分ゼロで確認する。
diff <(plutil -p backup/Info.plist) <(plutil -p ios/YourApp/Info.plist)
plutil -lint ios/YourApp/Info.plist
4つとも、共通しているのは ios/ を「編集するもの」と思っていると踏むという点だ。生成物として扱い、入力は全部 app.json に寄せる。これに切り替えてから、この種の事故は起きていない。