◉RORK LABEN
●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Expo SDK 58 が 10/15 に正式リリースされました(Beta は 09/15 から)。上げる前に依存の対応状況を確認しましょう●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SDK 58 — Expo SDK 58 が 10/15 に正式リリースされました(Beta は 09/15 から)。上げる前に依存の対応状況を確認しましょう●WIDGET — iOS のウィジェットが本番ビルドだけ真っ黒になる原因を追った報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…
記事一覧/開発ツール
⬡ 開発ツール/2026-05-20中級

iOS の 0x8badf00d ウォッチドッグ強制終了に Rork アプリが落ちるときの対処

Rork で作った iOS アプリが起動直後に消える。クラッシュログに 0x8badf00d。Apple のウォッチドッグが React Native の重い初期化を切ったときの正しい直し方を、私の運用中アプリで実際に効いた手順で説明します。

rork59ios12crash3watchdoglaunchreact-native12expo12

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 割は次のどれかでした。

  1. AppDelegate での SDK の連続同期初期化: AdMob, Firebase, RevenueCat, Branch, Sentry を「念のため起動直後に全部」初期化している
  2. MMKV / AsyncStorage / SQLite からの同期読み込み: ユーザー設定、テーマ、A/B フラグなどを didFinishLaunching 内の同期 IO で取ろうとしている
  3. AppOpen 広告の同期 preload: AdMob の GADAppOpenAd.load をネイティブ側で await 相当の処理に組んでしまっている
  4. 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 では出ないクラッシュをローカルで再現する

ウォッチドッグの厳しさは「コールド起動 + 低バッテリー + 低温 + 悪い回線」で増します。手元で意図的に再現したいときは、次の条件を重ねます。

  1. iPhone 実機を放電 20% 以下にしてから airplane mode 解除
  2. 「設定 → デベロッパ → Network Link Conditioner」で Very Bad Network を有効化
  3. アプリを完全に閉じてから(フォアグラウンドにある状態ではダメ)アイコンタップ

これだけで、開発機で再現しなかった 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 つ遅延読み込みに置き換えると、ウォッチドッグの余白がはっきり広がります。

同じ問題で困っている方の参考になればうれしいです。お読みいただきありがとうございました。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⬡ 開発ツール2026-04-24
Rork アプリのバイナリサイズが 150MB を超えた時に切り分ける 5 つの要因と実践的な削減手順
Rork で生成したアプリが 180MB まで膨らみ、App Store Connect の警告で止まった経験から、原因を 5 要素に分解して 69MB まで落とすまでの手順を、実測値と失敗した箇所も含めて共有します。
⟐ ビジネス2026-09-03
同じコードベースから出す2本目のアプリに、固有価値を宣言する1枚を置いています
Google Play が「低価値アプリ」の線引きを明確にしました。同じコードベースから兄弟アプリを出している側が、焼き直しに見えないための固有価値をどこに置き、提出前にどう数えているかを、実際に回している手順とスクリプトごと書き残します。
⬡ 開発ツール2026-10-15
EXPO_PUBLIC_ の値が、開発では読めて本番だけ undefined になる書き方を、変換結果で見分けます
API の URL が開発中は読めるのに、ビルドした途端に undefined になる。原因は process.env の書き方でした。babel-preset-expo の変換結果を開発・本番で並べ、置き換わる書き方と残ってしまう書き方を表にしました。