对用户来说,内存占用高耗费电量耗费流量 可能不容易被发现,但是用户对 卡顿 特别敏感,很容易直观感受到。

注意:如果出现图片不显示问题请移步 图片不显示问题

本文主要记载

  • 1 如何定义卡顿?
  • 2 如何排查卡顿?
    • 2.1 Linux 命令组合排查
    • 2.2 使用 top 命令查看进程信息
  • 3 有没有方便的 Android 卡顿排查工具?
    • 3.1 TraceView
    • 3.2 Nanoscope
    • 3.3 systrace
    • 3.4 Simpleperf
    • 3.5 可视化方法(Android Studio Profiler)
    • 3.6 Android Performance Montitor(BlockCanary)
    • 3.7 Profilo
  • 4 卡顿现场
    • 4.1 获取 java 线程状态
    • 4.2 获得所有线程堆栈
    • 4.3 SIGQUIT 信号实现
    • 4.4 Hook 实现
  • 5 相关 Demo 学习
    • 5.1 抓取 CPU 数据
    • 5.2 PLTHook 监控 Thread 的创建
    • 5.3 Loop 监控卡顿
  • 总结

1 如何定义卡顿?

60帧 每秒是目前最合适的图像显示速度,也是绝大部分 Android 设备设置的调试频率,如果在 16ms 内顺利完成界面刷新操作可以展示出流畅的画面,而由于任何原因导致接收到 VSYNC 信号的时候无法完成本次刷新操作,就会产生掉帧的现象,刷新帧率自然也就跟着下降(假定刷新帧率由正常的 60fps 降到 30fps ,用户就会明显感知到卡顿)。

卡顿的原因可以定义为 UI 复杂度问题没有提前或异步初始化问题内存泄漏频繁 GC 的问题 等,卡顿的解决思路是集结了 启动优化、内存优化、UI 优化 的所有知识点,所以这篇也是对之前的扩展补充。

造成卡顿的原因可能有千百种,不过最终都会反映到 CPU 时间上。

我们可以把 CPU 时间分为两种:

  1. 用户时间:执行用户态应用程序代码所消耗的时间
  2. 系统时间:执行内核态系统调用所消耗的时间,包括 I/O、锁、中断以及其他系统调用的时间。

2 如何排查卡顿

2.1 Linux 命令组合排查

  1. 获取 CPU 核心数
1
cat /sys/devices/system/cpu/possible
  1. 获取某个 CPU 的频率
1
cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq

我们需要根据设备 CPU 性能来 “看菜下饭” ,例如线程池使用线程数根据 CPU 的核心数,一些高级的 AI 功能只在主频比较高或者带有 NPU 的设备开启。

  1. 查看 CPU 的使用率

整个系统的 CPU 使用情况

1
cat /proc/stat

结果:

1
2
3
4
5
6
7
8
9
10
11
12
>adb shell
cat /proc/stat
cpu 166492 36473 149538 7550789 34852 0 9110 0 0 0
cpu0 31858 4048 34220 808812 5172 0 4604 0 0 0
cpu1 22897 4439 25504 894380 7874 0 1514 0 0 0
cpu2 29068 6317 27063 908505 7657 0 673 0 0 0
cpu3 25916 6157 23508 931591 6625 0 259 0 0 0
cpu4 28689 5321 22388 971414 2544 0 1030 0 0 0
cpu5 10136 3778 6687 1009482 1551 0 606 0 0 0
cpu6 8074 3398 5163 1014162 1489 0 218 0 0 0
cpu7 9854 3015 5005 1012443 1940 0 206 0 0 0
...

第一行的数据表示的是 CPU 总的使用情况

  1. 这些数值的单位都是 jiffiesjiffies 是内核中的一个全局变量,用来记录系统启动以来产生的节拍数,在 Linux 中,一个节拍大致可以理解为操作系统进程调度的最小时间片,不同的 Linux 系统内核这个值可能不同,通常在 1ms10ms 之间。

  2. cpu 166492 36473 149538 7550789 34852 0 9110 0 0 0

    • user(166492) 从系统启动开始累积到当前时刻,处于用户态的运行时间,不包含 nice 值为负的进程。
    • nice(36473) 从系统启动开始累积到当前时刻,nice 值为负的进程所占用的 CPU 时间。
    • system(149538) 从系统启动开始累积到当前时刻,处于核心态的运行时间。
    • idle(7550789) 从系统启动开始累积到当前时刻,除 IO 等待时间以外的其他等待时间。
    • iowait(34852) 从系统启动开始累积到当前时刻,IO 等待时间。(since 2.5.41)
    • irq(0) 从系统启动开始累积到当前时刻,硬中断时间。(since 2.6.0-test4)
    • softirq(9110) 从系统启动开始累积到当前时刻,软中断时间。(since 2.6.0-test4)
    • stealstolen(0) 这是在虚拟环境中运行时在其他操作系统中花费的时间。(since 2.6.11)
    • guest(0) 这是运行 Linux 内核控制下的来宾操作系统的虚拟 CPU 所花费的时间。(since 2.6.24)
    • guest_nice(0) 运行 niced 客户端的时间( Linux 内核控制下的客户操作系统的虚拟CPU)。(since Linux 2.6.33)

    具体使用率计算请查看 《Linux环境下进程的CPU占用率》 还是有点麻烦的 ~

2.2 使用 top 命令查看进程信息

  1. 直接输入 top 可查看所有进程的 cpu 使用情况

    几个常用的参数:

    • -d: 后面接秒数,就是整个进程画面更新的频率。默认是 5 秒。
    • -b: 以批处理的方式执行 top,还有更多的参数可用。通常会搭配数据流重导向,将批处理的结果输出为文件。
    • -n: 与 -b 搭配,意义是,需要进行几次 top 的输出结果。
    • -p: 指定某个 PID 来进行观察监测。
    • 在 top 执行过程中可以使用的按键命令:
    • ?: 显示在 top 中可以输入的按键命令。
    • P: 按照 CPU 的使用资源排序显示。
    • M: 按内存(Memory)的使用资源排序显示。
    • N: 按 PID 来排序。
    • T: 按该进程使用的 CPU 时间积累(TIME+)排序。
    • k: 给某个 PID 一个信号(signal)。
    • r: 给某个 PID 重新确定一个值。
    • 1: 显示所有 CPU 占用信息。
  2. 监测进程 13620

1
top -d 2 -p 13620

会一直输出进程 13620 的信息

1
2
3
4
5
6
7
8
top - 16:27:35 up 4 days, 7:43, 2 users, load average: 0.35, 0.47, 0.44
Tasks: 1 total, 1 running, 0 sleeping, 0 stopped, 0 zombie
Cpu(s): 0.1%us, 3.1%sy, 0.0%ni, 96.5%id, 0.0%wa, 0.0%hi, 0.3%si, 0.0%st
Mem: 16320632k total, 1790796k used, 14529836k free, 233168k buffers
Swap: 8232952k total, 0k used, 8232952k free, 941540k cached

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
13620 test1370 20 0 11060 944 760 R 53.4 0.0 0:04.78 netperf

3 有没有方便的 Android 卡顿排查工具?

日常开发中比较熟悉的工具分为两个流派:

  • instrument 流派

    获取一段时间内所有函数的调用过程,可以通过分析这段时间内的函数调用流程,再进一步分析待优化的点。

  • sample 流派

    有选择性或者采用抽样的方式观察某些函数调用过程,可以通过这些有限的信息推测出流程中的可疑点,然后再继续细化分析。

3.1 Traceview

我在 《App 初体验-启动优化 2.2.1 TraceView》 中讲过 TraceView 的基本使用方法。Traceview 属于 instrument 类型,它利用 Android Runtime 函数调用的 event 事件,将函数运行的耗时和调用关系写入 trace 文件中,它可以用来查看整个过程有哪些函数调用。

工具本身带来的性能开销过大,有时无法反映真实的情况。在 Android 5.0 之后,新增了startMethodTracingSampling 方法,可以使用基于样本的方式进行分析,以减少分析对运行时的性能影响。新增了 sample 类型后,就需要我们在开销和信息丰富度之间做好权衡。

3.2 Nanoscope

《App 初体验-启动优化 2.2.4 Nanoscope》 查看原理即项目地址。它是在 instrument 类型的性能分析工具中性能损耗比较小的。

3.3 systrace

《App 初体验-启动优化 2.2.2 systrace》 查看具体使用方法。其中还讲到了 systrace + 函数插桩 AOP

systrace 工具只能监控特定系统调用的耗时情况,所以它是属于 sample 类型,而且性能开销非常低。但是它不支持应用程序代码的耗时分析,所以在使用时有一些局限性。

3.4 Simpleperf

分析 Native 函数时使用的工具,在 Android Studio 3.2 也在 Profiler 中直接支持 Simpleperf

Simpleperf 属于 sample 类型,它的性能开销非常低,使用火焰图展示分析结果。

总结:

选择哪种工具,需要看具体的场景。如果需要分析 Native 代码的耗时,可以选择 Simpleperf;如果想分析系统调用,可以选择 systrace;如果想分析整个程序执行流程的耗时,可以选择 Traceview 或者插桩版本的 systrace
systrace 利用了 Linuxftrace 调试工具,相当于在系统各个关键位置都添加了一些性能探针,也就是在代码里加了一些性能监控的埋点。Androidftrace 的基础上封装了atrace,并增加了更多特有的探针,例如 GraphicsActivity ManagerDalvik VMSystem Server 等。

3.5 可视化方法(Android Studio Profiler)

Android Studio 3.2CPU Profiler 中直接集成了几种性能分析工具

  • Sample Java Methods 的功能类似于 Traceviewsample 类型。
  • Trace Java Methods 的功能类似于 Traceviewinstrument 类型。
  • Trace System Calls 的功能类似于 systrace
  • SampleNative (API Level 26+) 的功能类似于 Simpleperf

这些分析工具都支持了 Call ChartFlame Chart 两种展示方式。

  1. Call Chart

    Call ChartTraceviewsystrace 默认使用的展示方式。它按照应用程序的函数执行顺序来展示,适合用于分析整个流程的调用。举一个最简单的例子,A 函数调用 B 函数,B 函数调用 C 函数,循环三次,就得到了下面的 Call Chart

    图片来源于 Android开发高手课

    Call Chart 就像给应用程序做一个心电图,我们可以看到在这一段时间内,各个线程的具体工作,比如是否存在线程间的锁、主线程是否存在长时间的 I/O 操作、是否存在空闲等。

  2. Flame Chart

Flame Chart 也就是火焰图。它跟 Call Chart 不同的是,Flame Chart 以一个全局的视野来看待一段时间的调用分布,它就像给应用程序拍 X 光片,可以很自然地把时间和空间两个维度上的信息融合在一张图上。

上面函数调用的例子,换成火焰图的展示结果如下。

图片来源于 Android开发高手课

当我们不想知道应用程序的整个调用流程,只想直观看出哪些代码路径花费的 CPU 时间较多时,火焰图就是一个非常好的选择。

火焰图还可以使用在各种各样的维度,例如内存、I/O 的分析。有些内存可能非常缓慢地泄漏,通过一个内存的火焰图,我们就知道哪些路径申请的内存最多,有了火焰图我们根本不需要分析源代码,也不需要分析整个流程。

3.6 Android Performance Monitor(BlockCanary)

AndroidPerformanceMonitor 是一个Android平台的一个非侵入式的性能监控组件,应用只需要实现一个抽象类,提供一些该组件需要的上下文环境,就可以在平时使用应用的时候检测主线程上的各种卡慢问题,并通过组件提供的各种信息分析出原因并进行修复。

3.7 Profilo

2018 年 3 月,Facebook 开源了一个叫 Profilo 的库, 它收集了各大方案的优点。

  1. 集成 atrace 功能

    这样所有 systrace 的探针我们都可以拿到,例如四大组件生命周期、锁等待时间、类校验、GC 时间等。

  2. 快速获取 Java 堆栈

    这里有一个误区,大家都觉得在某个线程不断地获取主线程堆栈是不耗时的。但是事实上获取堆栈的代价是巨大的,它要暂停主线程的运行。

    profilo 巧妙的解决的这个问题,可以实现线程一边继续跑步,我们还可以帮它做检查,而且耗时基本忽略不计。

不用插桩、性能基本没有影响、捕捉信息还全,那 Profilo 不就是完美的化身吗?当然由于它利用了大量的黑科技,兼容性是需要注意的问题。它内部实现有大量函数的 Hookunwind 也需要强依赖 Android Runtime 实现。Facebook 已经将 Profilo 投入到线上使用,但由于目前 Profilo 快速获取堆栈功能依然不支持 Android 8.0Android 9.0,鉴于稳定性问题,建议采取抽样部分用户的方式来开启该功能。

帮助理解

每个工具都可以生成不同的展示方式,我们需要根据不同的使用场景选择合适的方式。


4 卡顿现场

4.1 获取 java 线程状态

通过 ThreadgetState 方法可以获取线程状态,WAITINGTIME_WAITINGBLOCKED 都是需要特别注意的状态。

BLOCKED: 是指线程正在等待获取锁,对应的是下面代码中的情况一;

WAITING: 是指线程正在等待其他线程的“唤醒动作”,对应的是代码中的情况二。

1
2
3
synchronized (object)  {     // 情况一:在这里卡住 --> BLOCKED
object.wait(); // 情况二:在这里卡住 --> WAITING
}

不过当一个线程进入 WAITING 状态时,它不仅会释放 CPU 资源,还会将持有的 object 锁也同时释放。

更多相关资料 《Java 线程 Dump 分析》

4.2 获得所有线程堆栈

当我们发现有个线程导致主线程 BLOCKED ,需要通过 Thread.getAllStackTraces() 拿所有线程的堆栈,需要注意的是在 Android 7.0getAllStackTraces是不会返回主线程的堆栈的。

4.4 SIGQUIT 信号实现

注:需要 root

Android 应用发生 ANR 时,系统会发出 SIGQUIT 信号给发生 ANR 进程。系统信号捕捉线程触发输出/data/anr/traces.txt 文件,记录问题产生虚拟机、线程堆栈相关信息。这个 trace 文件中包含了线程信息和锁的信息,借助这个 trace 文件可以分析卡死的原因。

由此,如果利用这个系统原有的机制,自己在线程卡死时候触发traces文件的形成进行上报,便可以把线程卡死的关键进行进行上报。本监控方案便是利用系统机制进行卡死信息的抓取

  1. 当监控线程发现被监控线程卡死时,主动向系统发送 SIGQUIT 信号。

  2. 等待 /data/anr/traces.txt 文件生成。

  3. 文件生成以后进行上报。

分析:

1
2
3
4
5
6
7
8
9
10
// 线程名称; 优先级; 线程id; 线程状态
"main" prio=5 tid=1 Suspended
// 线程组; 线程suspend计数; 线程debug suspend计数;
| group="main" sCount=1 dsCount=0 obj=0x74746000 self=0xf4827400
// 线程native id; 进程优先级; 调度者优先级;
| sysTid=28661 nice=-4 cgrp=default sched=0/0 handle=0xf72cbbec
// native线程状态; 调度者状态; 用户时间utime; 系统时间stime; 调度的CPU
| state=D schedstat=( 3137222937 94427228 5819 ) utm=218 stm=95 core=2 HZ=100
// stack相关信息
| stack=0xff717000-0xff719000 stackSize=8MB

其中 utm 代表 utimeHZ 代表 CPU 的时钟频率,将 utime 转换为毫秒的公式是 time * 1000/HZ。例子中 utm=218,也就是 218*1000/100=2180 毫秒。

4.5 Hook 实现

SIGQUIT 信号量获取 ANR 日志,从而拿到所有线程的各种信息,这套方案看起来很美好。但事实上,它存在这几个问题:

  1. 可行性。 高版本系统已经没有权限读取 /data/anr/traces.txt 文件。需要 root 手机
  2. 性能。获取所有线程堆栈以及各种信息非常耗时,对于卡顿场景不一定合适,它可能会进一步加剧用户的卡顿。

Android trace文件抓取原理 Android 平台 Native 代码的崩溃捕获机制及实现

通过Android trace文件分析死锁ANR

能力有限,还在研究中。。

思路: hook libart.so 。通过hook ThreadListThread 的函数,获得跟 ANR 一样的堆栈。为了稳定性,我们会在 fork 子进程执行。

优点:信息很全,基本跟 ANR 的日志一样,有 native 线程状态,锁信息等等。
缺点:黑科技的兼容性问题,失败时可以用 Thread.getAllStackTraces() 兜底

获取Java堆栈的方法还可以用在卡顿时,因为使用fork进程,所以可以做到完全不卡主进程。

Breakpad 使用了 fork 子进程甚至孙进程的方式去收集崩溃现场,即便出现二次崩溃,也只是这部分信息丢失。

5 Demo 相关 Demo 学习

5.1 抓取 CPU 数据

Chapter05

模仿 ProcessCpuTracker.java 拿到一段时间内各个线程的耗时占比

示例的日志数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
usage: CPU usage 5000ms(from 23:23:33.000 to 23:23:38.000):
System TOTAL: 2.1% user + 16% kernel + 9.2% iowait + 0.2% irq + 0.1% softirq + 72% idle
CPU Core: 8
Load Average: 8.74 / 7.74 / 7.36

Process:com.sample.app
50% 23468/com.sample.app(S): 11% user + 38% kernel faults:4965

Threads:
43% 23493/singleThread(R): 6.5% user + 36% kernel faults:3094
3.2% 23485/RenderThread(S): 2.1% user + 1% kernel faults:329
0.3% 23468/.sample.app(S): 0.3% user + 0% kernel faults:6
0.3% 23479/HeapTaskDaemon(S): 0.3% user + 0% kernel faults:982
...
  1. System Total 部分 user 占用不多,CPU idle 很高,消耗多在 kerneliowait
  2. CPU 是 8 核的,Load Average 大约也是 8,表示 CPU 并不处于高负载情况。
  3. Process 里展示了这段时间内 sample appCPU 使用情况:user 低,kernel 高,并且有 4965page faults
    1. page faluts 分为三种:minor page faultmajor page faultinvalid page fault
  4. Threads 里展示了每个线程的 usage 情况,当前只有 singleThread 处于 R 状态,并且当前线程产生了 3096 次 page faults,其他的线程包括主线程(Sample 日志里可见的)都是处于 S状态。
    1. R:代表线程处于 Running 或者 Runnable 状态。Running 状态说明线程当前被某个 Core 执行,Runnable 状态说明线程当前正在处于等待队列中等待某个 Core 空闲下来去执行。
    2. STASK_INTERRUPTIBLE(可中断) 发生这种状态是线程主动让出了 CPU,如果线程调用了 sleep 或者其他情况导致了自愿式的上下文切换就会处于 S 状态。

Demo 中执行 Test 抓取数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
CPU usage from 5137ms to 81ms ago (2020-05-13 21:57:32.764 to 2020-05-13 21:57:37.819):
16% 15921/com.sample.processtracker(R): 6.3% user + 9.6% kernel / faults: 3332 minor
thread stats:
15% 16145/SingleThread(S): 1.9% user + 13% kernel / faults: 3014 minor
13% 17088/SingleThread(S): 1.3% user + 12% kernel / faults: 3016 minor
1.3% 15921/.processtracker(R): 0.9% user + 0.3% kernel / faults: 40 minor
0.5% 16002/RenderThread(S): 0.1% user + 0.3% kernel / faults: 37 minor
0.1% 15942/Jit thread pool(S): 0.1% user + 0% kernel / faults: 222 minor
0% 15949/HeapTaskDaemon(S): 0% user + 0% kernel
...
0% TOTAL(): 0% user + 0% kernel
Load: 0.0 / 0.0 / 0.0

这里不太清楚为什么后面的数据都是 0 ,最后应该输出 iowait 才对。

极客时间原文例子

1
2
3
4
5
6
7
8
9
10
CPU usage from 5187ms to 121ms ago (2018-12-28 08:28:27.186 to 2018-12-28 08:28:32.252):
40% 24155/com.sample.processtracker(R): 14% user + 26% kernel / faults: 5286 minor
thread stats:
35% 24184/SingleThread(S): 11% user + 24% kernel / faults: 3055 minor
2.1% 24174/RenderThread(S): 1.3% user + 0.7% kernel / faults: 384 minor
1.5% 24155/.processtracker(R): 1.1% user + 0.3% kernel / faults: 95 minor
0.1% 24166/HeapTaskDaemon(S): 0.1% user + 0% kernel / faults: 1070 minor

100% TOTAL(): 3.8% user + 7.8% kernel + 11% iowait + 0.1% irq + 0% softirq + 76% idle
Load: 6.31 / 6.52 / 6.66

如果有大佬知道原因,还请告知。

如果产生大量的 faults 其实是不太正常的,或者 iowait 过高就需要关注是否有很密集的 I/O 操作。

《page fault 带来的性能问题》

《iowait 的形成原因和内核分析》

5.2 PLTHook 监控 Thread 的创建

Chapter06-plus 这个 DemoAndroid线程的创建过程 结合,了解 PLTHook 的使用,以及 Thread 状态知识、如何创建的。

5.3 Loop 监控卡顿

Android UI 线程中有个 Looper,在其 loop 方法中会不断取出 Message,调用其绑定的 HandlerUI 线程进行执行。

部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void loop() {
final Looper me = myLooper();
final MessageQueue queue = me.mQueue;
for (;;) {
Message msg = queue.next(); // might block
// This must be in a local variable, in case a UI event sets the logger
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
try {
msg.target.dispatchMessage(msg);
} finally {
}
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
msg.recycleUnchecked();
}
}

可以看到在执行此代码前后,如果设置了 logging,会分别打印出 >>>>> Dispatching to<<<<< Finished to 这样的log

我们可以通过计算两次 log 之间的时间差值

1
2
3
4
5
6
7
8
9
10
11
12
13
Looper.getMainLooper().setMessageLogging(new Printer() {
private static final String START = ">>>>> Dispatching";
private static final String END = "<<<<< Finished";
@Override
public void println(String x) {
if (x.startsWith(START)) {
LogMonitor.getInstance().startMonitor();
}
if (x.startsWith(END)) {
LogMonitor.getInstance().removeMonitor();
}
}
});

假设我们的阈值是 1000ms,当我在匹配到 >>>>> Dispatching 时,我会在 1000ms 毫秒后执行一个任务(打印出 UI 线程的堆栈信息,会在非 UI 线程中进行);

正常情况下,肯定是低于 1000ms 执行完成的,所以当我匹配到 <<<<< Finished ,会移除该任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class LogMonitor {
private static LogMonitor sInstance = new LogMonitor();
private Handler mIoHandler;
private static final long TIME_BLOCK = 1000L;

public LogMonitor() {
HandlerThread mLogThread = new HandlerThread("log");
mLogThread.start();
mIoHandler = new Handler(mLogThread.getLooper());
}

private static Runnable mLogRunnable = new Runnable() {
@Override
public void run() {
StringBuilder sb = new StringBuilder();
StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();
for (StackTraceElement s : stackTrace) {
sb.append(s.toString() + "\n");
}
Log.e("TAG",sb.toString());
}
};

public static LogMonitor getInstance(){
return sInstance;
}

public boolean isMonitor(){
try {
Class<?> handlerClass = Class.forName("android.os.Handler");
java.lang.reflect.Method method = handlerClass.getMethod("hasCallbacks", Runnable.class);
Boolean ret = (Boolean) method.invoke(mIoHandler, mLogRunnable);
return ret;
} catch (Exception e) {
}
return false;
}

public void startMonitor(){
mIoHandler.postDelayed(mLogRunnable,TIME_BLOCK);
}

public void removeMonitor(){
mIoHandler.removeCallbacks(mLogRunnable);
}
}

我们利用了 HandlerThread 这个类,同样利用了 Looper 机制,只不过在非UI线程中,如果执行耗时达到我们设置的阈值,则会执行 mLogRunnable ,打印出 UI 线程当前的堆栈信息;如果你阈值时间之内完成,则会 remove 掉该 runnable

BlockCanary 16 年原理分析

代码修改自于 Android UI性能优化 检测应用中的UI卡顿 修改了 hasCallbacks 调用方式

这个方法的缺点: 大量字符串拼接导致性能损耗严重,快速滑动时会降低帧数。

  • 消息队列

可以通过一个监控线程,每隔 1 秒向主线程消息队列的头部插入一条空消息。假设 1 秒后这个消息并没有被主线程消费掉,说明阻塞消息运行的时间在 0~1 秒之间。换句话说,如果我们需要监控 3 秒卡顿,那在第 4 次轮询中头部消息依然没有被消费的话,就可以确定主线程出现了一次 3 秒以上的卡顿。

图片来源于 Android开发高手课

这个方案也存在一定的误差,那就是发送空消息的间隔时间。但这个间隔时间也不能太小,因为监控线程和主线程处理空消息都会带来一些性能损耗,但基本影响不大。

这个方法的缺点: 基于消息队列的卡顿监控并不准确,正在运行的函数有可能并不是真正耗时的函数。

  • 插桩

参考 微信 开源库 matrix

避免方法数暴增。在函数的入口和出口应该插入相同的函数,在编译时提前给代码中每个方法分配一个独立的 ID 作为参数。

过滤简单的函数。过滤一些类似直接 returni++ 这样的简单函数,并且支持黑名单配置。对一些调用非常频繁的函数,需要添加到黑名单中来降低整个方案对性能的损耗。

基于性能的考虑,线上只会监控主线程的耗时。最终安装包体积只增大 1~2%,平均帧率下降也在 2 帧以内。

插桩方案看起来美好,它也有自己的短板,那就是只能监控应用内自身的函数耗时,无法监控系统的函数调用,整个堆栈看起来好像 “缺失了” 一部分。

感谢

极客时间 Android开发高手课

《Linux环境下进程的CPU占用率》

《Linux 文档》

《Java线程Dump分析》

《手Q Android线程死锁监控与自动化分析实践》

Android UI性能优化 检测应用中的UI卡顿

以及上文中的链接