作为 Android 开发者,我们最不想看到的就是用户在反馈中说“你的软件卡死了”或“点不动了”。在 Android 生态系统中,这种卡顿现象有一个专有名词:ANR (Application Not Responding,应用程序无响应)。在这篇文章中,我们将深入探讨什么是 ANR,它是如何产生的,最重要的是,我们如何通过编写高质量的代码和正确的架构来彻底预防它。如果你想让你的应用如丝般顺滑,这篇文章正适合你。
什么是 ANR?
简单来说,当我们的应用在很长一段时间内(通常超过 5 秒)未能响应用户的交互时,系统就会认为它“死”了。让我们想象一下这种场景:你正在主线程(UI 线程)上运行一个非常耗时的任务,比如下载一张大图片或者进行复杂的数据库运算。在此期间,界面是冻结的,用户的任何点击或触摸操作都无法被执行。
大约 5 秒后,如果主线程还在忙,Android 系统就会介入,向用户弹出一个类似下面这样的对话框:
这个对话框询问用户是选择“继续等待”还是“强制关闭”。这对用户体验来说是灾难性的。一旦用户频繁看到这个对话框,他们很可能就会卸载我们的应用。
#### ANR 与 崩溃 的区别
这里我们需要理清一个常见的误区:ANR 不是崩溃。
- 崩溃:通常是由代码逻辑错误引起的,比如空指针异常。例如,我们试图设置一个 EditText 的文本,但这个 EditText 对象是 null,而且没有 try-catch 语句来捕获异常。这时应用会崩溃并强制关闭。用户看到的通常是“应用已停止运行”的提示。崩溃往往意味着代码出了 Bug。
- ANR:则是一种性能问题。它发生在“主”线程被阻塞,导致系统无法处理用户的输入事件(如按键、触摸屏幕)时。代码逻辑可能没有错误,没有抛出异常,只是单纯的“忙”或者“卡住了”。
ANR 的触发条件
具体来说,Android 系统会在以下特定场景下触发 ANR:
- Activity 响应超时:当我们的 Activity 处于前台时,如果在 5 秒内没有响应用户的输入事件(比如按键或屏幕触摸)。
- BroadcastReceiver 超时:这有一个非常严格的时间限制。如果我们的应用正在前台运行,BroadcastReceiver 必须在极短的时间内(通常是几秒甚至更短,取决于 Android 版本和具体类型)执行完毕。如果没有在规定时间内完成,就会触发 ANR。如果没有在前台,这个时间限制会更宽松一些,但依然很严格。
- Service 超时:Service 也有特定的超时限制,前台服务和后台服务都有不同的响应时间要求。
在我们的分析案例中,Trace 文件显示大多数线程其实是运行正常的,它们在 MessageQueue 中空闲等待。问题在于主线程被一个长时间的同步操作占用了,导致它无法处理队列中的其他消息(即用户的点击操作)。
如何预防 ANR?
预防 ANR 的核心原则非常简单:永远不要在主线程上执行耗时任务。
让我们来看看具体有哪些操作容易导致主线程阻塞:
- 网络操作:访问网络、下载图片、API 请求。
- 数据库操作:大量的查询、插入或更新。
- 复杂的计算:图像处理、大量的 JSON 解析、加密解密。
- 同步 Binder 调用:如果你的主线程需要等待另一个进程返回结果,而那个进程处理很慢。
- 锁和死锁:主线程在等待一个永远不会释放的锁。
#### 解决方案:将任务移至后台线程
为了解决这些问题,我们可以使用多种多线程工具。以下是几种常见且有效的方式:
#### 1. 使用 Thread 和 Handler (基础方案)
这是最原始的方式。我们创建一个新的线程来执行耗时任务,任务完成后通过 Handler 将结果传回主线程更新 UI。
// 示例:在子线程中处理耗时任务,然后更新 UI
public class MainActivity extends AppCompatActivity {
private static final int MSG_UPDATE_UI = 1;
private Handler mHandler = new Handler(new Handler.Callback() {
@Override
public boolean handleMessage(Message msg) {
if (msg.what == MSG_UPDATE_UI) {
// 这里已经回到主线程,可以安全地更新 UI
TextView textView = findViewById(R.id.status_text);
textView.setText("任务完成!");
return true;
}
return false;
}
});
private void startHeavyTask() {
new Thread(new Runnable() {
@Override
public void run() {
// 模拟耗时操作 (例如网络请求或复杂计算)
try {
Thread.sleep(6000); // 故意超过 5 秒来演示如果放在主线程会发生什么
} catch (InterruptedException e) {
e.printStackTrace();
}
// 任务完成,通知主线程更新 UI
mHandler.sendEmptyMessage(MSG_UPDATE_UI);
}
}).start();
}
}
代码解析:在这个例子中,我们将 INLINECODE43ccc79c 放在了一个新创建的线程中。这确保了主线程依然保持响应,用户不会看到 ANR 对话框。只有当工作完成后,我们才通过 INLINECODE848cf313 通知主线程更新文本。
#### 2. 使用 AsyncTask (旧版兼容,虽然不推荐用于新项目,但理解其原理很重要)
AsyncTask 是为了简化 UI 线程和后台线程交互而设计的。
// 示例:使用 AsyncTask 处理后台任务
private class DownloadTask extends AsyncTask {
@Override
protected String doInBackground(String... urls) {
// 这里在后台线程运行,可以执行耗时操作
int count = urls.length;
long totalSize = 0;
for (int i = 0; i < count; i++) {
totalSize += DownloadFile(urls[i]); // 模拟下载
publishProgress((int) ((i / (float) count) * 100));
}
return "总大小: " + totalSize;
}
@Override
protected void onProgressUpdate(Integer... progress) {
// 这里在主线程运行,用于更新进度条
setProgressPercent(progress[0]);
}
@Override
protected void onPostExecute(String result) {
// 这里在主线程运行,任务执行完毕
showDialog("下载完成: " + result);
}
}
注意:在现代 Android 开发(如 Kotlin Coroutines 或 RxJava)中,AsyncTask 已经被弃用,但在维护老代码时,我们依然会经常见到它。它封装了线程切换的逻辑,避免了手动写 Handler 的繁琐。
#### 3. 使用 IntentService (适用于后台任务)
如果我们的任务是独立的,不需要立即更新 UI,比如上传日志或同步数据,IntentService 是最好的选择。它天生是单线程的,处理完 Intent 后会自动停止,非常适合耗时的队列任务。
#### 4. 现代化方案:Kotlin Coroutines (协程)
如果你正在使用 Kotlin(强烈建议使用),协程是处理并发最优雅的方式。它允许我们以非阻塞的方式编写看起来同步的代码。
// 示例:使用协程防止 ANR
fun fetchData() {
// 在主线程启动协程
viewModelScope.launch {
// 显示加载状态
textView.text = "加载中..."
// withContext(Dispatchers.IO) 将任务切换到 IO 线程
val result = withContext(Dispatchers.IO) {
// 模拟网络请求,这里在 IO 线程运行,不会阻塞主线程
Thread.sleep(6000)
"数据加载成功"
}
// 代码执行到这里,自动切回主线程
textView.text = result
}
}
诊断 ANR:当问题发生时我们该怎么做?
如果我们的应用已经在生产环境中出现了 ANR,或者我们在开发测试中触发了它,如何找到原因呢?
#### 1. 查看 Trace 文件
当 ANR 发生时,系统会生成一个 Trace 文件。通常位于 /data/anr/traces.txt。这个文件记录了应用中所有线程的堆栈信息。
- 寻找主线程:在文件中找到
"main"线程的部分。 - 查看堆栈:看看主线程在做什么。通常你会发现它正处于 INLINECODE7a02b0fb 状态,但在执行一个耗时的函数,或者处于 INLINECODE58156196 状态,在等待某个资源。
#### 2. 使用 Android Studio 的 Profiler
现代开发中,我们很少去手动拉取 traces.txt。使用 Android Studio 内置的 CPU Profiler 可以实时监控线程状态。
- 当你怀疑有卡顿时,开启 Profiler 记录。
- 如果主线程占用率过高,或者长时间处于 Running 状态而不处理消息,就是 ANR 的前兆。
#### 3. 检查死锁
有时候 ANR 是由死锁引起的。主线程在等待一个由工作线程持有的锁,而工作线程反过来需要主线程处理某些东西才能继续。这种情况下,主线程的 Trace 会显示它在等待一个 INLINECODEbc132309 或 INLINECODEb46930b4。这通常需要仔细审查代码中的同步块(synchronized)。
使用 StrictMode 捕捉违规操作
为了在开发阶段就发现潜在的问题,Android 提供了 StrictMode(严格模式)。从 API 9 开始,我们可以利用它来检测我们在主线程上做了哪些不该做的事(比如磁盘读写或网络访问)。
StrictMode 不会直接修复代码,但它会通过“惩罚”机制(比如闪红屏、打印日志或崩溃)来提醒开发者。
// 示例:在 Application 或 Activity 中启用 StrictMode
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
if (Build.VERSION.SDK_INT > 9) {
// 启用线程策略检测
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectDiskReads() // 检测在主线程读取磁盘
.detectDiskWrites() // 检测在主线程写入磁盘
.detectNetwork() // 检测在主线程进行网络操作
.penaltyLog() // 违规时打印日志
.penaltyDeath() // (可选) 违规时直接崩溃,强制修复
.build());
// 启用 VM 策略检测 (例如检测 Activity 泄漏)
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()
.penaltyLog()
.build());
}
}
}
实用建议:在开发环境中启用 INLINECODE77a35208,并在 Logcat 中搜索 INLINECODE98f09d67。如果你看到红色警告,说明你的代码有潜在 ANR 风险,必须将相关逻辑移到后台线程。
Android 运行状况
对于上架到 Google Play 的应用,平台会对应用的稳定性进行监控。当我们的应用表现出过度的 ANR 率时,我们会收到“Android 运行状况”警报。这不仅影响用户的评分,还可能导致应用被降权展示。
系统判断 ANR 过多的标准(通常基于每日会话的百分比)包括:
- 在至少 0.47% 的每日会话中表现出至少一次 ANR。
- 在更极端的情况下,如果在 0.14% 的会话中出现 4 次甚至更多的 ANR,这是非常严重的质量问题。
作为开发者,我们的目标是尽可能将这些数字降为 0,或者至少保持在极低的水平(例如 0.05% 以下)。
总结与最佳实践
在这篇文章中,我们学习了 ANR 的本质、触发条件以及如何使用线程模型来避免它。让我们总结一下关键点:
- 主线程仅负责 UI:主线程应该只负责绘制界面和响应用户点击。任何超过 100ms 的操作(实际上只要超过几毫秒就有风险,因为 5秒是非常宽裕的上限)都应该考虑移到后台。
- 利用工具:开发时使用 StrictMode,测试时使用 Profiler,发布后关注 ANR 追踪数据。
- 选择正确的工具:对于简单的后台任务,可以用 Thread;对于数据库或文件 I/O,使用 AsyncTask 或 Executors;对于生命周期感知的长任务,推荐使用 Kotlin Coroutines 或 RxJava。
- 代码审查:在写代码时时刻保持警惕,问自己一句:“这段代码运行在哪个线程?如果它跑得慢,会不会卡死界面?”
通过遵循这些准则,我们可以确保我们的应用始终保持流畅,为用户提供优秀的交互体验。希望这篇指南能帮助你彻底告别 ANR 问题!