「iOS 18でアプリが起動しない」を追いかけたら、WKWebViewのサイレント失敗にたどり着いた
Capacitor製iOSアプリがiOS 18でスプラッシュ画面のまま起動しない不具合を調査。WKWebViewに相対URLを渡した際のiOS 18とiOS 26の挙動差から、サイレント失敗という厄介な問題を発見した記録。
「アプリが起動しないんですけど…」
ユーザーさんから報告をもらって、手元の端末で確認したら普通に動く。嫌な予感がしました。
症状
報告してくれた方によると、アプリを起動するとスプラッシュ画面が表示されたまま、いつまで経っても先に進まない。再起動しても同じ。
このアプリはCapacitor(WebViewベースのハイブリッドアプリフレームワーク)で作っていて、起動時の流れはこうなっています。
- ネイティブのスプラッシュ画面を表示
- WKWebViewがWebアセットを読み込む
- Reactアプリが初期化される
- 初期化完了後、JavaScript側から
SplashScreen.hide()を呼ぶ
つまり、2か3のどこかで止まっている。
最初に疑ったこと
Capacitorアプリでは launchAutoHide: false を設定して、スプラッシュ画面の非表示をアプリ側で制御しています。
// capacitor.config.ts
plugins: {
SplashScreen: {
launchAutoHide: false,
launchShowDuration: 30000,
},
},
なので、JavaScriptが実行されないとスプラッシュ画面が永遠に表示されたままになります。
問題は、「スプラッシュ画面で止まっている」という情報以外に手がかりがないこと。起動フローのどこで止まっているのか、エラーが出ているのかすら分からない状態でした。認証周りの初期化処理を疑ってみたり、WebViewの読み込みタイミングを調べてみたりしましたが、手元の端末では再現しないので検証のしようがない。
転機: iOSアップデートで直った
問題が起きていた方がiOS 18からiOS 26にアップデートしたタイミングで、もう一度試してもらったところ、何もしていないのにアプリが起動するようになった。
iOS 18固有の問題ということがわかったので、Xcodeのシミュレータで再現を試みました。iOS 18のシミュレータをダウンロードしてビルドしたところ、見事に再現。スプラッシュ画面から永遠に画面が変わらない。
シミュレータで再現できたことで、ようやくXcodeのデバッグログを見ながら調査できるようになりました。
原因の特定
Xcodeのコンソールログを見ていると、気になる出力を見つけました。
[Process] 0x12f831e18 - [PID=84018] WebProcessProxy::didClose: (web process 0 crash)
[ProcessSuspension] 0x12f0c9500 - UIProcessWakeLock:
Released works assertion "WebProcess Media Degit Lock" for PID 84018
WebViewのプロセスがクラッシュしている。
Info.plistのServerURL
ここでプロジェクトの構成を説明させてください。
このプロジェクトでは、ローカル開発時にはリモートの開発サーバーに接続し、本番環境ではアプリに同梱したWebアセットを使う構成をとっています。こうすることで、開発時は再ビルドなしに変更を即座に確認でき、本番ではネットワークアクセスなしに安定してアプリを起動できます。
ネイティブ側でWebViewの接続先URLを切り替えるために、Info.plistにServerURLを定義し、xcconfigで環境ごとに値を変えています。
// Info.plist
<key>ServerURL</key>
<string>$(SERVER_URL)/home</string>
// Local.xcconfig(開発環境)
SERVER_URL = http:/$()/localhost:5173
// Prod.xcconfig(本番環境)
// SERVER_URLは定義しない → 空文字になる
ローカル開発時は http://localhost:5173/home に接続し、本番環境ではローカルバンドル(dist/に同梱されたWebアセット)を使います。
$(SERVER_URL) が空だと何が起きるか
本番環境ではSERVER_URLを定義していないので、xcconfigの変数展開で空文字になります。すると $(SERVER_URL)/home は単に /home になる。
Swift側では、この値をもとにリモートサーバーへの接続先URLを取得しています。
private var customServerUrl: URL? {
guard let serverUrlString = Bundle.main.infoDictionary?["ServerURL"] as? String,
!serverUrlString.isEmpty,
let serverUrl = URL(string: serverUrlString) else {
return nil
}
return serverUrl
}
customServerUrl が nil ならローカルバンドルモード(アプリ同梱のWebアセットを使う)、URLが返ればそのURLに接続してWebアセットを取得する、という分岐です。
ここが厄介で、URL(string: "/home") は nil を返さないんです。nil であれば guard let で早期リターンして本番環境ではローカルバンドルモードに切り替わるはずなのに、有効なURLオブジェクトが返ってしまうため、そのまま処理が続行されてしまいます。
let url = URL(string: "/home")
// url != nil (有効なURLオブジェクトが返る)
// url.scheme == nil
// url.host == nil
// url.path == "/home"
SwiftのURL(string:)はRFC 3986準拠で、相対パスも有効なURLとして受け入れます。スキームもホストもないけど、URL型のインスタンスとしては有効。
その結果、customServerUrl が nil にならず、WKWebView.load(URLRequest(url: url)) にスキームなしの相対URLが渡されていました。
iOS 18 vs iOS 26: 同じバグなのに挙動が違う
ここからが今回の問題の核心なんですが、同じ相対URLをWKWebViewに渡した場合、iOSのバージョンによって挙動がまったく違いました。
WKWebViewのナビゲーション関連のコールバック(didFailProvisionalNavigation、didFinish、decidePolicyFor など)にログを仕込んで、iOS 18シミュレータとiOS 26シミュレータで同じコードを実行してみました。
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
withError error: Error) {
let nsError = error as NSError
print("[DEBUG] didFailProvisionalNavigation domain: \(nsError.domain), code: \(nsError.code)")
}
iOS 18の挙動
[DEBUG] webView.load called with: /home
[Process] 0x12f831e18 - [PID=84018] WebProcessProxy::didClose:
(web process 0 crash)
- OS内部で
NSOSStatusErrorDomain Code=-50(パラメータ不正)が発生 didFailProvisionalNavigationコールバックが呼ばれない- WebViewプロセスがクラッシュまたはデッドロック
- 以降、WebViewは完全に無反応
WKWebViewでは、ナビゲーションの失敗時に didFailProvisionalNavigation というコールバックでアプリに通知される仕組みがあります。iOS 18ではこのコールバックが呼ばれない。これが致命的でした。エラーが発生したことをアプリ側から検知する手段がなく、WebViewは沈黙したままフリーズします。
iOS 26の挙動
[DEBUG] webView.load called with: /home
[DEBUG didFailProvisionalNavigation] error domain: WebKitErrorDomain, code: 101
[DEBUG didFailProvisionalNavigation] error: The URL is not valid.
- WebKit内部で
WebKitErrorDomain Code=101(無効なURL)として処理 didFailProvisionalNavigationコールバックが正常に呼ばれる- Capacitorがエラーを検知してローカルアセットにフォールバック
- アプリは正常に起動する
同じ「無効なURLをloadに渡す」というバグなのに、iOS 18ではサイレントに失敗し、iOS 26ではちゃんとエラーとして報告される。
なぜiOS 26では動いたのか
iOS 26のWKWebViewは、無効なURLに対するエラーハンドリングが改善されていると考えられます。
iOS 18では、スキームのないURLが渡された場合、OS内部の低レベル層(NSOSStatusErrorDomain)でエラーが発生し、そのエラーがWebKitのコールバック層まで伝播しないまま、WebViewプロセスが異常終了していました。
iOS 26では、同じ状況でもWebKit層が先にURLのバリデーションを行い、WebKitErrorDomain Code=101 として適切にコールバックで通知するようになっています。これにより、Capacitorのエラーハンドリングが機能し、ローカルアセットへのフォールバックが動作します。
要はOS側のエラーハンドリング改善によって、運よくiOS 26ではバグったコードでも動いてしまっていた、ということでした。
修正
原因がわかれば修正はシンプルです。customServerUrl にスキームのバリデーションを追加しました。
private var customServerUrl: URL? {
guard let serverUrlString = Bundle.main.infoDictionary?["ServerURL"] as? String,
!serverUrlString.isEmpty,
let serverUrl = URL(string: serverUrlString),
// http/httpsスキームを持つ絶対URLのみ許可
let scheme = serverUrl.scheme,
scheme == "http" || scheme == "https" else {
return nil
}
return serverUrl
}
本番環境ではxcconfigのSERVER_URLが空なので /home になりますが、スキームが nil のため customServerUrl は nil を返す。結果、ローカルバンドルモードで正常に起動します。
教訓
URL(string:) は思ったより寛容
SwiftのURL(string:)は、人間の直感に反する入力でもnilを返さないことがあります。
URL(string: "/home") // scheme: nil, host: nil, path: "/home"
URL(string: "home") // scheme: nil, host: nil, path: "home"
URL(string: "//example.com") // scheme: nil, host: "example.com"
「URL(string:) が nil じゃなければ有効なURL」という前提は危険。用途に合ったバリデーション(スキームの存在チェックなど)を明示的に行うべきです。
再現環境の確保が最優先
今回、一番時間がかかったのは原因特定ではなく再現環境の確保でした。シミュレータで再現できてからは、デバッグログを見ればWebViewプロセスのクラッシュはすぐ目に入りましたし、URL(string:) の挙動も調べればすぐわかりました。
逆に言えば、再現できない間は何もできなかった。「手元では動くんだけど…」という状況でどう再現環境を作るかが、この手のバグでは一番大事なポイントだと思います。
複数のiOSバージョンで検証する
今回のように、同じWKWebViewでもiOSのバージョンによって動作が変わるケースがあります。一つのバージョンで動いているからといって安心はできません。
Xcodeのシミュレータを使えば、古いiOSバージョンでの動作確認も手軽にできます。特にCapacitorのようなハイブリッドアプリはWKWebViewに依存しているので、複数のiOSバージョンでの検証を習慣にしておきたいところです。