CPU与GPU协同:渲染任务分工,瓶颈场景详解

咱们做Android渲染优化的,说白了就是在跟两个家伙打交道:CPU和GPU。很多同学一上来就盯着帧率看,觉得掉帧就是GPU不行。其实啊,我得说,掉帧的原因至少有一半出在CPU身上。今天我就把这俩的活儿掰开讲讲,再结合我踩过的坑,帮你快速定位瓶颈。

一、渲染流水线里谁干什么活

先画个总图,你心里有个谱。这张图我记得很清楚,每次培训新人我都先画它。

CPU与GPU渲染协作流程 CPU阶段 GPU阶段 ① 应用逻辑 & 布局 measure / layout ② 绘制指令生成 DisplayList / draw commands ③ 同步 fence + 提交 dequeueBuffer / queueBuffer BufferQueue ④ 栅格化 (Rasterization) 顶点处理 → 片元着色 ⑤ SurfaceFlinger合成 Layer blending / HWC ⑥ 帧缓冲 & 显示输出 Framebuffer → Display VSync / fence 关键: CPU负责构建指令,GPU负责执行。两者通过BufferQueue和fence解耦。 但CPU提交慢了或GPU处理慢了,都会导致掉帧。 CPU任务 GPU任务 同步机制

核心认知:CPU负责「想清楚画什么」,GPU负责「执行画」。但CPU如果「想」得太慢,GPU就会饿死;GPU如果「画」得太慢,CPU就得等着下一帧。

二、CPU瓶颈的典型场景

我个人习惯先用systrace抓一把,看CPU阶段的时间戳。如果Choreographer.doFrame或者View.draw那一块突然变长,那八九不离十是CPU瓶颈。下面列几个我实际项目里碰到最多的。

1. 布局嵌套过深

你想想看,一个页面五六层RelativeLayout套LinearLayout,每次measure都要从根遍历到底。我在给某个电商App做优化时,发现首页商品卡片打开要200ms。一查,一个商品条目嵌套了11层。去掉不必要的容器,直接降到18ms。

提示:用ConstraintLayout或者FlatLayout尽量压平层次。我建议每个页面布局深度控制在4层以内。

2. 过度绘制(Overdraw)导致CPU生成指令过多

其实Overdraw不只是GPU的活——CPU生成绘制指令时,如果同一个像素被重复画,CPU的指令队列也会膨胀。我曾经见过一个同事写了个背景色在Activity的根布局上,然后每个Fragment又设了一层背景,每条Item还设了背景……三层叠加。用开发者选项 → 调试GPU过度绘制一看,满屏紫红色。去掉重复背景后,CPU绘制指令数少了60%。

3. 耗时方法卡住UI线程

这个是最常见的。比如在onClick里直接做网络请求、数据库读写,或者解析JSON。UI线程一旦被卡住,Choreographer过了vsync点才收到回调,下一帧就直接掉帧了。我记得有次线上反馈滑动列表卡顿,查了三天发现是某次版本里在Adapter的getView里写了个BitmapFactory.decodeFile……嗯,从那以后我们团队强制所有文件操作放到异步线程。

4. 垃圾回收(GC)抖动

这个特别隐蔽。如果你的代码频繁创建临时对象(比如在onDraw里new Paint),GC就会频繁触发,暂停UI线程。我在优化一个视频播放器时,发现每次播放下一个视频前会掉一两帧。用systrace一看,GC_FOR_ALLOC经常出现。我们让播放器预分配对象池,帧率立马就稳了。

CPU瓶颈类型 典型现象 排查手段
布局嵌套过深 measure/layout耗时>5ms systrace + Layout Inspector
过度绘制 GPU渲染指令过多 开发者选项 → Overdraw调试
UI线程阻塞 doFrame长时间无返回 systrace + 代码review
GC频繁 每帧出现GC_FOR_ALLOC Memory Profiler + 对象分配追踪

三、GPU瓶颈的典型场景

好,接下来看GPU端。如果CPU阶段很顺畅,但是帧率依然不稳,那问题大概率出在GPU。

1. 纹理过大或过多

GPU处理纹理的能力是有限的。特别是OpenGL ES的纹理尺寸有上限,一般是4096x4096。我见过有人给列表Item的图片用了2000x2000的原图,GPU栅格化直接炸了。用Profiling工具看,GPU帧时间飙升到60ms。解决办法很简单——压缩到匹配显示尺寸,或者用Bitmap.Config.RGB_565减少内存带宽。

警告:别用RGB_565处理有渐变的图片,色带会很难看。拍照场景建议用ARGB_8888,但尺寸一定要缩放。

2. 复杂Shader或混合效果

如果你在自定义View里用了setLayerType(LAYER_TYPE_HARDWARE)然后又叠加多个BlendMode,GPU的片元着色器压力会很大。有一次我给一个时钟App画表盘,用了圆形渐变+阴影+抗锯齿,结果在低端机上连30帧都跑不到。后来用预渲染bitmap代替实时绘制,直接解决了。

3. SurfaceFlinger合成瓶颈

这个比较进阶。当你的App有多个Window、Surface、Layer时,SurfaceFlinger会在GPU里做layer合成。如果Layer数超过硬件合成器(HWC)支持的overlay数量,就会退回到GPU合成,性能暴跌。我记得在Android 8.0的一个项目里,我们通过合并Surface(比如把悬浮窗合并到Activity的Surface上)把Layer数从7降到4,帧时间降了一半。

4. 帧率与刷新率不匹配(掉帧渲染)

GPU渲染完一帧后,如果来不及在下一个vsync前完成,就会重复上一帧。这本质是GPU处理能力不足。我建议用dumpsys gfxinfo看Draw和Process时间。如果Draw时间超过16ms(60Hz下),那就真得降特效了。

快速判定法:systrace里CPU阶段正常(<10ms),但是GPU提交后等了很久才显示,那基本是GPU瓶颈。反过来,CPU阶段长,GPU空闲,就是CPU瓶颈。

四、避坑指南——我踩过的几个坑

说到底,CPU和GPU是搭档,哪个拖后腿都不行。我总结几个小建议:

  • 别迷信硬件加速。硬件加速不是万能药,复杂Path在GPU上反而慢,有时候软件绘制更快。我在做手写笔记App时,发现笔迹渲染用软件绘制比硬件快2倍,因为GPU处理大量小路径的开销太高。
  • 关注BufferQueue的dequeueBuffer超时。如果GPU太忙,CPU在dequeue时可能会等很久。systrace里如果看到dequeueBuffer飘红,就是GPU被前几帧堵住了。
  • VSync对齐非常重要。我曾经把一个动画的帧率从60降到30,理论上应该省电。但因为没对齐vsync,反而导致丢帧感更强。后来改成每两帧跳一帧,视觉上才平滑。

终极调试命令:adb shell dumpsys SurfaceFlinger --latency SurfaceView 可以直观看到每一帧的提交时间,对着数字分析,比凭感觉调强一百倍。

好了,CPU和GPU这哥俩的分工和典型瓶颈先说到这。记住一点:别只看帧率,要拆开看每个阶段耗时。用systrace,用dumpsys,用Perfetto,把时间打在屏幕上,哪儿疼治哪儿。