
ビルドは成功するのに、起動した瞬間に落ちる
Expo(React Native)で作った献立アプリを App Store に出すまでに、ネイティブまわりで何度か足を止められました。どれも「JS 側のコードは正しいのに、成果物が想定と違う」という種類の問題で、Expo のマネージドな体験に慣れているほど気づきにくい部類です。同じところで詰まる人のために、実際に起きたことと対処を書き残します。
対象は Expo SDK 54 / React Native 0.81、ウィジェットつきの iOS アプリです。
1. 設定ファイルが IPA に入っていなくて、起動時クラッシュした
一番痛かったのがこれです。Firebase Analytics を入れるために GoogleService-Info.plist をリポジトリに置き、app.json 側の設定も済ませてビルドしました。ビルドは成功し、TestFlight へのアップロードも通り、審査に出しました。結果は DEVELOPER_REJECTED。手元の実機で確認すると、起動した瞬間に落ちます。
原因は単純で、GoogleService-Info.plist が IPA に同梱されていませんでした。ファイルが無いまま FirebaseApp.configure() が呼ばれて例外を投げ、起動直後にクラッシュしていたわけです。
厄介なのは、この状態でもビルドは何のエラーも出さずに成功することです。ネイティブリソースは Xcode プロジェクトの Copy Bundle Resources に登録されていないと成果物に入りませんが、登録されていなくてもコンパイル自体は通ります。開発ビルドでは Metro 経由でファイルが解決されて動いてしまうこともあり、本番の IPA だけが壊れます。
対処:提出前に IPA の中身を見る
対処は「pbxproj の Copy Bundle Resources に登録する」ですが、それ以上に効いたのは、提出前に必ず成果物の中身を確認する手順を入れたことです。
上が1件ヒットしなければ、そのビルドは起動時に落ちます。下でビルド番号も一緒に確認しておくと、後述のバージョン事故も同時に潰せます。ネイティブリソースを追加したときは、毎回これを通すようにしました。
2. バージョン番号が3か所に散らばっていて、提出が 409 で止まる
Expo に慣れていると、バージョンは app.json の expo.version と expo.ios.buildNumber を上げれば済むと思いがちです。ところが ios ディレクトリをコミットして運用している構成では、app.json の値はビルド成果物に反映されません。実際に IPA へ埋め込まれるのは Info.plist 側の値です。
結果として、上げ忘れた古いビルド番号のまま提出することになり、App Store Connect に同じビルド番号が既にあるので 409 で弾かれます。
同時に更新が必要なのは次の3か所でした。
| ファイル | 変更箇所 |
|---|---|
| app.json | expo.version / expo.ios.buildNumber |
| ios/app/Info.plist | CFBundleShortVersionString / CFBundleVersion |
| ios/app.xcodeproj/project.pbxproj | MARKETING_VERSION / CURRENT_PROJECT_VERSION |
ウィジェット拡張を持っている場合は、拡張側のバージョンも本体と揃える必要があります。不一致だと Transporter の段階で拒否されます。pbxproj は該当箇所が複数あることが多いので、1か所だけ直して満足しないよう注意が要ります。
もうひとつ、バージョンを上げる前に App Store Connect の state を確認するのも大事でした。未リリースのバージョンが PREPARE_FOR_SUBMISSION や DEVELOPER_REJECTED で残っているなら、version は据え置いてビルド番号だけを上げるのが正解です。
3. ウィジェットを足したら、prebuild が使えなくなった
ホーム画面ウィジェットは WidgetKit の Swift 実装で、App Group を通してアプリ本体とデータを共有します。Expo からこれを扱う方法はいくつかありますが、最終的に「Xcode のターゲットを手動で追加して、ios ディレクトリごとバージョン管理する」形に落ち着きました。
理由は、自動生成に任せると再生成のたびにターゲットの構成が揺れるからです。とくに expo prebuild --clean は ios を作り直すため、手で足したウィジェットターゲットが消えます。一度これで壊してからは、prebuild --clean は使わない運用にしました。
この構成で注意が要るのは、app.json と実際のビルドが乖離することです。
- config plugin の設定を app.json に足しても、pbxproj に反映されない限りビルド成果物には入らない
- .gitignore に ios があっても、project.pbxproj と ios/app/Info.plist は force-add で追跡している
- つまり「app.json を直したから大丈夫」は成立しない
Expo の便利さを一部手放す判断ですが、ネイティブ拡張を持つアプリでは、成果物が毎回同じであることのほうが価値が高いと感じています。
4. iCloud 同期は「上限」と「復元しない理由」で決まる
機種変更でデータが消えるのが怖かったので、iCloud の NSUbiquitousKeyValueStore に全データを同期しています。サーバーを持たない個人開発アプリだと、この選択は相性が良いのですが、2点だけ設計が必要でした。
ひとつは容量上限です。KVS は 1MB までしか置けません。献立と料理履歴が増えると現実的に到達しうるサイズなので、900KB を超えるデータは push しない、というガードを入れています。マルチバイト文字を含むため、文字数ではなく UTF-8 のバイト長で数えるのがポイントです。編集のたびに書き込むと無駄が多いので、3秒のデバウンスでまとめて送っています。
もうひとつは復元の条件です。起動時に無条件で iCloud の内容を書き戻すと、別端末の古いデータでローカルを壊す事故が起きます。実装では、ローカルが空か、iCloud 側が新しいときだけ上書きするようにしました。
さらに、復元しなかった理由をユーザーに出し分けています。設定画面から手動復元したとき「何も起きない」のが一番不安なので、戻り値を次のように分けました。
同期の成否をブール値で返すと、この出し分けが後から書けなくなります。UI に出す文言が分岐するものは、最初から列挙型で返しておくと楽でした。
まとめ
4つとも共通しているのは、JS 側のコードを読んでも原因が見つからないことです。Expo は普段ネイティブを意識させない代わりに、意識すべき瞬間の境目が見えにくい。私の場合は、次の3つを習慣にしてから事故が止まりました。
- 提出前に IPA を unzip して、入っているはずのファイルとビルド番号を目で確認する
- バージョンは app.json だけでなく Info.plist と pbxproj を必ず同時に触る
- ネイティブターゲットを持ったら prebuild --clean は封印し、ios をバージョン管理下に置く
同じ構成でこれから出す人の時間が少しでも減れば嬉しいです。作ったアプリは献立カートという、1週間の献立から買い物リストを自動生成する iOS アプリです。実物はこちらにあります。