3. ANR问题深度剖析:原理、触发场景、log分析及实战案例
ANR,全称Application Not Responding。说白了,就是你的App卡住了,系统看不下去了,直接弹个对话框问用户:「要关掉它吗?」。
我在项目中遇到过不少ANR,有些是代码写得不小心,有些是第三方SDK搞的鬼。但不管原因是什么,ANR对用户体验的伤害是致命的。用户可能正在玩游戏、填表单,突然弹个框,你说气不气?
3.1 ANR的底层原理
ANR的本质是什么?是系统对「超时」的监控机制。Android系统给每个组件设定了一个时间上限,如果在这个时间内没有完成响应,系统就判定为ANR。
具体来说,系统通过两个关键角色来监控:
- AMS(ActivityManagerService):负责调度和监控四大组件的超时
- Watchdog:系统级别的守护线程,监控系统服务是否卡死
为什么会超时?因为主线程被阻塞了。Android的主线程(也叫UI线程)负责处理所有用户交互、绘制和四大组件的生命周期回调。一旦主线程被长时间占用,系统就收不到「我还活着」的信号,ANR就来了。
核心要点:ANR不是Bug,而是系统的一种保护机制。它告诉你:「你的App已经失控了,我来帮你收场。」
3.2 ANR的触发场景
根据我的经验,ANR主要发生在以下五种场景。你想想看,是不是都跟主线程有关?
| 场景 | 超时时间 | 典型原因 |
|---|---|---|
| 输入事件(按键、触摸) | 5秒 | 主线程执行耗时操作,如网络请求、大文件读写 |
| BroadcastReceiver | 10秒(前台)/ 60秒(后台) | onReceive中做了耗时操作,比如数据库查询 |
| Service | 20秒 | onStartCommand或onBind执行时间过长 |
| ContentProvider | 10秒 | query/insert/update/delete操作太慢 |
| JobScheduler | 几分钟不等 | JobService的onStartJob未及时返回 |
嗯,这里要注意:输入事件5秒超时是最常见的。用户点了一下屏幕,5秒没反应,系统就判定ANR。我见过一个案例,是因为在onClick里直接做了图片压缩,图片还特别大,结果妥妥的ANR。
3.3 ANR的Log分析
遇到ANR,第一件事是找log。系统会在/data/anr/目录下生成traces.txt文件。这个文件记录了所有线程的堆栈信息,是分析ANR的「第一手资料」。
我个人习惯按以下步骤来分析:
- 找到主线程:搜索"main"或"Thread-1",看主线程在干什么
- 定位阻塞点:看堆栈最顶层的调用,通常是wait、sleep、lock或耗时IO
- 分析锁竞争:如果主线程在等待某个锁,找到持有锁的线程
- 检查CPU负载:看CPU使用率,如果CPU被其他进程占满,主线程可能抢不到时间片
举个例子,这是我从一个真实案例中提取的traces片段:
"main" prio=5 tid=1 Blocked
at java.lang.Object.wait(Native Method)
at java.lang.Object.wait(Object.java:442)
at com.example.MyService.onStartCommand(MyService.java:45)
- waiting to lock <0x12345678> (a com.example.MyLock)
at android.app.ActivityThread.handleServiceArgs(ActivityThread.java:4567)
...
看到没?主线程在wait,等待一把锁。那这把锁被谁拿着?继续往下翻:
"Binder:1234_5" prio=5 tid=15 Blocked
at com.example.MyLock.doSomething(MyLock.java:30)
- waiting to lock <0x12345678> (a com.example.MyLock)
...
好家伙,两个线程都在等同一把锁。这就是典型的死锁。我曾经在一个支付SDK里遇到过类似问题,主线程等锁,Binder线程也等锁,两边都卡死了。
小技巧:如果traces.txt太大,可以用grep命令过滤。比如grep -A 20 "main" /data/anr/traces.txt,只看主线程的堆栈。
3.4 实战案例:一个典型的ANR修复过程
我记得有一次,用户反馈App在点击「保存」按钮后经常ANR。我拿到log后,发现主线程堆栈停在Bitmap.compress上。
代码大概是这样的:
public void onSaveClick(View v) {
// 直接在主线程压缩图片
Bitmap bitmap = BitmapFactory.decodeFile(path);
FileOutputStream fos = new FileOutputStream(file);
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, fos);
// 这里卡住了,因为图片有10MB
saveToDatabase(bitmap);
}
问题很明显:图片压缩和文件IO都放在主线程了。10MB的图片压缩,在低端机上可能要跑好几秒,ANR是必然的。
修复方案很简单:
- 把耗时操作移到子线程
- 用AsyncTask或线程池处理
- 压缩完成后通过Handler切回主线程更新UI
修复后的代码:
public void onSaveClick(View v) {
new Thread(new Runnable() {
@Override
public void run() {
Bitmap bitmap = BitmapFactory.decodeFile(path);
FileOutputStream fos = new FileOutputStream(file);
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, fos);
// 子线程处理完,切回主线程
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this, "保存成功", Toast.LENGTH_SHORT).show();
}
});
}
}).start();
}
避坑指南:我曾经以为用AsyncTask就万事大吉了,结果在Android 11上发现AsyncTask默认是串行执行的。如果多个任务排队,后面的任务会等前面的完成。建议直接用线程池或Kotlin协程。
3.5 预防ANR的最佳实践
说了这么多,怎么从根源上避免ANR?我总结了几个原则:
- 主线程只做UI操作:任何耗时超过16ms的操作,都扔到子线程
- 使用StrictMode:开发阶段开启StrictMode,它会直接告诉你哪里在主线程做了耗时操作
- 避免锁竞争:主线程尽量不要持有锁,如果必须用锁,确保锁的粒度足够小
- 监控第三方SDK:很多ANR是第三方SDK引起的,比如推送SDK在主线程做网络请求
- 使用异步框架:RxJava、Kotlin协程、WorkManager都是好选择
最后说一句:ANR不可怕,可怕的是不知道怎么分析。掌握了traces文件的阅读方法,再配合代码审查,大部分ANR都能在半小时内定位到根因。
嗯,这一章就到这里。下一章我们聊聊内存泄漏,那也是个让人头疼的问题。