卡顿监控与ANR分析:Systrace与TraceView使用、BlockCanary原理、ANR日志分析、主线程优化策略
卡顿和ANR,说白了就是Android开发者的「心头痛」。
我刚开始做性能优化那会儿,最怕的就是用户反馈「App卡死了」。你想想看,用户正刷着朋友圈,突然界面不动了,过一会儿弹出「应用无响应」——这体验,换谁都想卸载。
这一章,咱们就把卡顿和ANR彻底聊透。从工具使用到源码原理,再到实战优化策略,一条龙讲清楚。
1. 卡顿的本质:为什么UI会「掉帧」?
先问个问题:Android的流畅度标准是什么?
答案是16ms。系统每16.6ms发出一次VSYNC信号,通知CPU/GPU开始绘制下一帧。如果这一帧的绘制时间超过了16ms,就会发生「掉帧」——也就是我们感知到的卡顿。
我在项目中遇到过最夸张的情况:某个列表滑动时,单帧绘制时间达到了120ms。用户滑一下,画面要等七八帧才能跟上手指,体验极差。
核心结论:卡顿 = 主线程在16ms内没干完活。
主线程的任务包括:布局测量、绘制、事件分发、Handler消息处理。任何一个环节耗时过长,都会导致掉帧。
2. Systrace:系统级的「心电图」
Systrace是Google官方提供的系统级性能分析工具。我个人习惯叫它「Android的心电图」——它能精确记录每个线程在做什么,耗时多少。
2.1 基本用法
# 抓取5秒的systrace,重点关注应用包名
python systrace.py -t 5 -o trace.html \
-a com.example.myapp \
sched gfx view wm am
抓出来的trace.html用Chrome打开,你会看到这样的界面:
- 顶部是时间轴:每一行代表一个线程
- 彩色条块:代表各个系统调用或应用方法
- Alert区域:系统自动标记的「超时」或「警告」
嗯,这里要注意:Systrace的Alert不是万能的。我曾经遇到过一个卡顿问题,Systrace没有报任何Alert,但用户就是觉得卡。后来发现是布局嵌套太深,measure耗时虽然没超过16ms,但每次滑动都要重新measure,累积起来就卡了。
2.2 实战技巧
| 场景 | Systrace关注点 | 常见原因 |
|---|---|---|
| 列表滑动卡顿 | RecyclerView.onBindViewHolder耗时 | ViewHolder中做了IO操作或复杂计算 |
| 页面启动慢 | Activity.onCreate到onResume | 布局inflate耗时、Application初始化太重 |
| 动画掉帧 | Choreographer.doFrame | 主线程被其他Handler消息阻塞 |
我的习惯:抓Systrace时,我会同时抓取「sched」和「freq」两个tag。sched能看到线程调度情况,freq能看到CPU频率变化。有时候卡顿是因为CPU降频了,而不是代码问题。
3. TraceView:方法级别的「显微镜」
如果说Systrace是看全局的,那TraceView就是看局部的。它能精确到每个方法调用了多少次、耗时多少毫秒。
3.1 两种模式
- Sample模式:每隔一段时间采样一次调用栈。开销小,但可能漏掉短耗时方法。
- Instrument模式:记录每个方法的进入和退出。数据精确,但性能开销大。
我一般先用Sample模式快速定位「嫌疑方法」,再用Instrument模式精确分析。
3.2 怎么看TraceView结果?
打开trace文件后,重点关注两个指标:
- Inclusive Time:方法自身耗时 + 子方法耗时
- Exclusive Time:方法自身耗时(不含子方法)
举个例子:某个方法的Inclusive Time是100ms,Exclusive Time只有5ms。说明95ms都花在子方法里了。这时候你要继续往下钻,找到真正耗时的子方法。
避坑指南:我曾经在线上环境用Instrument模式抓TraceView,结果App直接卡死了5秒。因为Instrument模式会记录所有方法调用,在复杂页面下会产生海量数据。建议只在Debug包、特定场景下使用。
4. BlockCanary:自动化的卡顿监控
BlockCanary是一个开源库,原理很简单:利用主线程的Looper,在每次dispatchMessage前后打点。如果两次打点的时间差超过阈值(默认2秒),就认为发生了卡顿。
4.1 核心原理
// 伪代码:BlockCanary的核心逻辑
Looper.getMainLooper().setMessageLogging(new Printer() {
@Override
public void println(String x) {
if (x.startsWith(">>>>> Dispatching")) {
// 消息开始处理,记录时间
startTime = System.currentTimeMillis();
} else if (x.startsWith("<<<<< Finished")) {
// 消息处理完毕,计算耗时
long cost = System.currentTimeMillis() - startTime;
if (cost > BLOCK_THRESHOLD) {
// 超过阈值,上报卡顿信息
reportBlock(cost, getStackTrace());
}
}
}
});
说白了,BlockCanary就是给主线程装了一个「计时器」。每次消息处理超过阈值,就抓取当前调用栈,帮你定位是哪个方法卡住了主线程。
4.2 实战配置
// 初始化BlockCanary
BlockCanary.install(new BlockCanaryContext() {
@Override
public int provideBlockThreshold() {
return 2000; // 卡顿阈值,单位毫秒
}
@Override
public boolean isNeedDisplay() {
return BuildConfig.DEBUG; // 只在Debug包显示通知
}
@Override
public String getStackFoldPrefix() {
return "com.example.myapp"; // 只关注自己应用的调用栈
}
}).start();
我的经验:线上环境不建议用2秒阈值。我一般设成5秒,只监控「严重卡顿」。因为2秒阈值在低端机上太容易触发,会产生大量无效数据。另外,记得加上「堆栈去重」逻辑,同一个卡顿点不要重复上报。
5. ANR日志分析:从「现象」到「根因」
ANR(Application Not Responding)是卡顿的「终极形态」。系统会生成一个trace文件,保存在/data/anr/traces.txt中。
5.1 三种ANR类型
| 类型 | 触发条件 | 典型场景 |
|---|---|---|
| 输入事件超时 | 5秒内未处理完输入事件 | onTouchEvent中做了耗时操作 |
| 广播超时 | 前台广播10秒、后台广播60秒 | BroadcastReceiver中做了网络请求 |
| Service超时 | 前台Service 20秒、后台Service 200秒 | Service的onStartCommand中执行了耗时任务 |
5.2 如何分析traces.txt?
打开traces.txt,你会看到一堆线程的调用栈。重点关注「main」线程:
----- pid 12345 at 2024-01-15 10:30:00 -----
Cmd line: com.example.myapp
"main" prio=5 tid=1 Blocked
| group="main" sCount=1 dsCount=0 flags=1 obj=0x...
| sysTid=12345 nice=0 cgrp=default sched=0/0 handle=0x...
| state=S schedstat=( 12345678 987654 123 ) utm=123 stm=45 core=0
at com.example.myapp.MainActivity.onClick(MainActivity.java:50)
- waiting to lock <0x...> (a java.lang.Object)
at com.example.myapp.MainActivity$1.onClick(MainActivity.java:30)
- locked <0x...> (a java.lang.Object)
at android.view.View.performClick(View.java:7125)
关键信息解读:
- state=S:线程状态为Sleeping,说明在等待锁
- waiting to lock:正在等待某个锁释放
- locked:当前线程持有某个锁
我曾经遇到过一个经典ANR:主线程在等待一个锁,而这个锁被另一个线程持有。另一个线程在做网络请求,耗时10秒。结果就是主线程被活活「饿死」了。
分析ANR的黄金三步:
- 找到main线程的调用栈,看卡在哪个方法
- 如果是「waiting to lock」,找到锁的持有者
- 分析持有者线程在做什么,为什么耗时
6. 主线程优化策略:从「治标」到「治本」
前面讲了那么多工具和分析方法,最终都要落到优化上。我总结了一套「主线程优化三板斧」:
6.1 第一板斧:把耗时操作「扔出去」
所有可能超过16ms的操作,都不应该出现在主线程:
- IO操作:文件读写、数据库查询、SharedPreferences写入
- 网络请求:HTTP调用、Socket通信
- 复杂计算:图片压缩、JSON解析、加密解密
- 大对象创建:Bitmap加载、大量字符串拼接
用线程池或者协程把这些操作异步化:
// 错误示范:主线程做网络请求
fun onClick() {
val result = httpClient.get("https://api.example.com/data")
textView.text = result
}
// 正确示范:异步处理
fun onClick() {
lifecycleScope.launch(Dispatchers.IO) {
val result = httpClient.get("https://api.example.com/data")
withContext(Dispatchers.Main) {
textView.text = result
}
}
}
6.2 第二板斧:减少主线程的工作量
有些操作虽然不耗时,但频繁执行也会累积成卡顿:
- 布局优化:减少嵌套层级,使用ConstraintLayout
- 列表优化:ViewHolder复用、DiffUtil增量更新
- 动画优化:使用硬件加速、避免在动画中触发layout
- Handler优化:避免在主线程Handler中post大量消息
6.3 第三板斧:锁竞争优化
这是最容易忽略的点。主线程和子线程如果竞争同一个锁,主线程就可能被阻塞:
// 错误示范:主线程和子线程竞争锁
class DataManager {
private val lock = Any()
private var cache: String? = null
fun getData(): String {
synchronized(lock) { // 主线程可能被阻塞
if (cache == null) {
cache = loadFromDisk() // 耗时操作
}
return cache!!
}
}
}
// 正确示范:读写分离
class DataManager {
private val readWriteLock = ReentrantReadWriteLock()
private var cache: String? = null
fun getData(): String {
readWriteLock.readLock().lock() // 读锁不互斥
try {
return cache ?: loadData()
} finally {
readWriteLock.readLock().unlock()
}
}
private fun loadData(): String {
readWriteLock.readLock().unlock()
readWriteLock.writeLock().lock() // 写锁互斥
try {
// 双重检查
if (cache == null) {
cache = loadFromDisk()
}
return cache!!
} finally {
readWriteLock.writeLock().unlock()
}
}
}
我的建议:优化主线程,不要只盯着「耗时」这一个维度。有时候一个方法只耗时5ms,但它在列表滑动时被调用了100次,总耗时就是500ms。这种「高频低耗时」的优化,往往比「低频高耗时」更有价值。
7. 知识体系总览
下面这张图,是我梳理的卡顿监控与ANR分析的知识体系。你可以把它当作「优化地图」:
卡顿监控不是一蹴而就的事。你需要先建立「监控体系」,再配合「分析工具」,最后落地「优化策略」。这三者缺一不可。
我个人习惯在每个版本发布前,用Systrace跑一遍核心场景,再用BlockCanary做7×24小时的线上监控。双管齐下,基本能覆盖90%以上的卡顿问题。
最后说一句:性能优化没有银弹。工具再强大,也替代不了你对代码的理解。多读源码、多分析日志、多动手实践——这才是成为性能优化专家的唯一路径。
公众号:蓝海资料掘金营,微信deep3321