3. 渲染管线剖析:TouchGFX 的渲染流程
很多人在做 TouchGFX 优化时,上来就调帧率、改图片格式,结果卡顿依旧。为什么?因为你不清楚数据到底是怎么从 Model 走到屏幕上的。说白了,你连路都没摸清,怎么知道堵在哪?
这一章,我就带你彻底走一遍 TouchGFX 的渲染管线。从 Model 层的数据更新,到 Screen 层的 UI 树遍历,再到最后的硬件刷新。每一步我都会结合项目里踩过的坑来讲。
3.1 整体流程:三阶段模型
TouchGFX 的渲染流程,我习惯把它拆成三个阶段:数据准备 → UI 树遍历 → 硬件刷新。这三个阶段在每一帧里顺序执行,任何一个阶段超时,都会导致掉帧。
下面这张图,是我自己画的一个简化流程。你看一眼,心里就有谱了。
嗯,这里要注意:三个阶段是串行的,不是并行的。很多新手以为 TouchGFX 会自动多线程加速,其实默认情况下,所有工作都在主循环里完成。
3.2 阶段一:数据准备(Model → Presenter)
这个阶段发生在 Model::tick() 里。我见过不少项目,把大量业务逻辑塞在这里,结果每帧光数据处理就占了 5ms 以上。
数据准备阶段的核心工作就三件:
- 读取传感器/通信数据:比如 ADC 采样、串口接收、触摸事件。
- 业务逻辑计算:比如状态机切换、数值滤波、协议解析。
- 通知 UI 层:通过
modelListener调用 Presenter 的方法,触发界面更新。
举个例子,我之前做一个工业仪表项目,Model 里每帧都要做一次卡尔曼滤波。滤波本身不重,但数据量一大(128 点数组),每帧就多了 3ms。后来我把滤波放到定时器中断里,Model 只读结果,帧率直接从 45fps 升到 60fps。
3.3 阶段二:UI 树遍历(Screen → Widget)
数据准备好了,接下来 TouchGFX 会遍历当前 Screen 的 Widget 树。这个过程我称之为「脏矩形收集 + 绘制命令生成」。
具体来说,TouchGFX 会做这几步:
- 调用
Screen::handleTick():你在这里更新 Widget 的状态,比如移动一个 Box、改变文本内容。 - 标记脏矩形:每个 Widget 在状态变化时,会把自己所在的区域标记为「需要重绘」。
- 合并脏矩形:TouchGFX 会把所有脏矩形合并成若干个不重叠的矩形区域,减少绘制次数。
- 生成绘制命令:对每个脏矩形,递归遍历 Widget 树,生成对应的绘制指令(画线、填充、贴图等)。
UI 树遍历的耗时,跟 Widget 数量和脏矩形面积成正比。你想想看,如果一个界面有 200 个 Widget,每次全屏重绘,那遍历开销是相当可观的。
3.4 阶段三:硬件刷新(帧缓冲 → 屏幕)
最后一步,是把绘制好的帧缓冲数据送到屏幕。这里涉及两个关键硬件模块:DMA2D 和 LTDC。
| 硬件模块 | 职责 | 常见瓶颈 |
|---|---|---|
| DMA2D | 图形加速传输,负责像素格式转换、混合、填充 | 时钟频率不足、总线带宽竞争 |
| LTDC | 液晶显示控制器,从帧缓冲读取数据并驱动屏幕 | 帧缓冲层数过多、分辨率过高 |
| 帧缓冲 | 存储当前帧的像素数据 | 带宽不足(尤其是双缓冲切换时) |
硬件刷新阶段,我建议你重点关注两个参数:
- 帧缓冲格式:RGB565 比 ARGB8888 带宽减半,但颜色精度下降。如果屏幕只显示图标和文字,RGB565 完全够用。
- DMA2D 时钟:在 STM32F429 上,DMA2D 时钟默认是 90MHz。我试过降到 45MHz,全屏填充时间从 0.8ms 涨到 1.6ms,帧率直接掉 10fps。
3.5 三阶段的时间分配建议
根据我调试过的十几个项目,我总结了一个经验公式:
- 数据准备:≤ 2ms(占 12%)
- UI 树遍历:≤ 5ms(占 30%)
- 硬件刷新:≤ 5ms(占 30%)
- 余量:≥ 4.6ms(占 28%)—— 用于应对突发负载
为什么留这么多余量?因为 TouchGFX 的帧率是固定的,如果某一帧超时,下一帧就会被跳过,造成视觉卡顿。余量就是你的安全垫。
Model::tick() 开始和 HAL::flushFrameBuffer() 结束时各打一个 GPIO 翻转信号,用示波器量一下总耗时。如果总耗时超过 16.6ms(60fps),那就说明某个阶段超了,需要针对性优化。
3.6 一个典型的卡顿案例
最后,我分享一个真实案例。去年做的一个医疗设备界面,切换页面时卡顿明显。我用示波器一量,发现数据准备阶段占了 8ms,UI 树遍历占了 7ms,硬件刷新占了 4ms,总耗时 19ms,远超 16.6ms。
排查后发现两个问题:
- Model 里每帧都在做 CRC 校验(数据准备阶段),其实数据没变化时根本不需要校验。
- UI 树里有一个隐藏的 Container,里面 30 个 Widget 每帧都在更新位置(UI 树遍历阶段),但用户根本看不到。
修复方案很简单:CRC 校验改成 10 帧做一次;隐藏的 Container 直接 setVisible(false) 并跳过更新。改完后,数据准备降到 1ms,UI 树遍历降到 3ms,总耗时 8ms,流畅运行。
你看,只要摸清了渲染管线的每个环节,定位问题其实不难。