3、ANR问题实战:ANR的产生原理、日志分析、trace文件解读与典型案例

ANR,全称Application Not Responding,说白了就是应用无响应。这玩意儿在Android开发中太常见了,也是用户投诉的重灾区。我刚开始做系统稳定性时,遇到ANR就头大,日志一堆,不知道从哪看起。后来摸爬滚打多了,慢慢就总结出一套方法。

今天我就把这套方法掰开揉碎了讲给你听。咱们从原理入手,再到日志分析、trace解读,最后用几个典型案例收尾。保证你听完就能上手。

3.1 ANR的产生原理

ANR是怎么来的?系统有个监控机制,它会盯着你的主线程。如果主线程在规定时间内没处理完某个事情,系统就判定它卡死了,然后弹出ANR对话框。

具体来说,有四种场景会触发ANR:

场景 超时时间 触发条件
输入事件分发 5秒 按键或触摸事件没被处理
广播接收 前台10秒 / 后台60秒 onReceive()执行太久
服务启动 20秒 onCreate()或onStartCommand()卡住
ContentProvider 10秒 query()或insert()等操作超时

为什么会超时?根本原因就一个:主线程被阻塞了。你想想看,主线程既要处理UI绘制,又要响应触摸事件,还得执行四大组件的回调。如果这时候有人在主线程里做了耗时操作,比如网络请求、大文件读写、数据库批量操作,那UI线程就腾不出手来处理新来的事件。

我在项目中遇到过最典型的场景:某个应用在onCreate里直接做了网络请求,还加了重试机制。网络一慢,主线程就卡住了,5秒后ANR稳稳地弹出来。

核心要点:ANR的本质是主线程超时。所有分析都要围绕「主线程在做什么」展开。

3.2 日志分析:从哪看起

遇到ANR,第一件事不是看代码,而是看日志。我个人的习惯是,先抓三样东西:

  1. main log - 看ANR发生的上下文
  2. events log - 看系统事件的时间线
  3. trace文件 - 看线程堆栈,这是最关键的

日志里怎么找ANR?直接搜关键字 ANR in。比如:

// main log 中的典型ANR日志
01-01 12:00:00.000  1234  5678 I am_anr: [0,12345,com.example.app,123456789,Input dispatching timed out (Waiting because no window has focus but there is a focused application that may eventually add a window when it finishes starting up.)]

这条日志里信息量很大:

  • am_anr - 这是ActivityManagerService打印的ANR事件
  • 12345 - 进程PID
  • com.example.app - 包名
  • Input dispatching timed out - ANR类型,这里是输入事件超时

拿到PID后,我通常会去events log里看看这个进程之前发生了什么:

// events log 中的关键事件
01-01 11:59:55.000  1234  5678 I am_proc_start: [0,12345,10001,com.example.app]
01-01 11:59:58.000  1234  5678 I am_activity_launch: [0,12345,com.example.app/.MainActivity]
01-01 12:00:00.000  1234  5678 I am_anr: [0,12345,com.example.app,...]

你看,从进程启动到Activity启动,再到ANR,时间线非常清晰。这能帮你判断ANR是发生在启动阶段还是运行阶段。

小技巧:我习惯用 logcat -b events | grep am_anr 来过滤ANR事件。如果ANR频繁出现,可以用 logcat -b events -v time 看完整时间线。

3.3 trace文件解读:找到真凶

trace文件是ANR分析的核心。它记录了ANR发生时所有线程的堆栈信息。文件路径一般在 /data/anr/traces.txt/data/anr/anr_*

打开trace文件,你会看到一堆线程堆栈。别慌,先找主线程。主线程通常叫 "main""Thread-1"(取决于应用配置)。

// 主线程堆栈示例
"main" prio=5 tid=1 Blocked
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x72c0d000 self=0x7a8c0c00
  | sysTid=12345 nice=0 cgrp=default sched=0/0 handle=0x7a8c0c00
  | state=Blocked stack=0x7ffc000000-0x7ffc002000 stackSize=8MB
  | held mutexes=
  at com.example.app.MainActivity.onCreate(MainActivity.java:25)
  - waiting to lock <0x0c41c000> (a java.lang.Object) held by thread 15
  at android.app.Activity.performCreate(Activity.java:8000)
  at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3500)
  at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3700)
  at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2000)
  at android.os.Handler.dispatchMessage(Handler.java:106)
  at android.os.Looper.loop(Looper.java:233)
  at android.app.ActivityThread.main(ActivityThread.java:8000)
  at java.lang.reflect.Method.invoke(Native method)
  at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:600)
  at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1000)

看到 state=Blocked 了吗?这就是问题所在。主线程处于Blocked状态,它在等一把锁。锁被线程15拿着呢。

那线程15在干嘛?继续往下翻:

// 线程15的堆栈
"Thread-15" prio=5 tid=15 Runnable
  | group="main" sCount=0 dsCount=0 flags=0 obj=0x72c0d000 self=0x7a8c0c00
  | sysTid=12360 nice=0 cgrp=default sched=0/0 handle=0x7a8c0c00
  | state=Runnable stack=0x7ffc000000-0x7ffc002000 stackSize=8MB
  | held mutexes=0x0c41c000
  at java.net.SocketInputStream.socketRead0(Native method)
  at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
  at java.net.SocketInputStream.read(SocketInputStream.java:170)
  at java.net.SocketInputStream.read(SocketInputStream.java:141)
  at libcore.io.IoBridge.read(IoBridge.java:500)
  at java.io.InputStreamReader.read(InputStreamReader.java:200)
  at java.io.BufferedReader.fill(BufferedReader.java:160)
  at java.io.BufferedReader.readLine(BufferedReader.java:330)
  at com.example.app.NetworkUtils.getData(NetworkUtils.java:50)
  at com.example.app.DataManager.loadData(DataManager.java:30)
  - locked <0x0c41c000> (a java.lang.Object)
  at com.example.app.MainActivity$1.run(MainActivity.java:100)
  at java.lang.Thread.run(Thread.java:900)

真相大白了!线程15在 socketRead0 上卡住了,也就是在做网络读取。它拿着锁不放,主线程想拿同一把锁,就只能等着。这一等,就等出了ANR。

注意:trace文件里线程状态有很多种:Runnable(运行中)、Blocked(阻塞)、Waiting(等待)、Sleeping(休眠)。只有主线程处于Blocked或Waiting时,才说明它被卡住了。如果主线程是Runnable,那可能是CPU资源不够,或者代码逻辑太复杂。

3.4 典型案例分析

光说不练假把式。我挑三个真实案例,带你走一遍分析流程。

案例一:主线程做网络请求

现象:应用启动后,点击某个按钮,5秒后ANR。

日志:main log里看到 Input dispatching timed out

trace:主线程在 HttpURLConnection.getInputStream() 里卡住了。

根因:开发者在点击事件里直接调用了网络请求,没有放到子线程。

修复:用AsyncTask或线程池把网络请求移到子线程。

避坑指南:我曾经见过一个项目,所有网络请求都用了同步方式,还美其名曰「简单直接」。结果线上ANR率高达5%。后来全部改成异步,ANR率直接降到0.1%。记住:主线程永远不要做任何可能耗时超过5ms的操作

案例二:死锁导致ANR

现象:应用运行一段时间后随机ANR,没有固定复现路径。

日志:events log里看到多个ANR事件,PID不同但包名相同。

trace:主线程Blocked,等锁A。另一个线程Blocked,等锁B。而锁A被第三个线程拿着,锁B被主线程拿着。典型的循环等待。

根因:两个线程加锁顺序不一致。线程1先锁A再锁B,线程2先锁B再锁A。

修复:统一加锁顺序,或者用 synchronized 的替代方案如 ReentrantLock 配合超时机制。

我的经验:死锁问题最难排查。我习惯在代码里加 Thread.dumpStack() 来辅助分析。或者用 StrictMode 在开发阶段就发现潜在的锁问题。

案例三:广播接收器耗时操作

现象:收到某个系统广播后,应用立即ANR。

日志:main log里看到 Broadcast of Intent { act=android.intent.action.BATTERY_CHANGED } 超时。

trace:主线程在 BroadcastReceiver.onReceive() 里,正在执行数据库批量插入。

根因:开发者在广播接收器里直接做了数据库操作,没有考虑超时限制。

修复:广播接收器里只做轻量操作,耗时任务用 IntentServiceJobScheduler 处理。

特别注意:系统广播如 BATTERY_CHANGEDCONNECTIVITY_CHANGE 等,发送频率很高。如果在onReceive里做耗时操作,不仅自己ANR,还会拖慢整个系统的广播分发。我见过一个第三方SDK,在收到网络变化广播后做DNS解析,结果导致多个应用同时ANR。

3.5 总结与建议

ANR分析其实就三步:看日志定位类型、读trace找主线程卡点、分析代码修复根因。说起来简单,但实际排查时可能会遇到各种坑。

我个人建议你养成几个好习惯:

  • 开发阶段就开启 StrictMode,它能帮你提前发现主线程的潜在问题
  • 线上环境接入ANR监控平台,比如自己搭一个或者用第三方服务
  • 每次修复ANR后,写一个简单的复盘文档,记录根因和修复方案

嗯,ANR这部分内容就讲到这里。下一章咱们聊聊内存问题,那又是另一个让人头疼的话题了。