壁紙アプリの iOS 27 対応を進めていた夜、app.json に ios.enableSceneSupport を足して npx expo prebuild --clean を回しました。返ってきたのは一行だけでした。
ios.enableSceneSupport requires the standard Expo SDK 57 Swift AppDelegaterequires という語を見て、私は「版が足りないのだ」と読みました。expo を上げ直し、npx expo install --fix も通しました。それでも同じ行で止まります。手が止まったまま、ログを上から読み直しました。
原因は版ではなく、ios/<プロジェクト名>/AppDelegate.swift の形のほうにありました。requires が指していたのは「版」ではなく「形」だったのだと気づいたのは、三度目の再実行が空振りしたあとでした。
エラーが指しているのは版ではなく AppDelegate の形です
expo-build-properties のシーン対応は、SDK 57 のテンプレートそのままの AppDelegate を書き換える前提で作られています。プラグインが探すのは三つだけです——クラス宣言の行、window = UIWindow(...) の行、そして factory.startReactNative(...) の行です。以前の実装は、この三つをひと続きの文字列として照合していました。
ここに @react-native-firebase/app の config plugin が入ると、FirebaseApp.configure() が factory.startReactNative( のすぐ上へ挿し込まれます。挿された一行でひと続きの一致が崩れ、プラグインは「テンプレートではない」と判断して止まっていたのです(expo/expo#50210)。
// ios/YourApp/AppDelegate.swift(Firebase の config plugin が入ったあと)
#if os(iOS) || os(tvOS)
window = UIWindow(frame: UIScreen.main.bounds)
// ← この一行が挿し込まれ、ひと続きの照合が崩れていました
FirebaseApp.configure()
factory.startReactNative(withModuleName: "main", in: window, launchOptions: launchOptions)
#endifつまりエラーの主語は SDK でも Xcode でもなく、自分のプロジェクトに入っている別のプラグインでした。私自身、ここに気づくまで expo の版を三度上げ直しております。
プラグインがここまで厳しいのには理由があります。Swift のコードを構文木として解析しているのではなく、文字列として探して差し替えているのです。ですから照合が外れたときに黙って書き換えを続けるより、例外を投げて止まるほうが安全です。中途半端に当たった AppDelegate は、ビルドは通るのに起動しない——という、いちばん時間を奪われるかたちで表に出てまいります。
まず確かめる3行
プラグインの言う「標準の AppDelegate」が手元に残っているかは、prebuild を回す前に確かめられます。個人開発でプラグインを足したり外したりしていると、この三行のどれかが静かに書き換わっていることがあるのです。
# ios/ をコミットしている場合のみ有効です(CNG なら prebuild 後に確認します)
APP_DELEGATE=$(ls ios/*/AppDelegate.swift | head -1)
# 1. クラス宣言の行(ExpoAppDelegate を継承しているか)
grep -n "class AppDelegate" "$APP_DELEGATE"
# 2. window の生成行
grep -n "window = UIWindow" "$APP_DELEGATE"
# 3. React Native の起動行
grep -n "startReactNative" "$APP_DELEGATE"
# 期待する出力: 3 つのコマンドがそれぞれ 1 行を返します
# どれかが 0 行なら、プラグインは書き換えを諦めて例外を投げますgrep が何も返さない行があれば、そこが止まっている理由です。三行とも返るのに止まる場合は、行と行のあいだに誰かが挿し込んでいます。grep -n -A2 "window = UIWindow" で前後を目で見るのがいちばん早い確認になります。
上げれば済む場合と、手で書く場合
この件の修正は expo/expo#50221 で取り込まれ、npm へも公開されています。プラグインは三つをひと続きで照合するのをやめ、window の文と startReactNative の文を個別に外すようになりました。あいだに挿された行はそのまま残ります。
ですから順番としては、expo-build-properties を最新にして prebuild を回し直すのが先です。前提として expo@57.0.23 以上が必要で、シーンのランタイムが SDK 57 へ戻し入れられたのはこの版です。それより古い 57 系ではプラグインが例外を投げますので、npx expo install --fix を先に通します。
もう一つ、順番を決める前に確かめておきたいことがあります。この opt-in が必要になるのは、Xcode 27 と iOS 27 SDK でビルドする場合です。EAS Build の latest イメージは執筆時点で Xcode 26.6 のままで、27 は近日とされています。つまり手元の Xcode 27 で試している方が先に踏む段差で、クラウドのビルドだけで回している方はもう少し猶予があるのです。急いで立てるより、SDK 58 の安定版を待って既定に乗るという選択も十分に筋が通ります。
それでも同じ行で止まるなら、残っている可能性は二つです。AppDelegate.swift を自分の手で編集している場合と、Info.plist に既にシーンマニフェストを書いている場合です。どちらもプラグインは上書きを拒み、手で適用してほしいと告げてきます。公式の案内は Expo の iOS scene life cycle ガイドにまとまっています。
手で書くときに SDK 58 の手順をそのまま真似ないこと
ここが私のいちばんの読み違いでした。同じガイドの中に手で移行する節があり、そこには SceneDelegate.swift を自分で作り、UISceneDelegateClassName に $(PRODUCT_MODULE_NAME).SceneDelegate を書くと案内されています。私はそれを写して、起動しないビルドを一本作りました。
あれは SDK 58 の手順です。SDK 57 の opt-in では SceneDelegate.swift は生成されません。指すべきは Expo に組み込まれた EXExpoAppSceneDelegate のほうなのです。
<!-- ios/YourApp/Info.plist — SDK 57 で手で入れる場合 -->
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<!-- SDK 58 の $(PRODUCT_MODULE_NAME).SceneDelegate ではありません -->
<key>UISceneDelegateClassName</key>
<string>EXExpoAppSceneDelegate</string>
</dict>
</array>
</dict>
</dict>同じ文字列を書く場所なのに、指す先が版で変わります。並べると違いは三つだけです。
| 項目 | SDK 57(opt-in) | SDK 58(既定) |
|---|---|---|
| 有効化の方法 | ios.enableSceneSupport: true | 既定で有効(プロパティは無効化され警告のみ) |
| SceneDelegate.swift | 生成されません | prebuild が生成します |
| UISceneDelegateClassName | EXExpoAppSceneDelegate | $(PRODUCT_MODULE_NAME).SceneDelegate |
このプロパティが実際に書き換えるもの
true にしたときに prebuild が触るのは三箇所です。AppDelegate を ExpoReactNativeFactoryProvider に準拠させ、didFinishLaunchingWithOptions から従来の起動処理を外し、Info.plist に EXExpoAppSceneDelegate を指すシーンマニフェストを足します。false に戻せば三箇所とも元へ戻ります。逆に言えば、この三箇所のどれかを自分で手当てしているプロジェクトでは、プラグインに任せる前提が崩れているということです。
シーンのライフサイクルへ移ると、UIKit は AppDelegate のいくつかのメソッドを呼ばなくなります。URL を受ける application(_:open:options:)、Handoff の application(_:continue:restorationHandler:)、そして applicationDidBecomeActive(_:) から applicationWillEnterForeground(_:) までの前後面の通知がそれに当たります。クイックアクションの application(_:performActionFor:completionHandler:) も同じ扱いです。
ここが分かれ道になります。config plugin が ExpoAppDelegateSubscriber を使って書かれているなら、シーン側のイベントは Expo が転送してくれますので、そのままで動きます。ExpoAppDelegate の上でメソッドを override している場合も、super を呼び続けていれば転送されます。手当てが要るのは、UIApplicationDelegate を直に握っているコード——たとえば UIKit が呼んでくれる前提で applicationDidBecomeActive(_:) を待っているライブラリだけです。そうしたコードは sceneDidBecomeActive(_:) 相当へ移すか、購読側へ寄せることになります。
false に戻す日のために
一度立てたプロパティを戻すこともあります。その戻し方にも段差がありました。無効化したときに起動処理をまとめて factory の代入の下へ書き戻す実装だったため、他のプラグインが #if os(iOS) のラッパーの中に挿していた行が、下に別のラッパーとして取り残されていたのです。こちらも同じ PR の二つ目のコミットで直っています。
立ったのに起動しないときの見どころも、あわせて書き残しておきます。iOS 27 で真っ黒な画面のまま止まるなら、シーンマニフェストが入っていないか、UISceneDelegateClassName の綴りが解決できていない場合がほとんどです。アプリは開くのに Linking.getInitialURL() が null を返すなら、コールドスタート時の URL 受け渡しのほうを疑います。同じ URL が二度届くときは、自分の override が RCTLinkingManager へ渡している手動の経路が残っています。
設定を立てる前に、その設定が読む行を自分の目で見ておきます。 最初のうち、私はエラー文の主語を製品側に置きすぎました。結果は芳しくありません。いまは config plugin を足す前にそれが触るファイルを開き、既に誰かが手を入れていないかを確かめる順番にしております。Lab のサイトを更新するときにゲートを決まった順で通しているのと、やっていることは同じです。
今日できることは一つだけです。app.json の plugins を上から読み、AppDelegate を触るものに印を付けてください。それだけで、次に prebuild が止まったときの捜索範囲がぐっと狭まります。
上げる前に何が消えるのかを数える手順はExpo SDK 57 に上げる前に、prebuild で消えるネイティブ変更を洗い出すに、公開中のアプリを実機で先に触る方法はiOS 27 が正式版になる前に、予備の iPhone で公開中の Rork アプリを触っておくに書き残しております。棚卸しそのものを仕組みにしたい方はprebuild --clean で消える手入れを、上げる前に数える — Expo SDK 57 移行の棚卸しが近いところです。
同じ一行で手が止まっている方の、夜が少し短くなれば嬉しく思います。