ANTIGRAVITY LABEN
記事一覧/アプリ開発
アプリ開発/2026-07-03上級

クラッシュ一覧には一度も現れない不調 — ANR トレースを ApplicationExitInfo で回収し、エージェントに切り分けを渡す設計

クラッシュハンドラに映らない ANR を ApplicationExitInfo で起動時に回収し、トレースの切り分けを Antigravity のエージェントへ安全に渡すまでの実装と5週間の実測値です。

Antigravity305Android24ANRApplicationExitInfoPlay Console

プレミアム記事

昨年の暮れ、個人開発で運営している壁紙アプリの Play Console を開いたときのことです。クラッシュ率は 0.1% を切っていて、クラッシュ一覧は何週間も静かなまま。それなのに「ユーザーが感知した ANR 発生率」だけが 0.62% まで上がり、不良動作のしきい値である 0.47% を超えていました。

クラッシュは一件も増えていないのに、品質指標だけが沈んでいく。この非対称に気づくまで、私はずいぶん遠回りをしました。ANR はクラッシュとは別の経路で起きて、別の経路でしか観測できないためです。

本稿は、その ANR を ApplicationExitInfo で自前回収する実装と、回収したトレースの切り分けを Antigravity のエージェントに渡すときの制約設計、導入から5週間の実測値の記録です。

なぜクラッシュハンドラに ANR が映らないのか

自前のクラッシュ計測の多くは Thread.setDefaultUncaughtExceptionHandler を土台にしています。JVM 例外が投げられれば捕まえられる仕組みです。

ANR はここを通りません。メインスレッドが応答しなくなったとき、例外はプロセス内に投げられず、システム側が「このプロセスは応答していない」と判定して外からダイアログを出し、多くの場合そのままプロセスを終了させます。アプリ側のハンドラが呼ばれる瞬間が存在しないのです。

事象UncaughtExceptionHandlerApplicationExitInfo (API 30+)
JVM クラッシュ捕捉できるREASON_CRASH として記録
ネイティブクラッシュ捕捉できないREASON_CRASH_NATIVE として記録
ANR捕捉できないREASON_ANR としてトレース付きで記録
OOM キル・ユーザー強制終了捕捉できないREASON_LOW_MEMORY / REASON_USER_REQUESTED

公平のために書き添えると、Firebase Crashlytics も API 30 以降は同じ ApplicationExitInfo を経由して ANR を報告してくれます。ダッシュボードで発生率を眺めるだけならそれで足ります。

私が自前回収に踏み込んだ理由は一つで、トレース全文をテキストとして自分のパイプラインに乗せたかったからです。集計画面で丸められたスタックを目視するのではなく、生のトレースをそのままエージェントの入力に渡す。後半で述べる切り分けの自動化は、ここが起点になります。

起動時に前回の ANR を回収する実装

ApplicationExitInfo は「前回までのプロセス終了理由」を後から照会する API です。ANR が起きたその瞬間には何もできませんが、次の起動時に理由とトレースを取り出せます。

何を解決するコードか: アプリ起動時に前回プロセスの終了履歴を照会し、ANR だけをローカルに保存するコレクターです。

class ExitInfoCollector(private val context: Context) {
 
    private val prefs =
        context.getSharedPreferences("exit_info_collector", Context.MODE_PRIVATE)
 
    // Application.onCreate から Dispatchers.IO で呼ぶこと。
    // メインスレッドで readText すると、それ自体が新しい ANR の種になります。
    fun collect() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
 
        val am = context.getSystemService(ActivityManager::class.java)
        val lastSeen = prefs.getLong("last_timestamp", 0L)
 
        val reports = am.getHistoricalProcessExitReasons(context.packageName, 0, 16)
 
        reports
            .filter { it.timestamp > lastSeen }
            .filter { it.reason == ApplicationExitInfo.REASON_ANR }
            .forEach { info ->
                val trace = runCatching {
                    info.traceInputStream?.bufferedReader()?.use { r -> r.readText() }
                }.getOrNull()
 
                saveReport(
                    timestamp = info.timestamp,
                    description = info.description ?: "no description",
                    trace = trace
                )
            }
 
        // ANR 以外も含めた最新の timestamp を控えておき、次回の二重処理を防ぐ
        reports.maxByOrNull { it.timestamp }?.let {
            prefs.edit().putLong("last_timestamp", it.timestamp).apply()
        }
    }
 
    private fun saveReport(timestamp: Long, description: String, trace: String?) {
        val dir = File(context.filesDir, "anr_reports").apply { mkdirs() }
        val body = buildString {
            appendLine("timestamp: $timestamp")
            appendLine("description: $description")
            appendLine("---")
            append(trace ?: "trace unavailable")
        }
        File(dir, "anr_$timestamp.txt").writeText(body)
    }
}

なぜこう書くのか。押さえどころは三つあります。

トレースは null のことがあります。 traceInputStream が値を返すのは REASON_ANR と REASON_CRASH_NATIVE のときだけで、しかも端末やタイミングによっては ANR でも取れないことがあります。私のアプリでは取得成功率が84%前後でした。null でも description だけは必ず残す設計にしておくと、発生した事実までは失わずに済みます。

タイムスタンプで重複排除をします。 getHistoricalProcessExitReasons は履歴をそのまま返すので、何もしなければ毎回同じレポートを処理してしまいます。前回処理した最新の timestamp を控えておく、それだけの地味な一手間です。

回収処理そのものをメインスレッドに置かないこと。 トレースは数百 KB になることがあります。起動直後の Application.onCreate で同期的に読むと、ANR を観測するためのコードが ANR を作るという皮肉な循環になります。

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

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
起動時に前回の ANR トレースを回収する ApplicationExitInfo の実装(タイムスタンプ重複排除・トレース欠損時のフォールバック込み)
AI 生成コードに潜みやすい ANR の3類型と、メインスレッドから処理を逃がす Before/After の修正コード
トレースをエージェントに渡す際の制約設計(逐語引用の強制・候補3件上限・修正は人間レビュー)と導入5週間の実測値
Stripe による安全な決済 · いつでもキャンセル可能

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

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

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

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

関連記事

アプリ開発2026-06-26
生成も配信も任せても、署名鍵だけは渡さない — AI 主導の配布パイプラインで鍵の管理と引き継ぎを設計する
AI Studio や Antigravity が生成から内部テスト配信までを肩代わりする時代でも、アプリの署名鍵だけは別格です。鍵を失えば、そのアプリは二度と更新できません。個人開発で複数アプリを長く運用してきた立場から、アップロード鍵とアプリ署名鍵の分離、鍵の保管、そして万一の引き継ぎまでを設計としてまとめました。
アプリ開発2026-06-26
埋め込みエミュレータでは緑なのに、実機で初めて崩れる — AI 生成 Compose アプリに実機差を塞ぐ検証ゲートを置く
AI Studio はテキストから Kotlin/Compose アプリを生成し、埋め込みエミュレータで動かし、USB で実機へ送るところまで一画面でつなぎました。けれどエミュレータで通った画面が、手元の実機で初めて崩れることがあります。個人開発で複数アプリを抱える立場から、生成物に実機差を塞ぐ検証ゲートを据える設計をまとめました。
アプリ開発2026-06-24
生成から実機、内部テスト配信まで一手でつながった日に、私が手放さなかったもの — AI Studio の一気通貫を個人開発の配信フローに据える
AI Studio がテキストから Kotlin/Compose アプリを生成し、エミュレータ・実機・Play 内部テストまで一画面でつなぐようになりました。便利さの裏で「配信の瞬間」をどこまで機械に預け、どこを自分の手で握るか。個人開発で複数アプリを抱える立場からの線引きと、その境界を支える実装をまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →