首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >ANR 治理 2026:从一次低端机翻车到主线程立体监控

ANR 治理 2026:从一次低端机翻车到主线程立体监控

作者头像
陆业聪
发布于 2026-06-24 19:16:38
发布于 2026-06-24 19:16:38
4290
举报

上周线上出了个 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% 以内,完全可接受。

采样实现核心代码

代码语言:javascript
复制
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,但姿势变了

代码语言:javascript
复制
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 监听

代码语言:javascript
复制
// 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 拦截。

完整调用方式

代码语言:javascript
复制
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)
}
}
}
}

这接口能拿到的关键字段:

代码语言:javascript
复制
退出原因(ANR、CRASH、LMK等)

processStateSummary 这个字段被严重低估了——你可以在应用运行过程中持续写入"当前用户在哪个页面、最后一个用户操作是什么",下次启动时和 trace 一起读出来,排查问题的效率直接翻倍。我现在所有项目都强制接入这个。

六、那个低端机 ANR 的真相

回到开头那个故事——主线程看起来在 MessageQueue.next 睡觉,但 ANR 真实存在。我把上面三套机制都接上去,重新发版本灰度,第二天就拿到了真相。

采样数据回放

100 帧栈聚合后,主线程时间分布是这样的:

代码语言:javascript
复制
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 数据可以分享。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-06-18,如有侵权请联系 cloudcommunity@tencent.com 删除
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档