很多开发者写 Custom View 时都有过这种困惑: “我的 onDraw 代码只有几十行,逻辑也很简单,为什么跑起来 Systrace 上全是红色的 F (Frame drop)?” “为什么我只是做了一个简单的动画,CPU 占用率却飙到了 40%?”
如果你只盯着 Java/Kotlin 层的 onDraw 看,可能永远找不到答案。因为在 Android 5.0 开启硬件加速(Hardware Acceleration)之后,真正的渲染战场早已转移到了 RenderThread 和 GPU。
今天我们把 Android 的渲染管线切开,看看你的 View 到底死在哪一步。
1. 渲染管线的真相:双线程模型
首先必须打破一个认知:UI 卡顿不一定是因为主线程(UI Thread)忙。
在硬件加速开启的情况下,Android 的一帧渲染主要涉及两个线程的协作:
-
Main Thread (UI Thread) :
- 负责
Measure,Layout。 - 负责
onDraw(注意:这里的 onDraw 并不产生像素,只是录制命令)。
- 负责
-
RenderThread:
- 负责执行
SyncFrame(从主线程同步数据)。 - 负责将录制的命令转换为 OpenGL/Vulkan 指令。
- 负责交换缓冲区(Swap Buffer)送显。
- 负责执行
掉帧的真相通常发生在两个地方:
- 主线程耗时过长:导致无法及时将 DisplayList 交给 RenderThread。
- RenderThread 拥堵:GPU 指令太复杂,RenderThread 处理不过来,导致主线程在下一次
SyncFrame时被堵塞。
2. onDraw 的谎言:你只是在“录像”
在硬件加速模式下,当你调用 canvas.drawRect() 时,CPU 并没有去计算哪个像素点该变色。
Canvas 是一个壳,底层对应的是 DisplayListCanvas。你的每一次调用(drawPath, drawBitmap),实际上是向 DisplayList (RenderNode) 中添加了一条 OP 代码(类似于汇编指令)。
这就是为什么“自定义 View 极其容易掉帧”的第一个深层原因:DisplayList 重建风暴。
场景:如果你调用了 invalidate()
每次调用 invalidate(),Android 会把当前 View 标记为 "Dirty"。 下一帧到来时,系统会清空该 View 对应的 DisplayList,并重新调用 onDraw 再次录制一遍。
- 性能杀手 A:复杂的 Path 计算 如果你在
onDraw里实时计算贝塞尔曲线(Bezier Curve)或者做Path.op()(布尔运算),这些是纯 CPU 操作。主线程会被卡死,导致录制时间超过 16ms。 - 性能杀手 B:频繁的
invalidate()如果你的动画只是改变一个x坐标,却调用了invalidate(),导致整个 View 的复杂图形全部重新录制一遍,这就是极大的浪费。
优化策略:使用 RenderNode 属性(View Property Animator)
view.setTranslationX() 和 view.setAlpha() 为什么快? 因为它们不需要调用 onDraw,也不需要重新构建 DisplayList。它们直接修改了 RenderNode 的属性(Matrix/Alpha)。RenderThread 拿到旧的 DisplayList,应用新的 Matrix,直接交给 GPU 绘制。
结论:做动画时,能用 translation/scale/rotation 解决的,绝对不要用 invalidate() 重绘。
3. GPU 的噩梦:Path 渲染与光栅化
你可能遇到过这种情况:onDraw 里只有一行 canvas.drawPath(complexPath, paint),主线程只有 1ms,但界面依然卡顿。
打开 GPU 呈现模式分析(Profile GPU Rendering),你会看到 "Process" 或 "Swap Buffers" 条特别长。
这是因为:GPU 其实非常讨厌画 Path。
GPU 擅长画三角形、纹理(Bitmap),但不擅长画任意形状的矢量路径。Android 在硬件加速下绘制 Path,通常有两种策略(取决于 Android 版本和 GPU 驱动):
- CPU 光栅化 (Software Fallback) : 如果 Path 太复杂(包含大量曲线、裁剪),RenderThread 可能会退回到 CPU 将 Path 绘制成 Bitmap,然后再上传给 GPU。这个过程叫 Texture Upload,极其耗时。
- GPU 模版 (Stencil Buffer) : 使用模版缓冲来“切”出形状。这会消耗大量的 GPU 填充率(Fill Rate)。
掉帧陷阱:setLayerType(LAYER_TYPE_SOFTWARE, null) 很多老代码为了兼容某些混合模式(Xfermode)或阴影(ShadowLayer),会给 View 加上这就话。 这意味着:每一帧,这个 View 都要在主线程用 CPU 画出一张 Bitmap,然后上传 GPU。 在高分屏(2K/4K)设备上,这简直是性能核弹。
4. 实战:如何拯救你的 Custom View?
假设你正在做一个实时波形图(Waveform View),每一帧都在变,必须重绘,怎么办?
招式一:降低 Path 的精度
不要把所有采样点都 lineTo 进去。
- Ramer-Douglas-Peucker 算法:抽稀你的点集。人眼在 1080P 屏幕上看不出 0.5px 的抖动,去掉那些无意义的微小段落,能让 GPU 渲染速度提升 10 倍。
招式二:drawPoints 代替 drawPath
如果只是画折线图,canvas.drawPoints() 比 canvas.drawPath() 快得多。因为 drawPoints 直接对应 GPU 的 GL_LINES 指令,没有任何光栅化负担。
招式三:Bitmap 缓存(双缓冲思想)
如果你有一个复杂的背景盘(仪表盘刻度、文字),上面只有一根指针在动。
-
错误做法:在
onDraw里先画复杂的盘,再画指针。每次都重录。 -
正确做法:
-
在
onSizeChanged里创建一个Bitmap(Canvas)。 -
把背景盘画在这个 Bitmap 上(只画一次)。
-
在
onDraw里,canvas.drawBitmap(bgBitmap),然后canvas.drawLine(pointer)。
- 进阶:使用
RenderNode或HardwareLayer让 GPU 帮你缓存这个 Texture。
-
招式四:避免 ClipPath
canvas.clipPath() 是抗锯齿杀手,也是 GPU 杀手。 如果想要圆角,尽量用 ViewOutlineProvider 或者 CardView 的实现方式(setClipToOutline),这是硬件层面的裁剪,几乎零成本。
总结
当你觉得 View 卡顿时,请按照这个逻辑排查:
- 看主线程:
onDraw里是否有耗时计算?是否有对象分配(GC)? - 看重绘频率:是不是在用
invalidate()驱动本该用setTranslation完成的动画? - 看 GPU 压力:是不是用了
drawPath画几千个点的曲线?是不是用了clipPath?是不是无意中开启了软件渲染层?
Android 的渲染管线就像一条精密的流水线,主线程是把设计图纸(DisplayList)画好,RenderThread 是把图纸送到工厂(GPU)去生产。任何一方掉链子,你的 View 都会卡。