干了这么多年Android开发,最怕的不是崩溃,而是ANR。尤其是线上偶现的ANR,用户根本不会帮你复现,系统只丢给你一份traces和几行logcat,你要在这堆碎片里判断主线程到底卡在哪、为什么会卡、下一次怎么避免。我最近整理了一套自己的处理流程,可以叫它ANR Analysis Flow,从超时机制、日志定位到根因判定,每一步都有固定的抓手。这篇就把这套流程完完整整地拆开讲一遍,适合需要独立定位ANR问题的Android开发、性能优化负责人和质量人员参考,看完之后你再拿到一份ANR报告,至少知道自己该先看哪里、再往哪追。
1. ANR不是bug,是系统对“无法响应”的裁决——先搞清触发机制
分析ANR之前,得先把一个概念掰扯清楚:ANR不等于主线程卡死,而是主线程在系统规定的有限时间内没有完成应有的响应。系统不会无限期等下去,它像一个设置了闹钟的监考老师,时间一到就强行收卷,然后把考场里的状态全部拍下来,存成一份现场报告。理解这个“闹钟”机制,是后续所有分析的前提。
1.1 五种ANR类型与超时阈值
从超时类型来看,ANR可以分成五类。很多人以为ANR只有“点击没反应”一种,其实不对,系统对不同类型的消息设置了不同的时限,触发路径也各不相同:
| ANR类型 | 触发场景 | 主要超时时间 | 关键日志关键字 |
|---|---|---|---|
| InputDispatchingTimedOut | 按键、触摸事件分发超时 | 5秒 | Input dispatching timed out |
| BroadcastTimeout | 广播接收器执行超时 | 前台广播10秒,后台广播60秒 | Broadcast of Intent timeout |
| ServiceTimeout | 服务生命周期执行超时 | 前台服务20秒,后台服务200秒 | Executing service timeout |
| ContentProviderTimeout | ContentProvider发布或访问超时 | 各有阈值,常见20秒左右 | ContentProvider timeout |
| 应用自定义超时 | 某些熔断或监控机制主动判定 | 由业务决定 | 通常是业务自定义日志 |
这里有个坑:后台广播60秒、后台服务200秒看起来很长,但在低端机上很容易触发。因为后台广播执行时进程可能处于冻结或CPU调频受限状态,哪怕代码里只有一个耗时2秒的同步操作,叠加系统负载和后台冻结,也可能被拖到超时。我处理过的线上ANR里,后台广播占比一直不低,千万别因为它“时间长”就放松警惕。
1.2 为什么有的ANR“冤枉”:CPU负载与超时判定
系统判定超时时,不只看“时间到了没有”,还会看分配到的CPU时间。这个设计很微妙:如果主线程虽然慢,但它一直在“干活”,系统会认为是整体负载过高导致调度不及时,这种ANR会被标记为“CPU被占用”;如果主线程分配到的CPU时间很少,却一直处于等待状态,那基本就是“主动阻塞”了。
日志里的CPU片段就是判断这个差异的关键。报告底部通常会有这么一段:
CPU usage from 11222ms to 5256ms ago (2021-06-01 14:22:31.143 to 14:22:37.109): 5% 1234/com.example.demo: 4.5% user + 0.5% kernel / faults: 789 minor 18% 888/system_server: 15% user + 3% kernel / faults: 18353 minor拿到这段后要算一下总和。如果当前进程消耗的CPU极低,比如只有5%,那说明主线程基本在“睡觉”;如果进程CPU占用达到50%以上,还伴随着大量GC日志,说明主线程可能在死循环或者疯狂做计算。这两种情况虽然都以ANR告终,但修复方向完全相反——一个要处理阻塞与锁,另一个要处理算法和负载。
1.3 站在系统视角看ANR报告的生成过程
理解ANR报告的生成过程,能帮你分清哪些信息是可靠的、哪些是有误导性的。系统检测到ANR后,并不是只dump主线程的栈,而是把整个进程的所有线程栈、进程内存、CPU使用情况一起拍快照,写入/data/anr/目录,同时在logcat里输出一份简版报告。如果主进程已经被杀掉,系统还会尝试把现场保留到DropBox里,方便事后拉取。
这套快照机制决定了traces文件里“那一刻”的状态是真实的,但也意味着它是瞬时快照——如果ANR发生后进程继续运行了很长时间才被dump或拉取,栈可能已经变了。所以分析ANR时,一定要以logcat里ANR in出现的时间点为基准,把traces的时间戳和它对齐,而不是随便拿一份最新的/data/anr/traces.txt去猜。
2. 拿到一份ANR报告后,30分钟内定位根因的三个抓手
很多人拿到ANR报告第一步就跑去看traces.txt,看了半天一头雾水,因为里面几百个线程堆栈根本不知道从哪下手。我的习惯是倒过来:先读logcat里的主报告,再开traces找主线程,最后用锁和Binder调用把线索串起来。只要按这个顺序走,大多数ANR在半小时内都能定位到具体代码。
2.1 第一抓手:logcat里的ANR主报告怎么读
logcat里ANR报告的开头一般长这样(不同Android版本略有差异,但核心字段稳定):
ANR in com.example.demo (com.example.demo/.MainActivity) PID: 1234 Reason: Input dispatching timed out (Waiting because the touched window has not finished handling the input event.) Parent: com.example.demo/.MainActivity Load: 3.28 / 3.76 / 3.68这几行信息量很大,但很多人扫一眼就过去了。我应该提醒你注意三处:
- Reason字段决定定位方向。如果是
Input dispatching timed out,核心问题是“触摸事件没被消费掉”;如果是Broadcast of Intent timeout,则要把重心放在对应的广播接收器上。不同Reason对应的排查路径完全不同。 - Load值代表设备的负载趋势。三个数字分别是1分钟、5分钟、15分钟的平均负载。如果Load值超过CPU核数说明设备整体繁忙,这个ANR可能是“被拖累”的;如果Load值很低却依然ANR,那基本可以断定是进程内部的自阻塞。
- PID必须记死。后续在traces里寻找进程信息时,都是靠PID区分进程的。尤其是系统同时dump多个进程时,PID就是你的导航坐标。
2.2 第二抓手:traces文件里主线程在干什么
打开traces.txt后不要被几百个线程吓到,直接搜"main",定位主线程的栈。主线程栈的格式类似这样:
"main" prio=5 tid=1 Native | group="main" sCount=1 dsCount=0 flags=1 obj=0x75239040 self=0x7a01b73100 | sysTid=1234 nice=-10 cgrp=default sched=0/0 handle=0x7b32b6d9a8 | state=S schedstat=( 143536876 401307 85 ) utm=38 stm=11 core=4 HZ=100 | stack=0x7ffe4b5000-0x7ffe4b7000 stackSize=8MB | held mutexes= "mutator lock"(shared held) at android.os.BinderProxy.transactNative(Native Method) at android.os.BinderProxy.transact(BinderProxy.java:727) at android.app.IActivityTaskManager$Stub$Proxy.startActivity(IActivityTaskManager.java:1676) at android.app.Instrumentation.execStartActivity(Instrumentation.java:1716) at android.app.Activity.startActivityForResult(Activity.java:5273) ...读主线程栈的核心逻辑只有一条:看它到底停在哪个系统调用或锁等待上。如果是BinderProxy.transactNative,说明主线程正在等待一次跨进程调用返回,典型场景是startActivity等待系统进程响应;如果是Object.wait,说明在等待锁或者条件信号,这时候栈里会明确标注waiting to lock和锁被哪个线程持有;如果是epollWait,说明主线程正在Looper消息循环里等新消息,这本身是正常的,但如果MessageQueue里积压了任务,就要查到底是什么任务排队占用了时间。
判断“正常等待”和“异常卡顿”有一个实用技巧:看schedstat括号里的第一个数字(累计运行时间,单位一般是纳秒或毫秒,取决于系统配置)。如果主线程累计运行时间几乎没有增长,说明它确实一直在等待;如果累计运行时间增长很快,说明主线程在疯狂执行计算,ANR是“忙到没空响应”而不是“卡住不动”。
2.3 第三抓手:把“锁等待”和“Binder调用”串成一条链
单看主线程栈往往不够,尤其是锁竞争类的ANR,主线程只会告诉你“我在等锁”,不会告诉你“谁占着锁”。把这条链补完,才是真正的分析流程关键一步。
具体做法是:先在主线程栈里找到waiting to lock <0x12345678>,然后全局搜索这个地址,找到哪个线程栈里有held by thread xxx或locked字样,那个线程就是锁的持有者。接下来看持锁线程在干什么——如果它在做网络请求、文件读写或死循环,根因就浮出来了。如果持锁线程也卡在另一个锁上,那就要继续递归追,直到找到最底层的阻塞点。
Binder调用同理。主线程停在BinderProxy.transact时,往往是system_server或其他进程拖住了。这时候别只盯主线程栈,还要看Binder:xxx_1、Binder:xxx_2这些binder线程栈里有没有对应的调用栈,确认是“对方处理慢”还是“参数太大传输慢”。跨进程问题的判断,单靠主线程栈永远只能看到一半。
3. 实战案例拆解:三种最典型的ANR分析路径
纸上谈兵没用。我拉三个自己在排查过程中遇到的典型ANR案例,把从日志到结论的完整链路走一遍。这三个案例覆盖了大多数ANR的根因类型:锁等待、主线程繁忙、生命周期同步操作。
3.1 案例一:等锁等到超时——同步锁放错了线程
现象:线上收到一波Click类型的ANR,集中在某页面点击后主界面无响应。logcat里的Reason是Input dispatching timed out,traces主线程栈如下:
at com.example.demo.CacheManager.getData(CacheManager.java:120) - waiting to lock <0x09b2c451> (a java.util.HashMap) at com.example.demo.MainActivity.onClick(MainActivity.java:88)全局搜索<0x09b2c451>,找到一个工作线程的栈:
"Thread-7" prio=5 tid=26 TIMED_WAIT at java.lang.Object.wait(Native Method) - waiting on <0x09b2c451> (a java.util.HashMap)到这里真相大白:主线程想进入HashMap对应的同步块,但锁被Thread-7持有,而Thread-7在HashMap上wait,并且这个wait永远不会被notify。这种“锁池”场景的经典问题是:开发者在HashMap上做条件等待,导致锁被长期占用,主线程无法进入临界区。修复方案是改用ConcurrentHashMap + Condition或独立的锁对象,把“缓存访问锁”和“条件等待锁”分离。
3.2 案例二:Input分发超时——主线程“看似没死但很忙”
现象:某列表页面滑动卡死,接着弹出ANR。主线程栈非常活跃:
at android.graphics.Canvas.nativeDrawBitmap(Native Method) at android.graphics.drawable.BitmapDrawable.draw(BitmapDrawable.java:532) at com.example.demo.ImageDecoder.decodeFrame(ImageDecoder.java:210) at com.example.demo.GifView.onDraw(GifView.java:142)这个栈里没有锁,没有Binder等待,就是纯粹地在draw。为什么还会ANR?因为主线程一直在生产帧,每帧处理时间远超16ms,最终导致Input事件的处理超过5秒。根因是GifView.onDraw里直接对GIF帧做了解码,把重量级计算放到了绘制阶段。修复方向不是改锁,而是把解码挪到子线程、用复用帧缓冲,甚至直接换用硬件加速支持的图片格式。
这类ANR最容易误判。栈里没有任何“等待”迹象,主线程状态还是Runnable,初学者往往觉得不是ANR。判断标准就是第2节讲的CPU累计时间——如果主线程schedstat里累计运行时间疯狂增长,说明它一直占着CPU在跑,那这个ANR的本质就是“过度工作”而不是“被阻塞”。
3.3 案例三:广播超时——onReceive里的同步网络请求
现象:某机型用户反馈应用后台一段时间后,出现Broadcast of Intent timeout。traces里主线程栈指向:
at java.net.SocketInputStream.socketRead0(Native Method) at java.net.SocketInputStream.read(SocketInputStream.java:150) at com.example.demo.TrackUploader.upload(TrackUploader.java:66) at com.example.demo.BootReceiver.onReceive(BootReceiver.java:44)所有现场都指向一个事实:广播的onReceive在主线程执行,里面直接做了同步网络上传。虽然这段代码只在特定系统事件触发时执行,但在后台广播场景下,网络阻塞几十秒完全可能。修复方案简单粗暴:把上传逻辑丢到WorkManager或前台服务里执行,onReceive只负责收尾和启动任务。
这类问题严重性不在代码难度,而在于测试难发现——非root设备很难模拟完整的后台广播延迟环境。我自己踩过一次之后,所有涉及onReceive的代码都默认走异步任务,不赌系统网络状态。
3.4 案例共性总结
三个案例放在一起看,其实每个都符合一个规律:主线程在做一件“它不该做”的事。要么是同步等待一个不该主线程等的锁、要么是执行了不该在绘制阶段做的计算、要么是发了不该在主线程发的网络请求。分析ANR时,每次读到主线程栈,我都会问自己一句话:主线程停在这里,是合理的吗?如果答案是否定的,根因基本就找到了。
4. 分析工具链与远程日志方案:非root设备也能拿到关键现场
很多开发者一拿到ANR就慌,原因是个尴尬的现状:线上设备多数没有root权限,/data/anr/traces.txt根本读不出来。但其实能用的手段不少。下面把我的工具使用顺序和日志兜底方案完整列出来。
4.1 必备命令表与使用时机
| 场景 | 命令 | 说明 |
|---|---|---|
| 抓现场日志 | adb logcat -v threadtime | 带线程和时间的日志,ANR报告也在这里 |
| 拉取完整报告 | adb bugreport | 打包所有系统日志、dropbox、traces,最推荐 |
| 获取ANR traces(可root) | adb shell cat /data/anr/traces.txt | 旧版本文件名固定,新版本可能是子目录多个文件 |
| 获取traces(debuggerd) | adb shell debuggerd -j <pid> | Android 8.0+,可以直接拿Java backtrace |
| 查看CPU负载 | adb shell top -t -m 20 | 判断现场负载情况 |
| 看方法耗时 | adb shell am profile start/stop | 配合日志做简单性能采样 |
这里我特别想强调adb bugreport的价值。只要能在ANR发生后尽快执行一次,它会把/data/anr/下的traces、/data/system/dropbox里的ANR事件、logcat历史、系统状态一次性打包。即使是非root设备,bugreport也能带出大部分关键内容,比单独cat某个文件可靠得多。
4.2 traces解读进阶:线程状态关键字速查
可读性极强的traces文件里,有一些固定的关键字,看见它们就该知道主线程到底处于什么状态。这份速查表是我自己整理的,分析时可以直接对着查:
| 线程状态关键字 | 含义 | 常见根因 |
|---|---|---|
state=S | Sleep/Wait,等待中 | 锁等待、条件等待、IO阻塞 |
state=R | Runnable,运行或可运行 | 死循环、频繁GC、被调往不健康线程 |
state=D | Uninterruptible IO | 磁盘IO或网络IO挂在驱动层,很难被杀 |
sysCall | 陷入系统调用 | 可能是binder通信或socket读写 |
__ioctl | 设备IO控制 | 常见于binder driver的等待 |
epollWait | 等待事件 | 主线程Looper空闲的正常状态 |
pthread_mutex_lock | 原生层互斥锁 | 底层锁竞争 |
Object.wait | Java层等待 | 配合waiting to lock一起看 |
有一个容易误判的点:主线程栈出现epollWait时,许多人会以为主线程没问题。其实不一定。epollWait只表示主线程正在等待新消息,但如果等待之前MessageQueue里已有堆积的任务,说明某个任务的耗时挤占了后续所有Input事件的消费窗口。这时候要去查MessageQueue里有没有历史任务痕迹,或者结合Systrace看Choreographer帧时间线。
4.3 远程/线上ANR日志的采集与兜底
线上问题不可能每次都插着USB。我的兜底方案是在App内部做一个轻量级的ANR监控——不依赖系统报告,而是自己监听主线程执行状态:主线程Looper的每个Message如果执行时间超过阈值(比如4秒),就主动抓一次所有线程栈和关键状态,附带最近的logcat片段,打包后按队列上传。这套方案虽然不能替代系统ANR报告,但胜在能采集到系统报告之外的信息,比如卡顿发生时业务层的内存水位、网络状态、页面对栈。
如果产品不想引入第三方组件,也可以就依赖系统DropBox:通过DropBoxManager监听system_app_anr事件,在系统写dropbox的时候同步拉取ANR相关信息。缺点是部分系统事件需要一定权限,不同厂商定制ROM的行为也不一致。我建议自研监控和DropBox读取一起上,互为兜底。
5. 从分析到预防:堵住最容易踩的坑
分析流程跑通之后真正头疼的是:同一个ANR修好了,过两周又冒出一个新的。所以一套完整的ANR处理流程,必须包含预防和验证环节。这一节不是什么空中楼阁,都是我实际用过、觉得有直接效果的“土办法”。
5.1 代码层面的防线
最容易踩的坑还是主线程IO和同步锁。守住两条硬性红线,能挡掉大半ANR:
- 主线程绝对不碰网络和磁盘写。哪怕是读一个SharedPreferences,如果文件较大,也可能在低端机上造成几百毫秒的卡顿。正确做法是把这类操作全部搬到子线程,用协程或回调回主线程更新UI。
- 锁竞争必须限时。
synchronized一旦抢不到锁就无限期等待,这是锁型ANR的温床。建议在涉及跨线程共享的锁上加超时机制,抢不到就放弃,宁可让数据稍旧也不能让主线程挂起。Java的Lock接口配合tryLock(timeout)就是这个场景的标准解法。
另外,生命周期和回调方法里不要启动耗时任务。onCreate、onResume、onReceive这类方法执行时间会被系统感知,一旦超时直接触发ANR。它们的代码应该只负责“调度”和“展示”,重活全部往后排。
5.2 发布前怎么验证
测试环境很难完全模拟线上负载,但有几个手段能显著提升ANR发现率。首先是低端机矩阵测试:找一台配置较低的机型,把系统CPU负载人为拉高(比如后台循环播放视频),再走核心链路,看是否出现卡死。其次是系统开发者选项里的“不保留活动”和“限制后台进程”组合拳,能模拟后台强杀和限频场景。最后是使用StrictMode,在主线程里开启磁盘读和网络访问的检测,一旦开发期触发就能在logcat里直接看到。
我自己的发布检查清单里还有一条:每次发布前,收集上一版本所有ANR的Reason分布。如果某个Reason的占比突然变化,大概率是新版本引入了不该在主线程做的事。这个习惯帮我抓过好几次“测试没复现、线上已踩坑”的问题。
5.3 监控体系的建立思路
监控不是为了看数量,而是为了看趋势和分组。我的做法是把每次ANR按四维分组:类型(Input/Broadcast/Service等)、页面/模块、系统负载档位、是否与后台状态相关。同样是10个ANR,如果集中在低负载设备的某个页面,那是业务代码问题;如果集中在高负载设备的后台广播,那是调度与限频问题。这两种问题的处理方式差别很大,分不清维度就会瞎优化。
监控数据最好能和一个“主线程消息耗时”埋点关联起来。系统ANR是结果,主线程Message耗时是过程。过程指标能提前预警——当某个页面主线程平均消息耗时超过100ms时,即使还没触发ANR,也应该进入待优化列表。这种“结果+过程”双重监控的好处是,你不再被动等着系统裁决,而是能提前动手。
最后分享一个实际体会。ANR分析这件事,越往后越像“现场重建”:日志和traces只是那个时间点的静态切片,你要做的是在脑子里把主线程、锁、系统调度、外部IO这些角色的时间线重新拼起来。拼得多了,你就会形成一种直觉——看到主线程栈,能大概猜到它是被“饿死”的还是“忙死”的,这比任何工具都值钱。希望这套ANR Analysis Flow,也能帮你建立同样的直觉。