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的「第一手资料」。

我个人习惯按以下步骤来分析:

  1. 找到主线程:搜索"main"或"Thread-1",看主线程在干什么
  2. 定位阻塞点:看堆栈最顶层的调用,通常是wait、sleep、lock或耗时IO
  3. 分析锁竞争:如果主线程在等待某个锁,找到持有锁的线程
  4. 检查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是必然的。

修复方案很简单:

  1. 把耗时操作移到子线程
  2. 用AsyncTask或线程池处理
  3. 压缩完成后通过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都能在半小时内定位到根因。

嗯,这一章就到这里。下一章我们聊聊内存泄漏,那也是个让人头疼的问题。