第4章 CPU与GPU协同:一场精心编排的舞蹈

说实话,很多做Android开发的朋友,对CPU和GPU的理解是割裂的。总觉得CPU负责逻辑,GPU负责画图,各干各的。但实际项目里,真正让App卡顿的,往往是这两兄弟配合出了问题。

我个人习惯把CPU和GPU的关系,比作一个交响乐团。CPU是指挥,GPU是乐手。指挥再牛,乐手跟不上,曲子就乱了。反过来,乐手技术再好,指挥给错了节拍,一样完蛋。

CPU/GPU工作流程:到底谁先动的手?

一个典型的帧绘制流程,是这样的:

  1. CPU阶段:处理布局、测量、绘制指令。说白了,就是CPU在脑子里把画面“想好”。
  2. 同步阶段:CPU把“想好”的画面,打包成GPU能懂的指令,扔给GPU。
  3. GPU阶段:GPU拿到指令,开始真正往屏幕上画像素。
  4. 垂直同步:等屏幕刷新信号来了,把画好的帧显示出去。

这里有个关键点——CPU和GPU是异步工作的。CPU提交完指令,不会傻等着GPU画完,而是立刻回去处理下一帧。这就像你点了个外卖,然后继续干活,而不是站在店门口等。

核心矛盾:CPU处理太快,GPU来不及画,就会丢帧。GPU画得太快,CPU来不及准备,GPU就得空转。理想状态是两者速度匹配,但现实往往很骨感。

我在项目中遇到过一个问题:一个列表滑动时,偶尔会突然卡一下。排查后发现,是某个item的布局嵌套太深,CPU测量耗时暴增。GPU其实早就画完了,但CPU迟迟不提交下一帧指令,导致GPU在那干等。嗯,这就是典型的CPU瓶颈。

RenderThread的作用:CPU的“专职跑腿”

Android 4.0之后引入的RenderThread,很多人不太理解它的价值。你想想看,如果没有RenderThread,所有渲染相关的活儿都得主线程干。主线程既要处理触摸事件、又要跑业务逻辑、还要管渲染,不卡才怪。

RenderThread说白了,就是CPU派给GPU的一个“专职跑腿”。它的职责很明确:

  • 接收主线程发来的渲染指令
  • 把这些指令转换成GPU能理解的OpenGL ES命令
  • 管理硬件加速的缓冲区
  • 处理动画相关的属性变化

最妙的一点:RenderThread可以独立于主线程工作。比如你给一个View设置了属性动画,改变它的translationX。主线程只需要告诉RenderThread“这个View的X坐标变了”,剩下的插值计算、帧提交,全由RenderThread搞定。主线程可以腾出手来处理更重要的事。

避坑指南:我曾经遇到过一个诡异的问题——属性动画跑起来一卡一卡的,但主线程明明很空闲。后来发现,是某个自定义View的onDraw里做了大量计算,导致RenderThread每次提交指令时,都要等主线程的onDraw执行完。解决办法?把计算移到RenderThread能访问的地方,或者用ViewPropertyAnimator代替自定义动画。

硬件加速原理:GPU到底在加速什么?

很多人以为硬件加速就是“用GPU画图”。这个说法没错,但太笼统了。硬件加速真正加速的,是绘制指令的批量处理

没有硬件加速时,CPU用Skia软件渲染,每个像素都是CPU算出来的。CPU擅长逻辑运算,但不擅长并行处理大量像素。这就好比让一个数学家去数米粒——他能数对,但效率极低。

有了硬件加速后,CPU只负责生成绘制指令(比如“画一个红色矩形”),然后把指令打包发给GPU。GPU有几百个核心,可以同时处理大量像素。这就像让一个工厂流水线去数米粒——效率天差地别。

具体来说,硬件加速的流程是这样的:

  1. 记录阶段:CPU遍历View树,生成显示列表(DisplayList)。每个View的draw操作,被记录成一条条指令。
  2. 更新阶段:如果View的属性变了,只需要更新对应的显示列表节点,不用重新生成整个列表。
  3. 渲染阶段:RenderThread把显示列表转换成OpenGL命令,提交给GPU。

关键点:显示列表的复用是硬件加速性能提升的核心。一个View只要不改变绘制内容,它的显示列表就可以反复使用。这就是为什么滚动列表时,已经绘制过的item不需要重新走一遍onDraw。

我记得有一次优化一个复杂页面,发现每次页面切换都卡。用Profile GPU Rendering一看,红色柱状图(代表GPU处理时间)特别高。排查后发现,是某个View的onDraw里调用了canvas.clipPath()。这个操作在硬件加速下会触发GPU的离屏渲染,性能开销极大。换成canvas.clipRect()后,问题解决。

离屏缓冲与Overdraw:看不见的浪费

离屏缓冲,听起来很高大上,其实原理很简单:GPU在正式画到屏幕之前,先在一个临时缓冲区里画好,再一次性贴到屏幕上

为什么要这么做?因为有些效果,没法直接画到屏幕上。比如:

  • 模糊效果:需要先画好原图,再对原图做模糊处理
  • 阴影效果:需要先画好阴影层,再画内容层
  • 裁剪效果:需要先画好裁剪区域,再画内容

这些操作都需要一个“中间画布”,这就是离屏缓冲。

但离屏缓冲是有代价的——每创建一个离屏缓冲,GPU就要多一次渲染pass。一个pass可能只要1ms,但多个pass叠加起来,16ms的帧时间很快就用完了。

Overdraw就更常见了。说白了,就是同一个像素被画了多次。比如你有一个红色背景的布局,上面盖了一个蓝色按钮。GPU先画红色背景,再画蓝色按钮。按钮区域的像素,被画了两次。

注意:Overdraw在软件渲染下影响不大,因为CPU是逐像素绘制的,覆盖掉就覆盖掉了。但在硬件加速下,Overdraw意味着GPU要处理更多的像素填充操作。虽然GPU擅长并行处理,但无意义的填充依然会浪费带宽。

我曾经优化过一个列表页面,用开发者选项里的“显示过度绘制”一看,整个屏幕红得发紫。排查后发现,是每个item都设置了背景色,而列表本身也有背景色。更离谱的是,某个自定义View的onDraw里,先画了一个全屏矩形,再画内容。去掉这些重复的背景绘制后,列表滑动帧率从40fps直接飙到58fps。

怎么减少Overdraw?我总结了几个实用技巧:

  • 去掉不必要的背景:父布局和子布局不要同时设置背景
  • 使用View.setWillNotDraw(true):如果自定义View不需要绘制,告诉系统跳过它
  • 合理使用clipRect:只绘制可见区域,避免绘制被遮挡的部分
  • 避免在onDraw里创建新对象:尤其是Paint、Path这些,复用它们

个人经验:我习惯在开发阶段一直开着“显示过度绘制”和“Profile GPU Rendering”。这两个工具就像汽车的仪表盘,能让你实时看到性能状况。很多卡顿问题,光靠肉眼是看不出来的,但这两个工具一开,问题就原形毕露了。

最后说一句,CPU和GPU的协同优化,没有银弹。每个App的情况都不一样,关键是要理解它们的工作原理,然后针对性地去排查和优化。嗯,这就是我这些年踩坑踩出来的经验。