ログオフ
開発2026-08-01

Expo の prebuild は、流し忘れても流しても壊れる——ネイティブ生成まわりの4つの落とし穴

ネイティブ依存を package.json に足しただけで配信すると起動即クラッシュする。原因が3層に分かれるうえ、prebuild を流せば流したで Info.plist の直書きが消える。バージョンが pbxproj ではなく Info.plist のリテラルで決まる件と、ライブラリ差し替えで申告義務が抜ける件も含めて4つ。

Expo の managed 構成でネイティブ依存を扱うと、prebuild を境に静かに壊れる箇所がいくつかある。流し忘れても壊れるし、流したら流したで消えるものがある。実際に踏んだ4つを、原因の層ごとに分けて残す。

1. 流し忘れて配信すると、起動即クラッシュする

事象

ネイティブ依存を package.json に足し、JS 側の実装も書いた。型チェックは通る。ビルドも通る。配信したアプリを開くとスプラッシュの直後に落ちる。

原因

prebuildpod 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 を流したら、そのキーが消えていた。輸出コンプライアンスの申告など、一度書いたら忘れているものほど気づかない。

原因

prebuildios/ をテンプレートから作り直す。手で足したものは残らない。ios/ は生成物であって、編集対象ではない。

解決策

Info.plist に入れたいキーは app.json(または app.config.js)の ios.infoPlist 側へ移す。

{
  "expo": {
    "ios": {
      "infoPlist": {
        "ITSAppUsesNonExemptEncryption": false
      }
    }
  }
}

一度移してしまえば、以後の prebuild で消えない。

3. バージョンは Info.plist のリテラルで、pbxproj を直しても効かない

事象

新しいビルドを配信しようとして project.pbxprojMARKETING_VERSIONCURRENT_PROJECT_VERSION を上げた。それでもアップロードが弾かれる。

CFBundleShortVersionString ... must contain a higher version

原因

expo prebuild が生成する Info.plist は、CFBundleShortVersionStringCFBundleVersion数値のリテラルで直書きする。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.jsonversionios.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.jsoninfoPlist に用途文字列を足すだけで通る。実際に権限を要求しなければ、ユーザーにダイアログは出ない。

{
  "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 に寄せる。これに切り替えてから、この種の事故は起きていない。

関連記事

開発 一覧へ