iOS の 0x8badf00d ウォッチドッグ強制終了に Rork アプリが落ちるときの対処
App Store に出した直後だけ、レビューに「起動してすぐ消える」と書かれます。手元の Xcode でも Simulator でも再現しません。Crashlytics に残っているのは EXC_CRASH (SIGKILL) と例外コード 0x8badf00d。Apple が「ate bad food」と読ませる有名なシグネチャで、ウォッチドッグタイムアウトの印です。
厄介なのは、この落ち方が「自分のコードのバグ」の顔をしていないことです。スタックトレースの最上位にいるのは大抵サードパーティ SDK の初期化関数で、その関数自体は正しく動いています。iOS が待ちきれずにプロセスごと切った、というだけなのです。
React Native / Expo / Rork のように JS バンドルの評価が起動経路に乗る構成では、ネイティブ単体のときより余白がありません。SDK を一つ足すたびに確率が上がっていきます。原因の切り分け方と、運用中のアプリで実際に効いた対処を順に整理します。
0x8badf00d とは何が起きているのか
iOS は application(_:didFinishLaunchingWithOptions:) が返るまでに使ってよい時間を、デバイスの状態に応じて 5〜20 秒の範囲で動的に決めています。実効的には新しめの端末でも 10 秒前後、古い端末や寒い場所、バッテリー残量が低いとさらに短くなります。この上限を越えると iOS は理由を問わずプロセスを SIGKILL で落とし、例外コード 0x8badf00d を残します。
React Native / Expo の場合、JS スレッドが Hermes でバンドルを評価して最初の requireApplicationRegister を終えるまでの全部が、この計測に乗ります。Rork で量産したアプリでも、ネイティブ側で AdMob・AppOpen・Firebase・RevenueCat・Sentry・Branch を全部同期初期化していると、新しい iPhone でも余裕で 8〜9 秒に届いてしまい、古い端末では落ちます。
ポイントは、開発機で測ったコールドスタートが「2 秒」だから安全、ではないということです。ウォッチドッグの基準は端末側のリアルタイムの状態で決まります。Crashlytics の Apple 環境別クラッシュレートを見ると、私のアプリでもこのタイプは iPhone 8 / SE 第二世代 / iPad 第七世代に集中していました。
クラッシュログから 0x8badf00d を断定する
Crashlytics や Xcode Organizer の Crashes パネルで以下を確認すると、ほぼ確定で切り分けられます。
- Exception Type:
EXC_CRASH (SIGKILL) - Exception Codes:
0x8badf00d - Termination Reason:
FRONTBOARD 0x8badf00d ... (scene-create watchdog transgression: ...) - Triggered by Thread の最上位スタックが
RCTCxxBridge,facebook::hermes::HermesRuntime,expo-modules-core, あるいは AdMob / Firebase の+[FIRApp configure]など、起動経路の関数
Triggered by Thread: 0 でメインスレッドが固まっている、というのが典型です。JS スレッドや IO スレッドが原因でも、メインスレッドがそれを待っていることが多いです。
制限時間は推測しなくていい — ログに書いてある
見落とされがちなのですが、Termination Reason の文字列にはその場で与えられた猶予そのものが入っています。
Termination Reason: FRONTBOARD 2343432205
<RBSTerminateContext| domain:10 code:0x8BADF00D explanation:
scene-create watchdog transgression: application<com.example.app>:9312
exhausted real (wall clock) time allowance of 19.43 seconds>
exhausted real (wall clock) time allowance of 19.43 seconds の数字が、その端末・そのタイミングで iOS が実際に与えた時間です。何秒までに返せばいいのかを推測する必要はありません。複数のクラッシュを並べてこの値を書き出すと、自分のアプリが実際に戦っている下限が見えてきます。私の手元では、同じビルドでも 19 秒台から 9 秒台まで振れていました。
もう一つ重要なのは、0x8badf00d は起動時だけのコードではないという点です。どのライフサイクルで切られたのかは explanation の先頭で切り分けます。
| explanation の先頭 | 切られた場所 | まず疑うもの |
|---|---|---|
scene-create watchdog transgression | 起動から最初のシーン生成まで | AppDelegate の同期初期化・JS バンドルの評価 |
scene-update watchdog transgression | フォアグラウンド復帰などのシーン更新 | 復帰時の同期 IO・AppOpen 広告の同期 preload |
process-exit watchdog transgression | 終了処理 | applicationWillTerminate 内の保存処理 |
起動経路を直したのにクラッシュが減らない、というときは scene-update 側だった、というのが実際よくあります。手を入れる前に explanation を読み分けてください。
まず疑う 4 つのブロッキング処理
私が踏んだ実例から、Rork 構成で 0x8badf00d を引き起こす原因の 9 割は次のどれかでした。
- AppDelegate での SDK の連続同期初期化: AdMob, Firebase, RevenueCat, Branch, Sentry を「念のため起動直後に全部」初期化している
- MMKV / AsyncStorage / SQLite からの同期読み込み: ユーザー設定、テーマ、A/B フラグなどを
didFinishLaunching内の同期 IO で取ろうとしている - AppOpen 広告の同期 preload: AdMob の
GADAppOpenAd.loadをネイティブ側でawait相当の処理に組んでしまっている - JavaScript の初回 import の重さ:
App.tsxのトップで巨大な定数モジュール(多言語 JSON、配列でハードコードされた壁紙メタ 1 万件など)を import している
特に 1 と 3 は、Rork で生成された雛形をそのまま使ってしまうと起こりやすいパターンです。SDK 各社のドキュメントが揃って「起動直後に呼べ」と書いているせいで、結果として全部が直列に積み上がります。
直し方:起動経路から重い処理を全部追い出す
ネイティブ側(AppDelegate / ExpoAppDelegateSubscriber)
// 危険:すべてが didFinishLaunching に同期で乗っている
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[FIRApp configure];
[GADMobileAds.sharedInstance startWithCompletionHandler:nil];
[RCPurchases configureWithAPIKey:@"YOUR_REVENUECAT_KEY"];
[Branch.getInstance initSessionWithLaunchOptions:launchOptions
andRegisterDeepLinkHandler:^(NSDictionary *params, NSError *error){}];
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}これを次のように、必須のものだけ残して残りは dispatch_after または最初のフレーム描画後に押し出します。
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// クラッシュレポートだけは最優先で先に上げる
[FIRApp configure];
// 残りは UI が出てから初期化
dispatch_async(dispatch_get_main_queue(), ^{
[GADMobileAds.sharedInstance startWithCompletionHandler:nil];
[RCPurchases configureWithAPIKey:@"YOUR_REVENUECAT_KEY"];
[Branch.getInstance initSessionWithLaunchOptions:launchOptions
andRegisterDeepLinkHandler:^(NSDictionary *params, NSError *error){}];
});
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}Firebase だけ先に動かすのは、AppOpen 広告や RevenueCat 側でクラッシュが起きてもログを拾えるようにするためです。
JavaScript 側
// App.tsx — 重い import をルートからは外す
import { useEffect, useState } from "react";
import { View } from "react-native";
export default function App() {
const [ready, setReady] = useState(false);
useEffect(() => {
// 最初の描画が終わってから残りを読み込む
(async () => {
const [{ initAnalytics }, { initRemoteConfig }] = await Promise.all([
import("./bootstrap/analytics"),
import("./bootstrap/remote-config"),
]);
await Promise.all([initAnalytics(), initRemoteConfig()]);
setReady(true);
})();
}, []);
return <View>{/* スプラッシュ相当の軽い UI をまず出す */}</View>;
}Promise.all で並列にし、await import() を使ってバンドルから初回起動の依存を切り離します。Hermes は遅延 require と相性がよく、私の壁紙アプリでは平均コールドスタートが 2.4 秒 → 1.3 秒に縮みました。AdMob の eCPM や DAU に直接効くタイプの改善ではないですが、0x8badf00d のリテンション悪化分は確実に取り戻せます。
TestFlight では出ないクラッシュをローカルで再現する
ウォッチドッグの厳しさは「コールド起動 + 低バッテリー + 低温 + 悪い回線」で増します。手元で意図的に再現したいときは、次の条件を重ねます。
- iPhone 実機を放電 20% 以下にしてから airplane mode 解除
- 「設定 → デベロッパ → Network Link Conditioner」で
Very Bad Networkを有効化 - アプリを完全に閉じてから(フォアグラウンドにある状態ではダメ)アイコンタップ
これだけで、開発機で再現しなかった iPhone 8 のクラッシュを 5 回中 3 回は引けます。Firebase Test Lab の physical デバイスで --orientation portrait --device-ids iphone8 を指定して回す手もありますが、コストが嵩むので、私はこのローカル法を先にやります。
「直った」を実機の分布で確かめる — MetricKit
ローカル再現は当たりを付けるには有効ですが、直ったと言い切るには手元の 1 台では足りません。実際のユーザーの端末で起動時間がどう散らばっているかは、MetricKit が日次で返してくれます。
MXAppLaunchMetric の histogrammedTimeToFirstDraw は、その日の起動を「何秒台が何回」というヒストグラムで渡してきます。平均ではなく分布で見えるのが利点です。裾が 8 秒に張り付いているなら、ウォッチドッグに届いている端末が確実にいます。
import MetricKit
final class LaunchMetricsSubscriber: NSObject, MXMetricManagerSubscriber {
static let shared = LaunchMetricsSubscriber()
func start() {
MXMetricManager.shared.add(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
guard let histogram = payload.applicationLaunchMetrics?
.histogrammedTimeToFirstDraw else { continue }
for case let bucket as MXHistogramBucket<UnitDuration> in histogram.bucketEnumerator {
let start = bucket.bucketStart.converted(to: .seconds).value
let end = bucket.bucketEnd.converted(to: .seconds).value
guard start >= 5 else { continue } // 危険域だけを送る
Analytics.logEvent("slow_launch", parameters: [
"bucket": "\(start)-\(end)",
"count": bucket.bucketCount,
])
}
}
}
// ウォッチドッグに切られた理由の文字列も MetricKit 経由で拾える(iOS 14 以降)
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
for crash in payload.crashDiagnostics ?? [] {
guard crash.exceptionCode?.intValue == 0x8badf00d else { continue }
Analytics.logEvent("watchdog_kill", parameters: [
"reason": crash.terminationReason ?? "unknown",
])
}
}
}
}start() は didFinishLaunching の中で呼んで構いません。MXMetricManager.shared.add は購読の登録だけで、この記事で追い出そうとしている類の処理は走らないためです。
Expo / Rork の構成でも、この Swift ファイルを config plugin 経由でネイティブターゲットに足せば動きます。JS 側へ橋渡しする必要はなく、Analytics に直接投げてしまうのが一番手数が少ないです。
ただし運用上の制約が一つあります。MetricKit のペイロードは 1 日 1 回、しかも次にアプリが起動されたときにまとめて配送されます。リリース当日に結果は出ません。段階配信を 24 時間止めて待つ、という運用とセットで初めて意味を持ちます。ここを知らずに「翌朝データが無い=実装ミス」と判断して、私は半日を溶かしました。
0x8badf00d が出ない構成を保つチェックリスト
リリース前に Xcode の Instruments → App Launch テンプレートで毎回計測しておくと、SDK を一つ足したときに気付けます。私の場合、各リリースブランチで次の 3 点を CI に乗せています。
- App Launch (Pre-main + Time to first frame) が新 iPhone で 1.8 秒以下、SE 第二世代で 3.5 秒以下に収まっていること
- AppDelegate の
didFinishLaunching内に新規await/ 同期dictionaryWithContentsOfURL:系を入れていないこと package.jsonのdependencies差分で、起動経路から呼ばれる重量 SDK が増えていないこと(増えるなら遅延化)
Crashlytics 側では EXC_CRASH (SIGKILL) 0x8badf00d を別アラートに切り出し、リリースごとに 0.05% を超えたら段階配信を止める、というルールにしています。
次にやること
クラッシュログから 0x8badf00d だと確定したら、まず AppDelegate を覗いて、didFinishLaunching から押し出せる SDK 初期化を 1 つ選んで dispatch_async(main_queue, …) に移してください。1 個押し出すだけでも、SE 第二世代クラスでは目に見えてコールドスタートが軽くなります。続けて JS 側の App.tsx から最重量の import を 1 つ遅延読み込みに置き換えると、ウォッチドッグの余白がはっきり広がります。
同じ問題で困っている方の参考になればうれしいです。お読みいただきありがとうございました。