根据Perfetto trace研究Android图像绘制流程

14 阅读9分钟

前言:本文用实际中抓取的Perfetto trace,来研究Android图像绘制流程,不深入代码,让读者对Andoird图像绘制有一个大体方向的了解,前面我们以正常绘制流程讲解,之后分析一个CPU抢占导致的卡顿案例,对读者日后分析Android卡顿问题大有裨益。

一 一切的开始——Choreographer#onVsync

当APP画面变动的时候,除了RenderThread 动画、独立 Surface、游戏等可能绕开,一般最终都会调用到ViewRootImpl.scheduleTraversals,ViewRootImpl 会向 Choreographer 注册帧回调;Choreographer通过 DisplayEventReceiver 向系统显示调度链路请求下一次 VSync,在trace文件中以Choreographer#scheduleVsyncLocked标识,并且其是一次性的,只请求下一次 VSync。如果是动画的话,需要生成连续的多帧,则会在动画中判断动画是否结束,如果没有则继续申请下一次VSync;具体实现随 Android 版本变化。下图是APP线程向SurfaceFlinger请求下一次VSync的binder调用:

image.png

之后

HWC VSync 样本
      ↓
VSyncPredictor 预测下一次 VSync
      ↓
计算唤醒时间:
predictedVsync - workDuration - readyDuration
      ↓
timerfd 定时
      ↓
TimerDispatch 被唤醒
      ├─ 通知 SF 主线程开始合成
      └─ 通知 EventThread 向应用分发 VSync

下图是TimerDispatch 被唤醒,调用VsyncCallback通知应用合成下一帧,并且通知SF开始合成当前帧。

image.png

VsyncCallback最终回调到 Choreographer#onVsync,这也是一帧开始绘制的起点,如下图所示箭头所指就是Choreographer#onVsync:

image.png

请求到App Vsync信号之后,主线会完成以下工作:

image.png

这一点trace文件可以看出来:

image.png

在doFrame的工作末期会执行postAndWait与RenderThrad交接:

postAndWait() 做两件事:

  1. 主线程把 DrawFrameTask 放入 RenderThread 的任务队列。
  2. 主线程暂时等待 RenderThread 完成渲染状态同步。

RenderThread 随后:

→ 同步 RenderNode / DisplayList / 属性 (syncFrameState())

→ 通知主线程交接完成

→ 主线程继续执行

→ 后续步骤

关键点:postAndWait 通常等待的是“UI 数据安全交接完成”,不是等待RenderThread工作完成。如果 RenderThread 忙、主线程会在这里阻塞较长时间。

image.png

完成上述工作后RenderThread将会dequeueBuffer, dequeueBuffer 是向 BufferQueue 申请一块“可写的空闲 GraphicBuffer”,作为当前帧的绘制目标,也就是我们常说的帧缓冲buffer,显示 Surface 通常采用三缓冲配置,但实际分配数和可用槽数量是动态的,如果三个缓冲全部被占用的话,dequeueBuffer只能暂时先等待了。这里有一个例子:

image.png

如上图所示QueuedBuffer 3个Buffer全部没有被释放,dequeueBuffer一直在等待,直到有buffer释放。申请到可以绘制的buffer之后:

  1. RenderThread 获取可写的 GraphicBuffer。
  2. Skia 遍历 DisplayList,把 drawRect、纹理、文字等转换成图形 API 命令。图形驱动把这些命令组织成 GPU Command Buffer。OpenGL 路径通过 glDraw*、glFlush、eglSwapBuffers 等提交;Vulkan 路径通过 vkQueueSubmit 提交给GPU执行。所以如果对显示性能有高要求的地方,可以直接自己申请surface进行绘制。
  3. GPU 开始异步执行命令,并开始将结果写入 GraphicBuffer。
  4. 这一步与上一步同步执行,RenderThread 调用 queueBuffer()通知SurfaceFlinger有新的图像产生,如下图所示,是一个Binder调用,binder:461_1 549就是SurfaceFlinger中的binder线程,SurfaceFlinger收到通知会预测一个唤醒SurfaceFlinger主线程的时间,就是TimerDispatch的vsyncCallback,下图第二张图片所示,它会唤醒surfaceflinger的主线程,主线程去检查GPU是否写好了,写好了就进行下一步工作并显示。如何判断写好了是通过Buffer的Fence判断的,有3种Fence,下面有提到,这里是Acquire Fence,Fence有助于不同的进程和硬件之间同步状态。

image.png

image.png

  • Acquire Fence:生产者 queueBuffer() 时携带。SurfaceFlinger 必须等它变为 signaled,才能读取 Buffer。对 APP 来说,它本质上是 GPU Render Complete Fence。
  • Release Fence:SurfaceFlinger 使用完 Buffer 后返回。RenderThread 下次 dequeueBuffer() 得到该 Buffer 时,要等这个 Fence,才能重新写入。
  • Present Fence:由 HWC/显示系统返回,表示这一帧已经提交到显示设备。它主要用于显示时序统计,不等同于 Buffer 可立即复用。

上面我们说到了SurfaceFlinger合成并显示的过程,之后需要释放Buffer,也是一个Buffer调用:

image.png

可以看到释放之后最上面的QueueBuffer -1了,之后会一直重复这个过程,正常无卡顿的trace看起来应该如下图所示:

image.png

总结来说: 以硬件加速的 View/HWUI 窗口为例:View 更新触发 scheduleTraversals()Choreographer 请求下一次 App VSync。VSync 到达后,主线程进入 Choreographer#doFrame,依次处理输入、动画、Traversal 和 Commit 等回调;Traversal 中按需执行 measure、layout、draw,并记录或更新 DisplayList。

随后,主线程通过 syncAndDrawFrame()DrawFrameTask 交给 RenderThread,并等待帧状态同步完成。RenderThread 使用 Skia 处理 RenderNode/DisplayList,将图形工作提交到 GLES 或 Vulkan 后端。RenderThread通过 BufferQueue 获取 Buffer,并在提交新 Buffer 时携带 Acquire Fence;此时 GPU 可能仍在异步写入该 Buffer。

SurfaceFlinger 在自己的合成调度周期中 latch 可用 Buffer,并与 HWC 协作选择 device composition 或 client/GPU composition,最终调用显示链路完成显示(present)。消费者不再使用旧 Buffer 后产生 Release Fence,使该 Buffer 可以再次被生产者复用;Present Fence 则用于描述帧实际呈现的时间点。

BufferQueue 的标准生命周期是 dequeueBuffer → queueBuffer → acquireBuffer → releaseBuffer;最大 Buffer 数量由生产者和消费者共同决定。

主要流程是:

→ App主线程: Choreographer#onVsync

→ App主线程: Choreographer#doFrame

→ App RenderThrad线程: dequeue获取Buffer

→ App RenderThrad线程: 将绘制命令转化为图形API命令并交给GPU绘制,同步告诉SurfaceFlinger有新帧要显示,预测一个时间唤醒SurfaceFlinger主线程去合成显示

→ GPU 队列/硬件执行阶段: 将绘制命令转化为图形API命令并交给GPU绘制,绘制完成后将Acquire Fence设置为signaled

→ SurfaceFlinger主线程: 被唤醒,检查Buffer Acquire Fence是否设置为了signaled,如果设置了,就去合成显示,显示完之后,释放旧 Buffer;对应槽位和 Release Fence 随后供APP复用。

知道以上生成帧的原理之后,我们以一个实际案例来分析卡顿问题

二 分析卡顿案例

相对于正常的帧动画,卡顿的动画非常明显,没有那么平滑流畅:

image.png

在进程状态中我们可以看到充斥着大量的runnable状态,所以这里其实很容易判断出卡顿是由于CPU资源不足导致的,有其他线程争抢到了CPU。接下来我们可以放大分析,看看每一帧卡顿究竟是怎么被影响的:

案例1:

image.png

这里可以看到这一帧光在APP主线程中就耗费了84ms,造成这一结果的主要原因不是APP主线程自己的问题,主线程花费了大量时间在postAndWait中等待RenderThread的帧同步,而此时RenderThread还一直处于runnable状态,迟迟无法执行。

案例2:

image.png

这一帧同样也花费了大量的时间,这里可以看到,一个是主线程自身处于runnable状态的时间有点久,导致view#draw花费了一些时间,第二是和案例一一样花费了大量时间在postAndWait中等待RenderThread的帧同步,而这一次RenderThread不是处于runnable状态,迟迟无法执行,而是等待在dequeueBuffer上了,为什么会等待在dequeueBuffer上,是因为QueuedBuffer迟迟没有可用的Buffer,可以看到最下面的数值一直处于3,所以接下来我们要探索的是为什么QueuedBuffer为什么一直是3没有被是否出一个可用Buffer,按照上面的帧生成分析,SurfaceFlinger主线程使用完Buffer就会去释放,所以我们要到SurfaceFlinger中去看看使用完之后怎样了:

image.png

这里可以很明显的看到SurfaceFlinger使用完之后确实通知了binder:29975,也就是app中的binder进程进而去释放buffer,但是binder:29975却迟迟得不到执行,一直在runnable状态,甚至于更前面一帧的Buffer释放请求也没有得到执行:

image.png

所以当binder:29975得到执行的时候,一下子标识有2个Buffer都释放了,直接由3变成1。释放之后dequeueBuffer就可以返回执行后面的工作了。

Android卡顿问题常见有以下几类:

1. 主线程执行耗时任务

问题:文件读写、数据库查询、大 JSON 解析、图片解码、排序、SDK 初始化

现象:点击后没反应、进入页面时卡一下

trace现象:主线程绘制工作一直处于运行状态

2. 主线程消息积压

问题:大量 Handler 消息、频繁回调、短时间内集中更新

现象:UI单个任务不长,但滑动和动画持续不流畅

trace现象:主线程一直处于运行状态,但是却没有在执行绘制工作

3. 锁竞争或同步等待

问题:等待 synchronized 锁、Future.get()join()、同步 Binder 调用

现象:CPU 不一定高,界面却停住

trace现象:相关绘制线程长时间处于sleeping状态

4. 布局和列表更新过重

问题:层级复杂、重复测量布局、频繁 requestLayout()、列表全量刷新、onBindViewHolder() 做重活

现象:列表滑动、数据刷新时掉帧

trace现象:主线程的绘制相关函数一直处于运行状态

5. 绘制和 GPU 压力大

问题:复杂自定义绘制、过度绘制、大面积透明叠加、模糊阴影、大纹理上传

现象:动画或图片密集页面卡顿,主线程未必很忙

trace现象:主线程和RenderThread线程的绘制相关函数一直处于运行状态,GPU绘制时间很长

此外 GC和CPU降频也可能导致卡顿问题

处理卡顿问题的时候可以先从下面3个方向排查

线程一直在运行:查它在计算、布局还是绘制,或者做业务工作,搞清楚为什么耗时。

线程在等待:查锁、I/O、Binder,以及它依赖的线程或进程。

线程想运行却没被调度:查 CPU 竞争、调度和设备负载。