Skip to content

Latest commit

 

History

History
169 lines (102 loc) · 11.3 KB

卡顿优化.md

File metadata and controls

169 lines (102 loc) · 11.3 KB

卡顿优化

目录

  1. 基础知识
    1. CPU 性能
    2. 卡顿问题分析指标
  2. 卡顿排查工具
  3. 卡顿监控
    1. 消息队列
    2. 字节码插桩
    3. 帧率 FPS
    4. 生命周期监控
    5. 线程监控

基础知识

造成卡顿的原因可能有千百种,不过最终都会反映到 CPU 时间上。我们可以把 CPU 时间分为两种:用户时间和系统时间。用户时间就是执行用户态应用程序代码所消耗的时间;系统时间就是执行内核态系统调用所消耗的时间,包括 I/O、锁、中断以及其他系统调用的时间。

CPU 性能

评估一个 CPU 的性能,需要看主频、核心数、缓存等参数,具体表现出来的是计算能力和指令执行能力,也就是每秒执行的浮点数计算数和每秒执行的指令数。

我们可以通过以下方法获得设备的 CPU 信息:

// 获取 CPU 核心数
cat /sys/devices/system/cpu/possible  

// 获取某个 CPU 的频率
cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq

不过在 Android 8 之后,是没权限访问这些文件了。

卡顿问题分析指标

出现卡顿问题后,首先我们应该查看 CPU 的使用率。怎么查呢?我们可以通过 /proc/stat 得到整个系统的 CPU 使用情况,通过 /proc/[pid]/stat 可以得到某个进程的 CPU 使用情况。

其中比较重要的字段有:

proc/self/stat:
	utime: 用户时间,反应用户代码执行的耗时
	stime: 系统时间,反应系统调用执行的耗时
	majorFaults: 需要硬盘拷贝的缺页次数
	minorFaults: 无需硬盘拷贝的缺页次数

如果 CPU 使用率长期大于 60%,表示系统处于繁忙状态,就需要进一步分析用户时间和系统时间的比例。对于普通应用程序,系统时间不会长期高于 30%,如果超过这个值,我们就应该进一步检查是 I/O 过多,还是其他的系统调用问题。

除了 CPU 的使用率,我们还需要查看 CPU 饱和度。CPU 饱和度反映的是线程排队等待 CPU 的情况,也就是 CPU 的负载情况。

CPU 饱和度首先会跟应用的线程数有关,如果启动的线程过多,容易导致系统不断的切换执行的线程,把大量的时间浪费在上下文切换,我们知道每一次 CPU 上下文切换都需要刷新寄存器和计数器,至少需要几十纳秒的时间。

我们可以通过 /proc/[pid]/schedstat 文件来查看 CPU 上下文切换次数,特别需要注意被动切换的次数。

proc/self/sched:
  nr_voluntary_switches:     
  主动上下文切换次数,因为线程无法获取所需资源导致上下文切换,最普遍的是 IO。    
  nr_involuntary_switches:   
  被动上下文切换次数,线程被系统强制调度导致上下文切换,例如大量线程在抢占 CPU。
  se.statistics.iowait_count:IO 等待的次数
  se.statistics.iowait_sum:  IO 等待的时间

另外一个会影响 CPU 饱和度的是线程优先级,线程优先级会影响 Android 系统的调度策略,它主要由 nice 和 cgroup 类型共同决定。nice 值越低,抢占 CPU 时间片的能力越强。当 CPU 空闲时,线程的优先级对执行效率的影响并不会特别明显,但在 CPU 繁忙的时候,线程调度会对执行效率有非常大的影响。

关于线程优先级,你需要注意是否存在高优先级的线程空等低优先级线程,例如主线程等待某个后台线程的锁。

卡顿排查工具

Traceview、systrace 以及 AS 自带的 Profiler 工具。

Traceview 可以用来查看整个过程有哪些函数调用,但是工具本身带来的性能开销过大,有时无法反应真实的情况。

systrace 可以用来跟踪系统的 I/O 操作、CPU 负载、Surface 渲染、GC 等事件。systrace 工具只能监控特定系统调用的耗时情况,而且性能开销非常低,但是它不支持应用程序代码的耗时分析,所以在使用时有一些局限性。

AS 3.2 自带的 Profiler 中直接集成了几种性能分析工具,其中:

  • Sample Java Methods 的功能类似于 TraceView 的 sample 类型
  • Trace Java Methods 的功能类似于 Traceview 的 instrument 类型
  • Trace System Calls 的功能类似于 systrace
  • SampleNative(API Level 26+)的功能类似于 Simpleperf

卡顿监控

消息队列

基于消息队列的卡顿监控方案,主要思想是,监控主线程执行耗时,当超过阈值时,dump 出当前主线程的执行堆栈,通过堆栈分析找到卡顿原因。具体做法是,通过替换 Looper 的 Printer 实现,每一个消息的执行,都会在其前后打印日志,我们只需要设置这个 Printer,通过两次调用 println 的时间间隔,就是一个消息执行的耗时,这也是 BlockCanary 的核心实现。而 360 的 ArgusAPM 的做法相对优雅些,它是在消息分发的开始,去 postDelay 一个 Runnable,在消息分发的结束去移除这个 Runnable,如果在指定的 delay 时间内没有移除,就说明是发生卡顿了,这个 Runnable 所做的事情就是把当前线程的堆栈打印出来,这种做法其实就是 View 中判断长按事件的方式一样。

但是这两种方式都有相同的弊端,就是 println 参数有大量的字符串拼接,可能会导致性能损耗严重。

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

这两种方案,可以较方便的捕捉到卡顿的堆栈,但其最大的不足在于,无法获取到各个函数的执行耗时,对于稍微复杂一点的堆栈,很难找出可能耗时的函数,也就很难找到卡顿的原因。另外,通过其他线程循环获取主线程的堆栈,如果稍微处理不及时,很容易导致获取的堆栈有所偏移,不够准确,加上没有耗时信息,卡顿也就不好定位。还有就是,获取主线程堆栈的代价也是巨大的,它需要暂停主线程的运行。

字节码插桩

Matrix 的做法是,在编译期间收集所有生成的 class 文件,扫描文件内的方法指令进行统一的打点插桩。为了减少插桩量以及性能损耗,通过遍历 class 方法指令集,判断扫描的函数是否只含有 PUT/READ/FIELD 等简单的指令,来过滤一些默认或匿名构造函数,以及 get/set 等简单不耗时的函数。为了方便以及高效记录函数执行过程,会为每个插桩的函数分配一个独立的 ID,在插桩过程中,记录插桩的函数签名以及分配的 ID,在插桩完成后输出一份 mapping,作为数据上报后的解析支持。

通过向 Choreographer 注册监听,在每一帧 doFrame 回调时判断距离上一帧的时间差是否超过阈值,如果超过了阈值即判定发生了卡顿,这时候就会把两帧之间的所有函数执行信息进行上报分析。同时,在每一帧 doFrame 到来时,重置一个计时器,如果 5s 内没有 cancel,则认为是发生了 ANR。

帧率 FPS

FPS 可以衡量一个界面的流畅性,但往往不能很直观的衡量卡顿的发生。一个稳定在 40、50 FPS 的页面,我们不会认为是卡顿的,但一旦 FPS 很不稳定,人眼往往很容易感知到,因此我们可以通过掉帧程度来衡量卡顿。

业界都是使用 Choreographer 来监听应用的帧率,跟卡顿不同的是,需要排除掉页面没有操作的情况,我们应该只在界面存在绘制的时候做统计。那么如何监听界面是否存在绘制行为呢?可以通过 addOnDrawListener 实现:

getWindow().getDecorView().getViewTreeObserver().addOnDrawListener()

关于跳帧的判断,可以直接抄 Choreographer 的 doFrame 实现,源码内部判断跳帧次数 >=30 即意味着发生了卡顿。

生命周期监控

Activity、Service、Receiver 组件生命周期的耗时和调用次数也是我们重点关注的性能问题。例如 Activity 的 onCreate() 不应该超过 1 秒,不然会影响用户看到页面的时间。Service 和 Receiver 虽然是后台组件,不过它们的生命周期也是占用主线程的,也是我们需要关注的问题。

对于组件生命周期我们应该采用更严格的监控,可以全量上报各个组件各个生命周期的启动时间和启动次数。

一般的做法是,通过编译时插桩来做到组件的生命周期监控。

线程监控

线程间的竞争或者锁可能会导致主线程空等,从而导致卡顿。对于线程监控,需要监控以下两点:

  1. 线程数量

    需要监控线程数量的多少,以及创建线程的方式。例如有没有使用统一的线程池,这块可以通过 hook 线程的 nativeCreate() 函数,主要用于进行线程收敛,减少线程数量。

  2. 线程时间

    监控线程的用户时间 utime、系统时间 stime 和优先级。主要是看哪些线程 utime+stime 时间比较多,占用了过多的 CPU。

最后

导致卡顿的原因有很多,比如函数非常耗时、I/O 非常慢、线程间的竞争或者锁等,其实很多时候卡顿问题并不难解决,相较解决来说,更困难的是如何快速发现这些卡顿点,以及通过更多的辅助信息找到真正的卡顿原因。

参考

卡顿优化(上):你要掌握的卡顿分析方法

卡顿优化(下):如何监控应用卡顿?

卡顿优化:卡顿现场与卡顿分析

Matrix Android TraceCanary

微信 Android 客户端的卡顿监控方案

理解 Android ANR 的信息收集过程

从源码角度看 CPU 相关日志

深入探索 Android 卡顿优化

https://developer.android.com/studio/profile/android-profiler

https://developer.android.com/topic/performance/vitals/anr

https://github.com/markzhai/AndroidPerformanceMonitor

https://github.com/xanderwang/performance

ProcessCpuTracker