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 知识体系:卡顿监控工具全景图

下面这张图,是我自己梳理的卡顿监控工具分类。你可以把它当作一个检查清单,看看你的项目里还缺哪一块。

卡顿监控工具全景图 卡顿监控 主线程监控 BlockCanary Looper Printer Handler延迟检测 帧率检测 Choreographer SurfaceFlinger dumpsys GPU Profiling 阈值与策略 Debug: 500ms Release: 1000ms P99 / P999 分位 目标:发现卡顿 → 定位堆栈 → 修复上线

40.7 实战建议:搭建你的卡顿监控体系

工具再多,不会用也是白搭。我建议你按这个步骤来搭建:

  1. 先上帧率检测:用Choreographer在Debug包打个点,看看App整体帧率分布。如果平均帧率低于50fps,那肯定有问题。
  2. 再上BlockCanary:针对帧率低的页面,用BlockCanary抓具体堆栈。我遇到过最离谱的一次,是某个图片加载库在decode时直接在主线程做了,导致列表滑动时一帧卡了800ms。
  3. 最后定阈值:根据线上数据调整阈值。不要拍脑袋设一个值,要结合你的App类型。比如阅读类App对帧率要求不高,但游戏类App掉一帧用户都能感觉到。

重要提醒:线上监控一定要做采样率控制。全量采集的话,光上报堆栈的流量就能把用户手机跑废。我一般设5%的采样率,然后对重点页面(如首页、详情页)提升到20%。

好了,卡顿监控这块就聊这么多。工具只是辅助,关键还是写代码时要有「主线程意识」。每次写new Thread或者AsyncTask的时候,多问自己一句:这个操作能不能放到子线程?养成习惯后,卡顿自然会少很多。


公众号:蓝海资料掘金营,微信deep3321