40、卡顿优化工具:BlockCanary、UI线程监控、帧率检测、卡顿阈值
卡顿优化,说白了就是跟「掉帧」和「ANR」死磕。
我做了这么多年Android,最怕听到的就是用户反馈「这App好卡」。你想想看,用户不会管你底层逻辑多复杂,他只关心滑起来顺不顺。所以,卡顿监控工具就是我们的「照妖镜」,能精准定位到底哪段代码在拖后腿。
40.1 卡顿的本质:掉帧与主线程阻塞
先聊个基础概念。Android的UI刷新是16ms一帧,也就是60fps。如果某个任务耗时超过16ms,系统就来不及渲染下一帧,画面就「卡」了一下。
为什么会这样?因为所有UI操作都在主线程(也叫UI线程)上执行。一旦主线程被IO读写、复杂计算、或者锁等待给堵住了,渲染任务就得排队。嗯,这里要注意:卡顿的根源,99%是主线程干了不该它干的活。
核心公式:
卡顿 = 单帧耗时 > 16ms (60fps) 或 > 33ms (30fps)
ANR = 主线程阻塞超过5秒(输入事件)或10秒(广播)
40.2 BlockCanary:主线程卡顿的「心电图」
BlockCanary是我个人非常喜欢的一个开源库。它不依赖TraceView那种笨重的采样方式,而是通过监听主线程的Looper消息分发,一旦发现某个消息执行时间超过阈值,就自动dump出堆栈信息。
40.2.1 原理简述
它利用了Looper.getMainLooper().setMessageLogging()这个API。在每个消息执行前后打点,计算耗时差。如果超过你设定的阈值(比如1000ms),就认为发生了卡顿。
// 核心伪代码
Looper.getMainLooper().setMessageLogging(new Printer() {
@Override
public void println(String x) {
if (x.startsWith(">>>> Dispatching to")) {
// 消息开始
startTime = System.currentTimeMillis();
} else if (x.startsWith("<<<< Finished to")) {
// 消息结束
long cost = System.currentTimeMillis() - startTime;
if (cost > BLOCK_THRESHOLD) {
// 卡顿了!dump堆栈
dumpStack();
}
}
}
});
我的经验:阈值别设太低。我见过有人设200ms,结果线上疯狂上报,全是误报。建议Debug包设500ms,Release包设1000ms。Release包还要注意,堆栈dump本身也耗性能,最好只采样上报。
40.2.2 集成与配置
集成很简单,在Application里初始化就行。但我建议你加上白名单过滤,比如某些第三方SDK的卡顿,你改不了,报了也白报。
// 推荐配置
BlockCanary.install(this, new BlockCanaryContext() {
@Override
public int provideBlockThreshold() {
return 1000; // 毫秒
}
@Override
public boolean isNeedDisplay() {
return BuildConfig.DEBUG; // 只在Debug包显示通知
}
@Override
public boolean filter(String stackTrace) {
// 过滤掉第三方库的堆栈
return !stackTrace.contains("com.tencent") && !stackTrace.contains("com.alibaba");
}
}).start();
40.3 UI线程监控:自己动手写一个轻量版
BlockCanary虽好,但有些场景下你不想引入第三方库。比如公司自研的监控SDK,或者你只想在特定页面做监控。这时候,自己写一个UI线程监控器就很有必要了。
我个人习惯用Handler的postDelayed机制。每隔100ms往主线程发一个Runnable,如果这个Runnable被延迟执行了,说明主线程正忙。
public class UiThreadMonitor {
private Handler mainHandler = new Handler(Looper.getMainLooper());
private Runnable checkTask = new Runnable() {
@Override
public void run() {
// 如果走到这里,说明主线程有空闲
// 记录当前时间戳
lastIdleTime = System.currentTimeMillis();
mainHandler.postDelayed(this, 100);
}
};
public void start() {
mainHandler.post(checkTask);
}
public void stop() {
mainHandler.removeCallbacks(checkTask);
}
// 外部调用,检查是否卡顿
public boolean isBlocking(long thresholdMs) {
return (System.currentTimeMillis() - lastIdleTime) > thresholdMs;
}
}
避坑指南:我曾经在线上包用过类似方案,结果发现这个监控器本身如果写得不好,反而会加剧卡顿。比如频繁创建匿名Runnable对象,导致GC频繁。建议用静态内部类+弱引用,或者直接复用Runnable实例。
40.4 帧率检测:用Choreographer看掉帧
帧率检测是更直观的卡顿指标。Android提供了Choreographer这个类,专门用来监听每一帧的渲染回调。
40.4.1 使用Choreographer.FrameCallback
注册一个FrameCallback,每次VSync信号到来时,系统会回调doFrame方法。通过计算两次回调的时间差,就能知道这一帧花了多久。
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
long lastFrameTime = 0;
@Override
public void doFrame(long frameTimeNanos) {
if (lastFrameTime == 0) {
lastFrameTime = frameTimeNanos;
} else {
long diffMs = (frameTimeNanos - lastFrameTime) / 1_000_000;
if (diffMs > 16) {
// 掉帧了!掉帧数 = (diffMs / 16) - 1
int droppedFrames = (int) (diffMs / 16) - 1;
Log.w("FPS", "掉帧 " + droppedFrames + " 帧, 耗时 " + diffMs + "ms");
}
lastFrameTime = frameTimeNanos;
}
// 继续监听下一帧
Choreographer.getInstance().postFrameCallback(this);
}
});
注意:这个回调本身也在主线程执行。如果你的监控逻辑太复杂,比如在里面写文件、做字符串拼接,那监控器自己就成了卡顿源。我一般只做简单的数值累加,然后每隔几秒统一上报一次。
40.4.2 帧率统计的常见指标
| 指标 | 含义 | 健康值 |
|---|---|---|
| FPS | 每秒帧数 | 55~60 |
| 掉帧率 | 掉帧帧数 / 总帧数 | < 5% |
| Jank | 单次掉帧超过3帧的卡顿次数 | 0次/分钟 |
| BigJank | 单次掉帧超过7帧的严重卡顿 | 0次/分钟 |
40.5 卡顿阈值:到底设多少才合理?
阈值设高了,漏报;设低了,误报。我踩过这个坑,跟大家分享下我的经验。
- BlockCanary阈值:Debug包500ms,Release包1000ms。Release包低于500ms的话,像SharedPreferences的commit操作都可能触发,太敏感了。
- 帧率掉帧阈值:连续3帧掉帧(即单帧耗时超过48ms)才算一次卡顿事件。偶尔掉一帧,用户其实感知不到。
- ANR阈值:系统默认是5秒。但线上监控我建议设3秒预警,因为一旦超过5秒系统就弹ANR对话框了,你连上报的机会都没有。
我的建议:不要只看平均值。平均值平滑了所有毛刺,看不出问题。要看P99(99分位值)和P999。比如P99帧率是55fps,说明99%的帧都正常,但剩下1%的帧可能掉到10fps以下,这才是用户骂娘的根源。
40.6 知识体系:卡顿监控工具全景图
下面这张图,是我自己梳理的卡顿监控工具分类。你可以把它当作一个检查清单,看看你的项目里还缺哪一块。
40.7 实战建议:搭建你的卡顿监控体系
工具再多,不会用也是白搭。我建议你按这个步骤来搭建:
- 先上帧率检测:用Choreographer在Debug包打个点,看看App整体帧率分布。如果平均帧率低于50fps,那肯定有问题。
- 再上BlockCanary:针对帧率低的页面,用BlockCanary抓具体堆栈。我遇到过最离谱的一次,是某个图片加载库在decode时直接在主线程做了,导致列表滑动时一帧卡了800ms。
- 最后定阈值:根据线上数据调整阈值。不要拍脑袋设一个值,要结合你的App类型。比如阅读类App对帧率要求不高,但游戏类App掉一帧用户都能感觉到。
重要提醒:线上监控一定要做采样率控制。全量采集的话,光上报堆栈的流量就能把用户手机跑废。我一般设5%的采样率,然后对重点页面(如首页、详情页)提升到20%。
好了,卡顿监控这块就聊这么多。工具只是辅助,关键还是写代码时要有「主线程意识」。每次写new Thread或者AsyncTask的时候,多问自己一句:这个操作能不能放到子线程?养成习惯后,卡顿自然会少很多。
公众号:蓝海资料掘金营,微信deep3321