
上周线上出了个 ANR,问题挺典型,但排查过程让我重新审视了 ANR 这个老问题。
用户反馈某个低端机型上首页打开偶尔白屏卡顿,伴随系统弹出"应用无响应"。后台监控告警里 ANR 率确实在那个机型上飙升到了 0.8%。我看到堆栈第一反应是"啊,又是主线程 IO"——但翻进去发现根本不是。Thread.dumpStack 出来的主线程在 MessageQueue.next 那一行,看起来主线程是空闲的。
空闲主线程触发 ANR?这玩意儿坑了我整整一天。
这篇就把我这次踩坑的全过程拆开讲一讲,顺便聊聊 2026 年我对 Android ANR 治理的整体看法——一些老的方法依然管用,但也有新的工具值得替换掉旧方案。
一、ANR 不只是主线程慢
先说一个我观察到的现象:很多团队的 ANR 上报系统,依赖的还是 Java 层的 Thread.getAllStackTraces()。这个方法在 ANR 真正发生那一刻打栈,看起来很合理。但实际上,它经常打不到真正卡的位置。
系统判定 ANR 的真实路径
系统侧 ANR 的判定,并不是"主线程卡了 5 秒",而是事件分发或服务调用没有在规定时间内被 ACK。具体的几条触发路径:
系统检测点
↓
① InputDispatcher → 输入事件 5 秒内未消费
② Service → 前台服务 20 秒、后台 200 秒
③ BroadcastReceiver → 前台 10 秒、后台 60 秒
④ ContentProvider → publish 超 10 秒
关键点在于:这四条路径的检测都在系统进程,不是你的应用进程。当 system_server 决定弹"应用无响应"时,它会先发 SIGQUIT 给你的进程,让 ART 把所有线程的栈写到 /data/anr/anr_*。这一刻——栈是真实的。
但你应用层捕获 ANR 用的方法(监控 Looper、Watchdog 线程、FileObserver 监听 anr 目录)都比这一刻晚。延迟可能是几百毫秒到几秒——足够主线程从卡死的 Binder 调用里恢复出来,等你抓栈时人家已经走到 MessageQueue.next 等下一个消息了。
这就是为什么你看到主线程在睡觉
说回我那个低端机的 ANR。我用之前公司沿用了三年的 Watchdog 方案——主线程 post 一个空 Message,每隔 2 秒检查 token 有没有被消费,超过 5 秒就 dump 栈。这个方案的问题就是:发现卡死和真正卡死之间,有一个不可控的窗口。
所以这次我的策略改成了三件事一起做:
• 实时采样:每 50ms 抓一次主线程栈,环形缓冲存最近 100 帧;
• 事件级埋点:在 Looper.setMessageLogging 拦截每条 Message 的 dispatch 时间;
• 系统侧抓取:注册 SIGQUIT 信号处理,第一时间从 /data/anr/ 读 trace。
二、主线程实时采样:50ms 一帧
采样的核心思想:与其在 ANR 发生后才打一次栈,不如持续打栈,事后回放。50ms 一帧的频率,对 CPU 的开销我实测在 1% 以内,完全可接受。
采样实现核心代码
class MainStackSampler(
private val intervalMs: Long = 50,
private val bufferSize: Int = 100
) {
private val mainThread =
Looper.getMainLooper().thread
private val ring =
RingBuffer<StackSnapshot>(bufferSize)
private val handler: Handlerinit {
val ht = HandlerThread(
"stack-sampler",
Process.THREAD_PRIORITY_BACKGROUND
)
ht.start()
handler = Handler(ht.looper)
}fun start() {
handler.post(::loop)
}private fun loop() {
val ts = SystemClock.uptimeMillis()
val stack = mainThread.stackTrace
ring.add(StackSnapshot(ts, stack))
handler.postDelayed(::loop, intervalMs)
}fun snapshot(): List<StackSnapshot> {
// 拷贝最近 N 帧
return ring.toList()
}
}这里有一个细节我一开始没注意:调用主线程的 stackTrace 方法本身,是一个对主线程无感的操作。它走的是 ART 内部的 SuspendThreadByPeer,会短暂挂起目标线程拷贝栈。这意味着:你 50ms 采样一次,主线程实际会被挂起几十微秒,量级很小。
栈聚合:找到真正阻塞的方法
采样数据上来后,光看栈是不够的,必须做聚合。我的做法是:取一段时间窗口(比如 ANR 发生前 3 秒)的所有栈,对每个栈帧做计数,得分公式很简单——出现次数越多,越接近栈底,越可能是真正的阻塞点。
实际上这就是火焰图的本质,只不过是离线生成。我把这部分集成到了 ANR 上报包里,每条上报附带 100 帧栈和聚合后的 Top-3 候选方法,比单纯一张栈截图有用多了。
三、Looper 监听:消息维度的耗时归因
采样能解决"主线程当时在做什么"的问题,但回答不了"为什么开始变慢"。这就要靠 Looper 维度的监听。
老朋友 Printer,但姿势变了
Looper.getMainLooper().setMessageLogging {
line ->
if (line.startsWith(">>>>>")) {
// dispatch 开始
startTs =
SystemClock.uptimeMillis()
currentMsg = line
} else if
(line.startsWith("<<<<<")) {
// dispatch 结束
val cost =
SystemClock.uptimeMillis() - startTs
if (cost > 200) {
// 单条消息 200ms 视为慢消息
recorder.record(currentMsg, cost)
}
}
}2026 年再用 Printer 这套,和过去几年最大的不同是:不要为每条消息都打日志。早年大家把所有 dispatch 日志都写到磁盘,IO 本身就拖慢主线程。现在更合理的做法是只记录"超过阈值"的消息——200ms 以下根本不关心,超过阈值的才记 callback、target、what。
注意:Looper 的 setMessageLogging 是全局的,多人 hook 会互相覆盖。建议在应用进程启动时用一个统一的 Dispatcher 包一层,所有想监听的模块通过 Dispatcher 注册回调。
慢消息的归因
拿到慢消息后,要做的就是把它和"业务"对应起来。Message 的 target.javaClass 通常是 Handler 子类,如果是匿名内部类,callback 字段反射拿到 lambda 的外部类,基本能定位到具体的业务模块。这一步在 R8 混淆开启的情况下要特别处理——把关键 Handler 加入 keep 规则,否则线上拿到的都是 a
四、SIGQUIT 信号:在系统打栈那一刻同步抓取
采样和 Looper 监听都是应用层视角。但有一些 ANR——尤其是 Binder 卡死、跨进程死锁——栈本身在应用层看不全(只能看到 Java 层在 transact,看不到对端为什么不返回)。这时候就需要拿系统的 trace 文件。
SIGQUIT 触发的 ANR 流程
system_server 判定 ANR
↓
向应用进程发 SIGQUIT
↓
✅ ART SignalCatcher → 接收信号,dump 所有线程栈到 stderr
↓
trace 写入 /data/anr/
↓
弹"应用无响应"对话框
关键点在中间——SignalCatcher 抓栈,到弹框,之间有一段时间。在这段时间里,如果我们能在应用进程里"截胡"SIGQUIT,就能拿到和系统同时的栈。
注册 SIGQUIT 监听
// JNI 层注册 SIGQUIT
extern "C" JNIEXPORT void
install_anr_handler(
JNIEnv* env, jobject thiz) {// 不能直接 sigaction
// ART 的 SignalCatcher 已经占用
// 用 sigaltstack + 链式 handlerstruct sigaction old_sa;
sigaction(SIGQUIT, nullptr, &old_sa);
// 保存旧 handler,确保 ART 的
// dump 流程不被破坏
g_old_handler = old_sa.sa_sigaction;struct sigaction new_sa = old_sa;
new_sa.sa_sigaction =
my_sigquit_handler;
new_sa.sa_flags |= SA_SIGINFO;
sigaction(SIGQUIT, &new_sa, nullptr);
}static void my_sigquit_handler(
int sig,
siginfo_t* info,
void* uctx) {// 1. 标记 ANR 即将发生
g_anr_imminent = true;// 2. 链式调用,让 ART 写 trace
if (g_old_handler) {
g_old_handler(sig, info, uctx);
}// 3. 唤醒上报线程读 trace 文件
sem_post(&g_report_sem);
}这段代码有几个坑必须提一下:
• 直接调用 sigaction 覆盖 SIGQUIT 会导致 ART 的 SignalCatcher 收不到信号,系统的 trace 写不出来,反而会让 ANR 变成"无 trace 的 ANR"。必须做链式调用。
• 信号处理函数里能做的事极少。绝对不能 malloc、不能取 Java 锁、不能调用大部分 libc 函数。我的做法是只 set flag + post 信号量,剩下的活在工作线程做。
• Android 11 之后 /data/anr/ 应用进程没有读权限。要读自己的 trace,得通过 ApplicationExitInfo 拿——但这是下一次启动时才能查的接口。所以现代方案是混合:当次抓应用层栈+下次启动补 ApplicationExitInfo 系统栈。
五、ApplicationExitInfo:被低估的官方接口
坦白说,从 Android 11 这个 API 出来到现在五年了,我观察到很多团队的 ANR 系统还是没用上它。原因可能是:它要等下一次启动才能读,不"实时"——但这恰恰是它最稳定的优势:你拿到的是系统视角的退出信息,不需要 hook,不会被厂商 ROM 拦截。
完整调用方式
fun checkLastExit(ctx: Context) {
if (Build.VERSION.SDK_INT <
Build.VERSION_CODES.R) returnval am = ctx.getSystemService(
ActivityManager::class.java
)
val exits = am
.getHistoricalProcessExitReasons(
null, 0, 5
)exits.forEach { info ->
if (info.reason ==
ApplicationExitInfo
.REASON_ANR) {
// 关键:trace 数据通过
// inputStream 拿到
info.traceInputStream?.use {
val trace =
it.bufferedReader()
.readText()
report(info, trace)
}
}
}
}这接口能拿到的关键字段:
退出原因(ANR、CRASH、LMK等)processStateSummary 这个字段被严重低估了——你可以在应用运行过程中持续写入"当前用户在哪个页面、最后一个用户操作是什么",下次启动时和 trace 一起读出来,排查问题的效率直接翻倍。我现在所有项目都强制接入这个。
六、那个低端机 ANR 的真相
回到开头那个故事——主线程看起来在 MessageQueue.next 睡觉,但 ANR 真实存在。我把上面三套机制都接上去,重新发版本灰度,第二天就拿到了真相。
采样数据回放
100 帧栈聚合后,主线程时间分布是这样的:
42 帧(idle)真凶是 ContentResolver.query——首页有个广告 SDK 在主线程查询 MediaStore 拿用户图片元数据。在低端机上 MediaStore 进程被 LMK 频繁回收,每次冷启动都要重新建表索引,于是 query 在主线程一卡就是 7-8 秒。
系统弹 ANR 后,应用层 Watchdog 才反应过来。等 Watchdog 抓栈时,query 已经返回了,主线程已经走到 next 等下一个消息——这就是为什么旧方案抓不到。
解决方案就很直接了:广告 SDK 那个调用挪到 IO 线程。但更重要的是——这次让我对线上 ANR 监控的方法论彻底换了一遍思路。
七、2026 年我推荐的 ANR 监控组合
最后总结一下,2026 年如果让我从零搭一套 Android ANR 监控体系,我的组合会是:
ANR 立体监控架构
↓
✅ 第一层:实时栈采样 50ms 一帧,环形缓冲存最近 5 秒
✅ 第二层:Looper 监听 慢消息归因,单条 >200ms 记录
✅ 第三层:SIGQUIT 链式 hook 抓 ART 同时点的全线程栈
✅ 第四层:ApplicationExitInfo 下次启动补充系统视角
⚠ 上下文埋点 processStateSummary 写入用户路径
这四层不是简单叠加,是互补的——采样解决"主线程在做什么",Looper 解决"是什么消息触发的",SIGQUIT 解决"卡的是 Binder 还是别的",ApplicationExitInfo 解决"系统怎么看的"。任何一层单独用都有盲区。
顺嘴说一句:很多团队会问"我们直接用 Matrix/AndroidPerformanceMonitor 行不行?"。答案是行——这些库本质上就是上面这套机制的工程化封装。但你至少要理解它们在做什么、各层的边界在哪——出问题时知道往哪儿看。这也是我自己更倾向手写一套精简版本的原因。
写在最后
ANR 这个问题,五年前我以为它早就被解决了——结果工作里它每年都还在变形。从早期的"主线程 IO 卡死",到现在的"端侧 AI 推理在主线程占了 200ms"、"广告 SDK 查 MediaStore 触发 LMK 链式反应"、"Compose 重组在低端机失控",老问题、新场景,工具也在演进。
2026 年端侧 AI 兴起后,主线程的"忙碌时长"概念其实变了——AICore 在后台跑推理时,CPU 大核都在帮它干活,你的 App 主线程跑事件就被排到小核去了,原本 50ms 能完成的 dispatch 可能要 200ms。这种"系统层挤压"的 ANR,靠应用层完全看不出来——必须 ApplicationExitInfo 拿系统快照才能定位。
下次有空想聊聊端侧 AI 推理对 Android 主线程调度的实际影响——是个挺新的话题,我手头也有些 Profiler 数据可以分享。