卡顿监控与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的黄金三步:

  1. 找到main线程的调用栈,看卡在哪个方法
  2. 如果是「waiting to lock」,找到锁的持有者
  3. 分析持有者线程在做什么,为什么耗时

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分析的知识体系。你可以把它当作「优化地图」:

卡顿监控与ANR分析知识体系 卡顿/ANR的本质 主线程16ms内未完成工作 Systrace 系统级·线程调度·帧率 TraceView 方法级·耗时统计·调用链 BlockCanary 自动化·Looper打点·堆栈 ANR日志 traces.txt·线程状态·锁 分析方法 定位耗时方法 → 分析调用栈 → 识别锁竞争 → 确认根因 异步化 IO/网络/计算 → 子线程 减负 布局优化·列表优化·动画 锁优化 读写分离·减少竞争 目标:主线程单帧耗时 < 16ms,无ANR

卡顿监控不是一蹴而就的事。你需要先建立「监控体系」,再配合「分析工具」,最后落地「优化策略」。这三者缺一不可。

我个人习惯在每个版本发布前,用Systrace跑一遍核心场景,再用BlockCanary做7×24小时的线上监控。双管齐下,基本能覆盖90%以上的卡顿问题。

最后说一句:性能优化没有银弹。工具再强大,也替代不了你对代码的理解。多读源码、多分析日志、多动手实践——这才是成为性能优化专家的唯一路径。


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