性能优化之卡顿优化

对用户来说,内存占用高、耗费电量、耗费流量可能不容易被发现,但是对于卡顿特别敏感,尤其在某些时候出现高频掉帧,俗称冻帧。对于开发者来说,卡顿问题非常难以排查定位,其产生的原因错综复杂,跟用户当前手机的 CPU、内存、磁盘 I/O 等环境都可能有关系。所以,到底如何该定义卡顿?在我们平时开发又有哪些工具可以帮助开发更好的发现和排查问题?在线上又该监控卡顿情况呢?下面我们来一一解决这些困惑。

一、基础知识

1.1 CPU 时间

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

评价一个 CPU 的性能,需要看主频、核心数、缓存等参数。

获取设备的 CPU 信息

1
2
3
4
5
// 获取 CPU 核心数
cat /sys/devices/system/cpu/possible

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

当出现卡顿问题的时候,怎么去区分究竟是我们代码的问题,还是系统的问题?用户时间和系统时间可以给我们哪些线索?这里需要两个非常重要的指标帮助我们做判断。

  • CPU 的使用率

可以通过 /proc/stat 得到整个系统的 CPU 使用情况,通过 /proc/[pid]/stat 可以得到某个进程的 CPU 使用情况。

参考链接:

1
2
3
4
5
6
7
8
9
10
11
命令 

* top 命令
帮助查看哪个进程是 CPU 的消耗大户

* vmstat 命令
可以实时监控操作系统的虚拟内存和 CPU 活动;

* strace 命令

可以跟踪某个进程中所有的系统调用
  • CPU 饱和度

CPU 饱和度反映的是线程排队等待 CPU 的情况,也就是 CPU 的负载情况。CPU 饱和度会跟应用的线程数有关,如果启动线程过多,容易导致系统不断地切换执行的线程,把大量的时间浪费在上下文切换,每次 CPU 上下文切换都需要刷新寄存器和计数器,至少需要十几纳秒的时间。

1
2
3
4
5
6
7
8
9
10
11
12
命令

* vmstat 命令或者 /proc/[pid]/schedstat 文件

查看 CPU 上下文切换次数,需要特别注意 nr_involuntary_switches 被动切换的次数。

// TODO

* uptime 命令
查看 CPU 在1分钟、5分钟和15分钟内的平均负载,例如一个4核的 CPU,如果当前平均负载是 8,这意味着每个 CPU 上有一个线程在运行,还有一个线程在等待。一般平均负载建议控制在 0.7 x 核数 以内。

// TODO

线程优先级-nice 值越低,抢占 CPU 时间片的能力越强。

1.2 绘制原理

1.2.1 UI 渲染背景知识

屏幕适配

px、ppi、dpi、dp、density

参考链接:

CPU 与 GPU

UI 渲染依赖两个核心的硬件:CPU 与 GPU。UI 组件在绘制到屏幕之前,都需要经过 Rasterization(格栅化)操作,而格栅化操作是一个耗时操作。GPU(Graphic processing Unit)也就是图形处理器,它主要用于处理图形运算,可能帮助我们加快格栅化操作。

软件绘制使用的是 Skia 库,它是一款能在低端机上呈现高质量的 2D 跨平台框架,类似 Chrome、Flutter 内部使用的都是 Skia 库。

OpenGL 与 Vulkan

对于硬件绘制,通过调用 OpenGL ES 接口利用 GPU 完成绘制。OpenGL 是一个跨平台的图形 API,它为 2D/3D 图形处理硬件指定了标准软件接口,而 OpenGL ES 是 OpenGL 的子集,专为嵌入式设备设计。

Android 7.0 把 OpenGL ES 升级到最新的 3.2 版本同时,还添加了对 Vulkan的支持,Vulkan 是用于高性能 3D 图形的低开销、跨平台 API。相比 OpenGL ES,Vulkan 在改善功耗、多核优化提升绘图调用上有着非常明显的优势。

参考资料:

1.2.2 Android 渲染的演进

Android 图形系统整体架构

  • 画笔:Skia 或者 OpenGL
  • 画纸:Surface
  • 画板:Graphic Buffer
  • 显示:SurfaceFlinger

参考资料:

硬件加速绘制
Android 3.0 软件绘制

在 Android 3.0 之前,或者没有启动硬件加速时,系统都会使用软件方式来渲染 UI。

整个流程如上图所示:

  • Surface。每个 View 都由某一个窗口管理,而每一个窗口都关联有一个 Surface。
  • Canvas。通过 Surface 的 lock 函数获得一个 Canvas,Canvas 可以简单理解为 Skia 底层接口的封装。
  • Graphic Buffer。SurfaceFlinger 会帮我们托管一个 BufferQueue,我们从 BufferQueue 中拿到 Graphic Buffer,然后通过 Canvas 以及 Skia 将绘制内容栅格化到上面。
  • SurfaceFlinger。通过 Swap Buffer 把 Front Graphic Buffer 的内容交给 SurfaceFinger,最后硬件合成器 Hardware Composer 合成并输出到显示屏。
Android 4.0 开启硬件加速
  • 从 Androd 3.0 开始,Android 开始支持硬件加速,到 Android 4.0 时,,默认开启硬件加速。

硬件加速核心是通过 GPU 完成 Graphic Buffer 的内容绘制,此外硬件绘制还引入了一个 DisplayList 的概念,每个 View 内部都有一个 DisplayView,当某个 View 需要重绘时,将它标记为 Dirty,不需要像软件绘制向上递归,这样可以大大减少绘图的操作数量,提升渲染效率。

Project Butter 黄油计划

Android 4.1:Project Butter 主要包含两个组成部分,一个是 VSYNC,一个是 Triple Buffering。

VSYNC 信号

三级缓冲机制 Triple Buffering

Jank渲染超时

Android 5.0: RenderThread 异步渲染

数据测量
  • 绘制过度工具

在 Android 4.2,系统增加了检测绘制过度工具。

参考资料:检查 GPU 渲染进度和绘制过度

未来

在 Android 6.0 的时候,Android 在 gxinfo 添加了更详细的信息;在 Android 7.0 又对 HWUI 进行了一些重构,而且支持了 Vulkan;在 Android P 支持了 Vulkun 1.1。相信在未来不久的 Android Q,更好地支持 Vulkan 将是一个必然的方向。

总的来说,UI 渲染的优化必然会朝着两个方向。一个是进一步压榨硬件的性能,让 UI 可以更加流畅。一个是改进或者增加更多的分析工具,帮助我们更容易地发现以及定位问题。

总结

虽然硬件加速绘制极大提高了 Android 系统显示和刷新的速度,但它也存在一些问题,一方面是内存消耗,另一方面是兼容性问题,部分绘制函数不支持,更可怕的是硬件加速绘制流程本身存在 Bug。

1.2.3 如何优化 UI 渲染?

adb 命令
  • gfxinfo,可以输出包含各阶段发生的动画以及帧相关的性能信息
1
2
3
4
5
// 渲染相关的内存和 View hierarchy 信息
adb shell dumpsys gfxinfo 包名

// 拿到最近 120 帧每个绘制阶段的耗时信息
adb shell dumpsys gfxinfo 包名 framestats
  • SurfaceFlinger
1
2
// 查看 Graphic Buffer 占用的内存
adb shell dumpsys SurfaceFlinger
UI 优化常用手段

尽量使用硬件加速

如果使用了不支持的 API,系统就需要通过 CPU 软件模拟绘制,这也是渐变、磨砂、圆角等效果渲染性能比较低的原因。

SVG 是个典型的例子,SVG 有很多指令硬件加速都不支持,我们可以用一个取巧的方法,提前将这些 SVG 转换为 Bitmap 缓存起来,这样系统就可以更好地使用硬件加速绘制。同理,对于圆角、渐变等场景,我们也可以改为 Bitmap 实现。

问题:如何提前生成 Bitmap,以及 Bitmap 的内存需要如何管理,可以参考市面上常用图片库的实现!

Create View 优化
  • 使用代码创建

使用一些开源的 XML 转换为 Java 代码工具,例如 X2C。建议在一些修改不频繁的场景下使用这种方式。

  • 异步创建

在线程提前创建 View,实现 UI 的预加载,在使用线程创建 UI 的时候,先把线程 Looper 的 MessageQueue 替换为 UI 线程 Looper 的 Queue,在创建完 View 后需要把线程 Looper 恢复成原来的。

  • View 重用

View 会随着 Activity 的销毁而销毁,ListView、RecyclerView 通过 view 的缓存与重用大大提升渲染性能,因此我们可以参考它们的思想,实现一套可以在不同 Activity 或 Fragment 使用的 View 缓存机制。注意:需要保证所有进入缓存池的 View 都是干净的,不会保留之前的状态。

measure/layout 优化
  • 减少 UI 布局层次

尽量扁平化,使用 等优化。

  • 优化 layout 开销

尽量不用 RelativeLayout 或者基于 weighted LinearLayout,它们 layout 的开销非常巨大,推荐使用 ConstraintLayout 替代 RL 或者 weighted LL。

  • 背景优化

尽量不要重复设置主题背景(Theme),theme 默认会是一个纯色背景,如果我们自定义了界面的背景,那么主题的背景对我们来说是无用的,由于主题背景设置在 DecorView 中,所以这里会带来重复绘制,会带来绘制性能损耗。

UI 进阶优化
Litho: 异步布局
  • 异步布局

Android 主线程,measure -> layout -> draw

Litho异步布局

  • 界面扁平化

  • 优化 RecyclerView

Litho 优化了 RV 中 UI 组件的缓存和回收方法,原生 RV 或者 LV 都是按照 viewType 来进行缓存和回收,但如果一个 RV/LV 中出现了 viewType 过多,会是缓存形同虚设,Litho 是按照 text、image 和 video 独立回收,可以提高缓存命中率、降低内存使用率、提高滚动帧率。

缺点:Litho 实现了 measure/layout 异步化,使用了类似 react 单向数据流设计,一定程度上加大了 UI 开发的复杂度,并且 Litho 的 UI 代码是使用 java/kotlin 进行编写,无法在 AS 中预览。

RenderThread 与 RenderScript

在 Android 5.0,系统增加了 RenderThread,对于 ViewPropertyAnimator 和 CircularReveal 动画,我们可以使用 RenderThread:异步渲染动画,当主线程阻塞的时候,普通动画会出现明显的丢帧卡顿,而使用 RenderThread 渲染的动画即使阻塞了主线程仍不受影响。

现在越来越多的应用会使用一些高级图片或者视频编辑功能,例如图片的高斯模糊、放大、锐化等。拿日常我们使用最多的“扫一扫”这个场景来看,这里涉及大量的图片变换操作,例如缩放、裁剪、二值化以及降噪等。

图片的变换涉及大量的计算任务,这个时候使用 GPU 是更好的选择,那如何进一步压榨系统 GPU 的性能呢?

我们可以通过RenderScript,它是 Android 操作系统上的一套 API。它基于异构计算思想,专门用于密集型计算。

Flutter: 自己的布局 + 渲染引擎

总结
  1. 在系统的框架下优化。布局优化、使用代码创建、View 缓存等都思路,减少甚至省下渲染流水线里某个阶段的耗时。
  2. 利用系统的新特性。使用硬件加速、RenderThread、RenderScript 等,通过系统一些新的特性,最大限度压榨出性能。
  3. 突破系统的限制,Android 碎片化很严重,很多好的特性可能低版本系统并不支持,一些特定的场景下它无法实现最优解。这时候,就要突破系统的条条框框,例如 Facebook-Litho 突破了布局渲染过程,Flutter 更近一步,把渲染都接管过来。

在 UI 优化时,第一阶段的优化在系统的束缚下也可以达到非常不错的效果。不过越到后面越容易出现瓶颈,这时就需要进一步往底层走,可以对整个架构有更大的掌控力,需要造自己的「轮子」!

二、Android 卡顿排查工具

2.1 Traceview

利用 Android Runtime 函数调用的 event 事件,将函数运行的耗时和调用关系写入 trace 文件中。可监控 Android framework、java、应用程序代码。

在 Android 5.0 之后,新增了 startMethodTracingSampling 方法,可以使用基于样本的方式进行分析,以减少对运行时的性能影响。新增了 sample 类型后,就需要我们在开销和信息丰富度之间做好权衡。

TraceView 的生成

  1. 代码调用
1
2
3
4
5
6
7
8
private void onCreate(){
// 生成traceView的起点,保存traceView的名称(路径:/mnt/sdcard/fileName)
Debug.startMethodTracing("fileName");
}

private void onDestroy(){
Debug.stopMethodTracing();
}



缺点:工具本身带来的性能开销过大,有时候无法真实反映情况,比如一个函数本身的耗时是 1 秒,开启 TraceView 后可能变成 5 秒,而且这些函数的耗时变化并不是成比例放大。

  1. DDMS

Android Device Monitor(DDMS),选择进程 attached,点击trace 开始,操作app, trace 结束,分析结果。

ps:DDMS 可通过命令 monitor 直接打开,目录在 /sdk/tools 下。

参考链接:

2.2 systrace

Android 4.1 新增的性能分析工具,经常使用 systrace 来跟踪系统的 I/O 操作、CPU 负载、Surface 渲染、GC 等事件。

系统预留接口来监控应用程序的调用耗时,可以在可疑引起 jank 代码的地方,添加如下代码,这两个是成对出现的。

1
2
Trace.beginSection("tag");
Trace.endSection();

参考资料:

怎样在 systrace 上面自动增加应用程序的耗时分析呢?

编译时给每个函数插桩的方式来实现,在重要函数的入口和出口分别增加 Trace 代码,实现在 systrace 基础上增加应用程序耗时的监控。

2.3 AS-Profiler

Android Studio 自带 Profiler 工具,可以检测 CPU、memory等,点击 View -> Tool Window -> Profiler 打开工具使用。

参考链接:

2.4 Simpleperf

Android 5.0 新增了 Simpleperf 性能分析工具,分析 Native 函数的调用,它利用 CPU 的性能监控单元 (PMU)提供的硬件 perf 事件,使用 Simpleperf 可以看到所有的 Native 代码的耗时,有时候一些 Android 系统库的调用对分析问题有比较大的帮助,例如加载 dex、verify class 的耗时等。

Simpleperf 同时封装了 systrace 的监控功能,现在 Simpleperf 比较友好地支持 Java 代码的性能分析。

其它 Nanoscope

Uber 开源的 Nanoscope,直接修改 Android 虚拟机源码,在 ArtMethod 执行入口和执行结束位置新增埋点代码,将所有的信息先写到内存,等到 trace 结束后才统一生成结果文件。需要刷入 ROM。

总结

汇总一下,如果需要分析 Native 代码的耗时,可以选择 Simpleperf;如果想分析系统调用,可以选择 systrace;如果想分析整个程序执行流程的耗时,可以选择 TraceView 或者插桩版本的 systrace。

三、监控应用卡顿实践

3.1 监控主线程原理

  • 消息队列

依赖主线程 Looper,监控每次 dispatchMessage 的执行耗时。(BlockCanary)

Looper#loop 代码片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void loop() {
...
for (;;) {
...
// This must be in a local variable, in case a UI event sets the logger
Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
msg.target.dispatchMessage(msg);
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
...
}
}

主线程所有执行的任务都在 dispatchMessage 方法中派发执行完成,我们通过 setMessageLogging 的方式给主线程的 Looper 设置一个 Printer ,因为 dispatchMessage 执行前后都会打印对应信息,在执行前利用另外一条线程,通过 Thread#getStackTrace 接口,以轮询的方式获取主线程执行堆栈信息并记录起来,同时统计每次 dispatchMessage 方法执行耗时,当超出阈值时,将该次获取的堆栈进行分析上报,从而来捕捉卡顿信息,否则丢弃此次记录的堆栈信息。

  • Vsync 时间差

依赖 Choreographer 模块,监控相邻两次 Vsync 事件通知的时间差。(ArgusAPM、LogMonitor)。

利用系统 Choreographer 模块,向该模块注册一个 FrameCallback 监听对象,同时通过另外一条线程循环记录主线程堆栈信息,并在每次 Vsync 事件 doFrame 通知回来时,循环注册该监听对象,间接统计两次 Vsync 事件的时间间隔,当超出阈值时,取出记录的堆栈进行分析上报。

1
2
3
4
5
6
7
8
9
10
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
if(frameTimeNanos - mLastFrameNanos > 100) {
...
}
mLastFrameNanos = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
}
});

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

所以我们希望寻求一种可以在线上准确地捕捉卡顿堆栈,又能计算出各个函数执行耗时的方案。 而要计算函数的执行耗时,最关键的点在于如何对执行过程中的函数进行打点监控。

3.2 插桩

  • 应用启动时,默认打开 Trace 功能(Debug.startMethodTracing),应用内所有函数在执行前后将会经过该函数(dalvik 上 dvmMethodTraceAdd 函数 或 art 上 Trace::LogMethodTraceEvent 函数), 通过hack手段代理该函数,在每个执行方法前后进行打点记录。

  • 修改字节码的方式,在编译期修改所有 class 文件中的函数字节码,对所有函数前后进行打点插桩。

第一种方案,最大的好处是能统计到包括系统函数在内的所有函数出入口,对代码或字节码不用做任何修改,所以对apk包的大小没有影响,但由于方式比较hack,在兼容性和安全性上存在一定的风险。

第二种方案,利用 Java 字节码修改工具(如 BCEL、ASM、Javassis等),在编译期间收集所有生成的 class 文件,扫描文件内的方法指令进行统一的打点插桩,同样也可以高效的记录函数执行过程中的信息,相比第一种方案,除了无法统计系统内执行的函数,其它应用内实现的函数都可以覆盖到。而往往造成卡顿的函数并不是系统内执行的函数,一般都是我们应用开发实现的函数,所以这里无法统计系统内执行的函数对卡顿的定位影响不大。此方案无需 hook 任何函数,所以在兼容性方面会比第一个方案更可靠。

在这考虑上,我们最终选择了修改字节码的方案,来实现 Matrix-TraceCannary 模块,解决其它方案中卡顿堆栈无耗时信息的主要问题,来帮助开发者发现及定位卡顿问题。

参考链接:

Matrix Android TraceCanary

3.3 Profilo

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

  • 集成了 atrace 功能,ftrace(Linux 的ftrace 调试工具,相当于在系统各个关键位置都添加了一些性能探针,也就是在代码里添加了一些性能监控的埋点。Android 在 ftrace 基础上封装了 atrace,并增加了更多特有的探针,例如 Graphics、Activity Manager、Dalvik VM、System Server 等。),Profilo 通过 PLT Hook 拦截了写入操作,选择部分关心事件,例如四大组件生命周期、锁等待时间、类校验、GC 时间等。
  • 快速获取 Java 堆栈,拿到当前执行的 Thread,通过 Thread 对象获取当前线程的 ManagedStack,ManagedStack 是一个单链表,保存了当前的 ShadowFrame 或 QuickFrame 栈指针,它依次遍历 ManagedStack 链表,然后遍历内部的 ShadowFrame 或 QuickFrame 还原一个可读的调用栈,从而 unwind 出当前的 Java 堆栈。通过这种方式可以实现一边继续跑步,还可以帮它做检查,而且耗时基本忽略不计。

3.4 线程监控

Java 线程管理是很多应用头疼的事情,应用启动就已经创建了十几上百个线程,而且大部分线程都没有经过线程池管理,都在自由的狂奔着。

另一方面某些线程优先级或者活跃度比较高,占用了过多的 CPU,这会降低主线程 UI 响应能力,需要特别针对这些线程做重点的优化。

  • 线程数量
    监控线程数量的多少以及创建线程的方式,可通过 got hook 线程的 nativeCreate() 函数,用于进行线程收敛,也就是减少线程数量。

  • 线程时间

监控线程用户时间 utime、系统时间 stime 和优先级,看哪些线程 utime + stime 比较多,占用了过多的 CPU,有可能有一些线程因为生命周期很短导致很难发现,这里我们需要结合线程创建监控。

总结

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

四、向日葵工作台页面帧率情况

演示工作台检测 trace.html 文件!

五、卡顿优化计划

5.1 常见卡顿场景

  • 布局嵌套层次太深,可以使用 merge、viewStub、include 来优化
  • onDraw() 里面循环创建了大量临时变量,频繁触发 GC
  • 主线程等待优先级子线程问题(锁同步问题)
  • 主线程执行耗时操作,阻塞主线程执行(同步读写文件,DB 操作)

5.2 卡顿排查思路

  • 显示页面实时 FPS 帧率,帮助查看流畅度(UI 优化,参见上述方法)
  • 慢函数堆栈显示,当发生卡顿时,可以显示具体哪个函数哪行代码造成的卡顿
  • 线程监控,监控全局线程创建

六、参考

都看到这了,不打赏下?您的支持将鼓励我继续创作!:)