iOS の 0x8badf00d ウォッチドッグ強制終了に Rork アプリが落ちるときの対処
App Store に出した直後だけ、レビューが「起動してすぐ消える」と報告してくる。手元の Xcode や Simulator では再現しありません。Crashlytics には EXC_CRASH (SIGKILL) と例外コード 0x8badf00d。これは Apple が「ate bad food」と読ませる有名なシグネチャで、ウォッチドッグタイムアウトの印です。
私は 2014年から個人で iOS / Android アプリを運用していて、累計 5,000万 DL を越えた壁紙・癒し・引き寄せ系アプリでも、SDK を増やすたびにこの 0x8badf00d を踏みました。React Native / Expo / Rork のように JS バンドルを読み込む構成では、ネイティブ単体のときよりさらに発生しやすいので、原因の切り分け方と、私の手元で効いた対処を整理しておきます。
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 スレッドが原因でも、メインスレッドがそれを待っていることが多いです。
まず疑う 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 では出ないクラッシュをローカルで再現する
ウォッチドッグの厳しさは「コールド + 低バッテリー + 低温」で増します。手元で意図的に再現したいときは、Xcode の Device メニューから次の Network Link Conditioner を組み合わせます。
- iPhone 実機を放電 20% 以下にしてから airplane mode 解除
- 「設定 → デベロッパ → Network Link Conditioner」で
Very Bad Networkを有効化 - アプリを完全に閉じてから(フォアグラウンドにある状態ではダメ)アイコンタップ
これだけで、開発機で再現しなかった iPhone 8 のクラッシュを 5 回中 3 回は引けます。Firebase Test Lab の physical デバイスで --orientation portrait --device-ids iphone8 を指定して回す手もありますが、コストが嵩むので、私はこのローカル法を先にやります。
0x8badf00d が出ない構成を保つチェックリスト
リリース前に Xcode の Instruments → App Launch テンプレートで毎回計測しておくと、SDK を一つ足したときに気付けます。私の場合、Apps/iOS Apps の各リリースブランチでは次の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 つ遅延読み込みに置き換えると、ウォッチドッグの余白がはっきり広がります。
同じ問題で困っている方の参考になればうれしいです。お読みいただきありがとうございました。