第4章 脏矩形与局部刷新:告别全屏闪烁
全屏刷新,听起来简单粗暴。但在嵌入式GUI里,这往往是性能杀手。我早期做的一个温控器项目,屏幕只有320×240,全屏刷新一次要30毫秒。用户旋转编码器调温度时,数字跳得跟幻灯片似的。后来我才意识到——大部分区域根本没变,为什么要重画?
4.1 全屏刷新的性能瓶颈
先算一笔账。假设你的屏幕分辨率是480×272,颜色深度16位(RGB565)。
| 参数 | 数值 |
|---|---|
| 总像素数 | 480 × 272 = 130,560 |
| 每像素字节 | 2字节(16位) |
| 单帧数据量 | 130,560 × 2 = 261,120字节 ≈ 255KB |
| SPI时钟(典型) | 20MHz |
| 理论传输时间 | 261,120 × 8 / 20,000,000 ≈ 104ms |
104毫秒一帧,连10FPS都不到。这还没算CPU计算像素的时间。你想想看,如果用户按个按钮,要等100多毫秒才有反馈,这体验能好吗?
核心问题:全屏刷新把大量时间浪费在传输和绘制没有变化的像素上。实际应用中,每次更新往往只改变屏幕的5%~20%。
我在一个智能家居面板项目里遇到过更极端的例子。屏幕是800×480的RGB接口屏,全屏刷新虽然快(DMA传输),但CPU要花大量时间做图层混合。结果动画帧率只有12FPS,用户拖拽滑动条时明显卡顿。后来改成脏矩形刷新,帧率直接跳到45FPS。
4.2 脏矩形算法原理
脏矩形(Dirty Rect)的思路很简单:只重画发生变化的那一小块区域。就像你家里墙上脏了一块,你不会把整面墙重新粉刷,只刷脏掉的那部分就行。
具体怎么做?
- 标记脏区域——当某个控件状态改变时,把它所在的矩形区域标记为“脏”。
- 合并脏矩形——多个脏区域如果有重叠或相邻,合并成一个更大的矩形。
- 裁剪到屏幕边界——确保脏矩形不超出屏幕范围。
- 只重画脏矩形内的像素——调用驱动接口,只更新这部分区域。
下面是一个简单的脏矩形结构体定义:
typedef struct {
int16_t x; // 左上角X坐标
int16_t y; // 左上角Y坐标
int16_t w; // 宽度
int16_t h; // 高度
uint8_t dirty; // 标记位:1表示需要刷新
} DirtyRect_t;
// 全局脏矩形列表(最多支持8个独立脏区)
DirtyRect_t g_dirty_list[8];
uint8_t g_dirty_count = 0;
为什么是8个?我个人习惯用固定数组而不是链表。嵌入式环境里,动态分配容易产生碎片,固定数组更可控。8个一般够用,如果不够,可以增加到16个。
4.3 区域合并与裁剪优化
脏矩形多了,一个个刷新反而更慢。因为每次调用LCD驱动设置窗口都有固定开销。所以需要合并。
合并策略有两种:
- 简单合并——把所有脏矩形合并成一个外接矩形。实现简单,但可能包含大量不需要更新的像素。
- 智能合并——只合并相邻或重叠的矩形,不相邻的保持独立。效率更高,但算法复杂一些。
我一般用简单合并,然后加一个面积阈值判断。如果合并后的矩形面积超过全屏的60%,干脆全屏刷新算了。因为这时候合并后的区域已经接近全屏,省不了多少。
经验值:脏矩形面积 < 全屏面积 × 60% 时,用局部刷新;超过这个阈值,直接全屏刷新。这个阈值我是在一个项目里反复调出来的,你可以根据实际硬件调整。
裁剪优化也很重要。脏矩形不能超出屏幕边界,否则LCD驱动会出问题。另外,如果脏矩形宽度或高度为0,直接跳过。
// 裁剪脏矩形到屏幕边界
void clip_dirty_rect(DirtyRect_t *rect, int16_t screen_w, int16_t screen_h) {
if (rect->x < 0) {
rect->w += rect->x;
rect->x = 0;
}
if (rect->y < 0) {
rect->h += rect->y;
rect->y = 0;
}
if (rect->x + rect->w > screen_w) {
rect->w = screen_w - rect->x;
}
if (rect->y + rect->h > screen_h) {
rect->h = screen_h - rect->y;
}
// 如果裁剪后宽或高为0,标记为无效
if (rect->w <= 0 || rect->h <= 0) {
rect->dirty = 0;
}
}
我曾经在一个项目里忘了做裁剪,脏矩形坐标跑到屏幕外面去了。结果LCD驱动直接卡死,因为它在等待DMA传输完成,但地址越界了。从那以后,我把裁剪函数放在刷新流程的第一步。
4.4 脏矩形刷新流程
整个流程可以用下面这张图概括:
流程看起来不复杂,但实际实现时要注意几个细节。
注意:脏矩形标记必须在修改控件状态之前完成。否则会出现“撕裂”——用户看到一半旧画面、一半新画面。我建议在修改控件属性的函数入口处先调用标记函数。
4.5 实际项目中的避坑指南
做脏矩形刷新,有几个坑我踩过,分享给你:
- 字体渲染的脏区计算——中文字体宽度不固定,渲染后实际占用的矩形可能比你预估的大。我习惯在字体渲染函数里直接返回实际占用的矩形,而不是外部估算。
- 滚动条和滑动条——这类控件移动时,旧位置和新位置都需要标记为脏。否则旧位置会留下残影。
- 图层叠加——如果用了多个图层(如背景层+控件层),脏矩形要分别计算。但刷新时只刷新最上层的可见区域。
- DMA传输对齐——有些LCD控制器要求起始地址和宽度是偶数。脏矩形的x坐标和宽度如果不是偶数,需要做对齐处理。
调试技巧:开发阶段可以把脏矩形边界用红色边框画出来,肉眼观察刷新范围是否准确。我每个项目都会保留这个调试开关,上线前再关掉。