3. 性能指标与评估
渲染优化,说白了就是解决「屏幕怎么画才不卡」的问题。我早年做智能手表项目时,屏幕只有 1.3 寸,CPU 主频才 72MHz,画个时钟指针都掉帧。那时候我才意识到——嵌入式 GUI 的瓶颈,往往不在逻辑计算,而在像素填充。
这一章,我把实战中最常用的四个渲染优化手段串起来讲。它们不是孤立的,而是一套组合拳。
3.1 脏矩形算法——只画该画的部分
先问个问题:一个按钮被按下,你需要重绘整个屏幕吗?
当然不。你只需要重绘按钮那一小块区域。这块区域,就叫「脏矩形」(Dirty Rect)。
脏矩形的核心思想很简单:记录所有发生变化的区域,合并后只刷新这些区域。我见过不少新手,每次更新 UI 都调用 FillScreen(),结果帧率直接腰斩。
脏矩形合并规则
- 两个矩形有重叠 → 合并为一个更大的矩形
- 两个矩形相邻且间距小于阈值 → 合并以减少刷新次数
- 脏矩形数量超过上限(比如 8 个)→ 直接刷新全屏
实际代码里,我习惯用一个链表或固定数组来维护脏矩形列表。每次更新时,先检查新矩形能否与已有矩形合并,不能就追加。刷新时遍历列表,逐个裁剪绘制区域。
// 脏矩形合并示例(伪代码)
typedef struct {
int16_t x, y, w, h;
} Rect;
Rect dirty_list[8];
uint8_t dirty_count = 0;
void mark_dirty(Rect new_rect) {
for (int i = 0; i < dirty_count; i++) {
if (rects_overlap(&dirty_list[i], &new_rect)) {
merge_rect(&dirty_list[i], &new_rect);
return;
}
}
if (dirty_count < 8) {
dirty_list[dirty_count++] = new_rect;
} else {
// 超过上限,标记全屏刷新
mark_full_refresh();
}
}
我的经验:脏矩形算法在按钮、滑块、文本输入框这类局部交互场景下效果极好。但如果你在做一个全屏动画(比如页面滑动),脏矩形反而会退化为全屏刷新。这时候就该考虑其他策略了。
3.2 局部刷新——裁剪与区域重绘
脏矩形告诉你「画哪里」,局部刷新告诉你「怎么画」。两者是搭档。
局部刷新的底层依赖两个机制:
- 裁剪区域 (Clip Region):告诉硬件只更新某个矩形内的像素
- 区域重绘 (Region Redraw):只调用受影响控件的绘制函数
我记得有一次调试一个温控界面,温度数字每秒更新一次。如果全屏刷新,CPU 占用率冲到 40%;改成局部刷新后,直接降到 5%。差别就这么大。
实现局部刷新时,要注意一个坑:控件重叠。比如一个弹窗盖住了按钮,关闭弹窗后,按钮区域必须重绘。你不能只重绘弹窗消失的那块,还得把被遮挡的控件也标记为脏。
我曾经踩过的坑:在 STM32F429 上做局部刷新时,忘了处理控件 Z 序。结果弹窗关闭后,下面的按钮显示不全,留下一块「鬼影」。后来我加了一个简单的 Z 序检测:每次脏矩形生成后,遍历所有控件,把与脏矩形相交且 Z 序更高的控件也加入刷新列表。
3.3 双缓冲与三缓冲——消除撕裂
先看一个现象:屏幕上半部分显示新画面,下半部分还是旧画面。这就是「撕裂」(Tearing)。
原因很简单——你在屏幕刷新的中途修改了显存。解决思路也直接:给显示提供两个缓冲区。
| 方案 | 缓冲区数量 | 显存占用 | 适用场景 |
|---|---|---|---|
| 单缓冲 | 1 | 低 | 静态界面、极低资源 |
| 双缓冲 | 2 | 中 | 大多数嵌入式 GUI |
| 三缓冲 | 3 | 高 | 高帧率动画、触摸反馈 |
双缓冲:一个缓冲区用于显示(前台),一个用于绘制(后台)。绘制完成后,交换指针。这样用户永远看不到「画到一半」的画面。
三缓冲:在双缓冲基础上再加一个缓冲区。当绘制速度跟不上刷新速度时,第三个缓冲区可以缓存一帧,避免等待。我做过一个 60fps 的动画菜单,双缓冲偶尔会丢帧,换成三缓冲就稳了。
但三缓冲不是免费的午餐。它多占一块显存,在内存只有 256KB 的 MCU 上,可能直接撑爆。所以我的建议是:能用双缓冲就别上三缓冲。
// 双缓冲交换示例
uint16_t *front_buffer = (uint16_t *)0x60000000;
uint16_t *back_buffer = (uint16_t *)0x60200000;
void swap_buffers(void) {
// 等待当前帧扫描完成
while (LCD_IS_VSYNC());
// 交换缓冲区指针
LCD_SetFrameBuffer(back_buffer);
uint16_t *tmp = front_buffer;
front_buffer = back_buffer;
back_buffer = tmp;
}
个人习惯:我会在双缓冲的基础上,配合脏矩形做增量绘制。后台缓冲区只更新脏矩形区域,而不是整帧重绘。这样既省显存,又省 CPU。
3.4 垂直同步——让帧率与屏幕同步
双缓冲解决了撕裂,但如果没有垂直同步,你可能会遇到另一个问题:帧率不稳定。
垂直同步 (V-Sync) 的原理很简单:只在屏幕刷新完成(VBlank 区间)才交换缓冲区。这样每一帧的显示时间都是固定的,画面流畅度大幅提升。
但 V-Sync 也有代价:如果你的绘制时间超过了 16.67ms(60fps 的一帧),帧率会直接掉到 30fps。这就是为什么有些游戏开了 V-Sync 反而更卡。
在嵌入式系统里,我一般这样处理:
- 如果帧率能稳定在 60fps → 开启 V-Sync,画面最平滑
- 如果帧率波动大 → 关闭 V-Sync,用双缓冲 + 脏矩形保证响应速度
- 如果帧率低于 30fps → 降低分辨率或减少绘制层级,别硬扛
我曾经犯过的错:在一个 72MHz 的 MCU 上强行开启 V-Sync,结果动画帧率从 40fps 掉到 20fps。后来发现是绘制函数里有个耗时的浮点运算。优化掉之后,V-Sync 才真正发挥作用。所以记住:V-Sync 是锦上添花,不是雪中送炭。
3.5 四种策略的协同工作
这四种策略不是互斥的,而是层层递进的关系。我画了一张图,帮你理清它们怎么配合:
你看,整个流程是串起来的:应用层触发变化 → 脏矩形引擎记录并合并区域 → 局部刷新只重绘受影响部分 → 双缓冲在后台完成绘制 → V-Sync 在安全时机交换缓冲区 → 最终显示到硬件上。
任何一个环节出问题,整体性能都会受影响。我见过有人把脏矩形和双缓冲都做好了,但忘了开 V-Sync,结果画面还是偶尔撕裂。也见过有人开了 V-Sync,但脏矩形合并算法写得稀烂,导致每次刷新区域过大,帧率上不去。
我的建议:从最简单的方案开始——先上双缓冲 + 脏矩形。这两个组合能解决 80% 的渲染问题。如果还有撕裂,再加 V-Sync。如果显存够且帧率要求高,再考虑三缓冲。别一上来就全上,调试起来会疯的。
嗯,渲染优化这块,说白了就是「少画、快画、画对」。少画靠脏矩形,快画靠局部刷新和缓冲,画对靠 V-Sync。把这四个点吃透,你的嵌入式 GUI 基本就告别卡顿了。