昨年の暮れ、個人開発で運営している壁紙アプリの Play Console を開いたときのことです。クラッシュ率は 0.1% を切っていて、クラッシュ一覧は何週間も静かなまま。それなのに「ユーザーが感知した ANR 発生率」だけが 0.62% まで上がり、不良動作のしきい値である 0.47% を超えていました。
クラッシュは一件も増えていないのに、品質指標だけが沈んでいく。この非対称に気づくまで、私はずいぶん遠回りをしました。ANR はクラッシュとは別の経路で起きて、別の経路でしか観測できないためです。
本稿は、その ANR を ApplicationExitInfo で自前回収する実装と、回収したトレースの切り分けを Antigravity のエージェントに渡すときの制約設計、導入から5週間の実測値の記録です。
なぜクラッシュハンドラに ANR が映らないのか
自前のクラッシュ計測の多くは Thread.setDefaultUncaughtExceptionHandler を土台にしています。JVM 例外が投げられれば捕まえられる仕組みです。
ANR はここを通りません。メインスレッドが応答しなくなったとき、例外はプロセス内に投げられず、システム側が「このプロセスは応答していない」と判定して外からダイアログを出し、多くの場合そのままプロセスを終了させます。アプリ側のハンドラが呼ばれる瞬間が存在しないのです。
| 事象 | UncaughtExceptionHandler | ApplicationExitInfo (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 を作るという皮肉な循環になります。