3. 渲染管线剖析:TouchGFX 的渲染流程

很多人在做 TouchGFX 优化时,上来就调帧率、改图片格式,结果卡顿依旧。为什么?因为你不清楚数据到底是怎么从 Model 走到屏幕上的。说白了,你连路都没摸清,怎么知道堵在哪?

这一章,我就带你彻底走一遍 TouchGFX 的渲染管线。从 Model 层的数据更新,到 Screen 层的 UI 树遍历,再到最后的硬件刷新。每一步我都会结合项目里踩过的坑来讲。

核心观点: 渲染管线不是黑盒。你只有看清每个环节的职责和瓶颈,才能精准定位卡顿根源。

3.1 整体流程:三阶段模型

TouchGFX 的渲染流程,我习惯把它拆成三个阶段:数据准备 → UI 树遍历 → 硬件刷新。这三个阶段在每一帧里顺序执行,任何一个阶段超时,都会导致掉帧。

下面这张图,是我自己画的一个简化流程。你看一眼,心里就有谱了。

TouchGFX 渲染管线三阶段 阶段1:数据准备 Model::tick() Presenter 逻辑处理 数据模型更新 → 触发 invalidate 数据流 阶段2:UI树遍历 Screen::handleTick() Widget 树递归遍历 脏矩形收集 → 生成绘制命令 绘制命令 阶段3:硬件刷新 DMA2D 传输 LTDC 刷新 帧缓冲交换 → 屏幕显示 ⚠ 注意:三个阶段在单帧内串行执行。阶段1或阶段2超时,阶段3的刷新窗口就会被压缩,直接导致掉帧。

嗯,这里要注意:三个阶段是串行的,不是并行的。很多新手以为 TouchGFX 会自动多线程加速,其实默认情况下,所有工作都在主循环里完成。

3.2 阶段一:数据准备(Model → Presenter)

这个阶段发生在 Model::tick() 里。我见过不少项目,把大量业务逻辑塞在这里,结果每帧光数据处理就占了 5ms 以上。

数据准备阶段的核心工作就三件:

  • 读取传感器/通信数据:比如 ADC 采样、串口接收、触摸事件。
  • 业务逻辑计算:比如状态机切换、数值滤波、协议解析。
  • 通知 UI 层:通过 modelListener 调用 Presenter 的方法,触发界面更新。
我的经验: 数据准备阶段尽量只做轻量级操作。重计算(比如 FFT、图像处理)请放到后台任务或低优先级线程里,别在主循环里硬扛。

举个例子,我之前做一个工业仪表项目,Model 里每帧都要做一次卡尔曼滤波。滤波本身不重,但数据量一大(128 点数组),每帧就多了 3ms。后来我把滤波放到定时器中断里,Model 只读结果,帧率直接从 45fps 升到 60fps。

3.3 阶段二:UI 树遍历(Screen → Widget)

数据准备好了,接下来 TouchGFX 会遍历当前 Screen 的 Widget 树。这个过程我称之为「脏矩形收集 + 绘制命令生成」。

具体来说,TouchGFX 会做这几步:

  1. 调用 Screen::handleTick():你在这里更新 Widget 的状态,比如移动一个 Box、改变文本内容。
  2. 标记脏矩形:每个 Widget 在状态变化时,会把自己所在的区域标记为「需要重绘」。
  3. 合并脏矩形:TouchGFX 会把所有脏矩形合并成若干个不重叠的矩形区域,减少绘制次数。
  4. 生成绘制命令:对每个脏矩形,递归遍历 Widget 树,生成对应的绘制指令(画线、填充、贴图等)。
避坑指南: 我曾经遇到一个项目,界面上有 50 多个 TextArea 每秒更新一次。每个 TextArea 都会触发脏矩形,合并后几乎覆盖了整个屏幕。结果每帧都要全屏重绘,帧率直接掉到 20fps。后来我把静态文本和动态文本分层管理,动态区域只占屏幕 1/3,帧率立刻回到 55fps。

UI 树遍历的耗时,跟 Widget 数量和脏矩形面积成正比。你想想看,如果一个界面有 200 个 Widget,每次全屏重绘,那遍历开销是相当可观的。

3.4 阶段三:硬件刷新(帧缓冲 → 屏幕)

最后一步,是把绘制好的帧缓冲数据送到屏幕。这里涉及两个关键硬件模块:DMA2D 和 LTDC。

硬件模块 职责 常见瓶颈
DMA2D 图形加速传输,负责像素格式转换、混合、填充 时钟频率不足、总线带宽竞争
LTDC 液晶显示控制器,从帧缓冲读取数据并驱动屏幕 帧缓冲层数过多、分辨率过高
帧缓冲 存储当前帧的像素数据 带宽不足(尤其是双缓冲切换时)

硬件刷新阶段,我建议你重点关注两个参数:

  • 帧缓冲格式:RGB565 比 ARGB8888 带宽减半,但颜色精度下降。如果屏幕只显示图标和文字,RGB565 完全够用。
  • DMA2D 时钟:在 STM32F429 上,DMA2D 时钟默认是 90MHz。我试过降到 45MHz,全屏填充时间从 0.8ms 涨到 1.6ms,帧率直接掉 10fps。
关键指标: 硬件刷新阶段的目标是 ≤ 5ms(60fps 下)。如果超过 8ms,你就得考虑降低分辨率、减少帧缓冲层数,或者开启双缓冲 + 垂直同步。

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。

排查后发现两个问题:

  1. Model 里每帧都在做 CRC 校验(数据准备阶段),其实数据没变化时根本不需要校验。
  2. UI 树里有一个隐藏的 Container,里面 30 个 Widget 每帧都在更新位置(UI 树遍历阶段),但用户根本看不到。

修复方案很简单:CRC 校验改成 10 帧做一次;隐藏的 Container 直接 setVisible(false) 并跳过更新。改完后,数据准备降到 1ms,UI 树遍历降到 3ms,总耗时 8ms,流畅运行。

你看,只要摸清了渲染管线的每个环节,定位问题其实不难。