3、脏矩形算法:局部刷新原理、矩形合并策略、碰撞检测优化
做嵌入式GUI,最怕什么?
最怕整个屏幕从头刷到尾。你想想看,一个320×240的屏,全屏刷新一次可能要十几毫秒,要是再加上透明叠加、字体渲染,帧率直接掉到个位数。我早年做一款手持设备,就因为全屏刷新太频繁,电池续航硬生生少了两个小时。
脏矩形算法,说白了就是「哪里变了刷哪里」。这招不是新东西,但在资源受限的嵌入式系统里,它几乎是GUI性能的命门。今天咱们就把这块掰开揉碎了讲。
3.1 局部刷新原理
脏矩形的核心思想很简单:只更新屏幕上发生变化的那一小块区域。每次用户操作、数据更新、动画切换,系统都会标记一个「脏矩形」——也就是需要重绘的区域。
核心公式:
刷新耗时 = 脏矩形面积 × 单位像素绘制耗时
全屏刷新面积 = 屏幕宽 × 屏幕高
局部刷新面积 = 脏矩形宽 × 脏矩形高
性能提升比例 ≈ 全屏面积 / 脏矩形面积
举个例子。一个320×240的屏,全屏面积是76800像素。如果只更新一个64×64的按钮,面积只有4096像素。性能提升接近19倍。嗯,这就是脏矩形的魅力。
我在项目中遇到过一种情况:明明只改了一个数字,整个仪表盘都在闪。后来一查,是底层驱动没做脏矩形判断,每次数值变化都触发了全屏刷新。加了个简单的脏矩形裁剪,帧率从12fps直接跳到45fps。
脏矩形的生命周期一般分三步:
- 标记脏区——当控件状态变化时,计算并记录需要重绘的区域
- 合并脏区——多个脏区合并成更少的矩形,减少绘制次数
- 执行重绘——只对最终脏矩形区域进行绘制和刷新
我的习惯:在标记脏区时,多留1-2个像素的余量。别小看这个细节,它能避免因为抗锯齿、阴影等效果导致的边缘残留。我曾经因为没留余量,按钮按下后边缘总有一条细线消不掉,排查了整整一个下午。
3.2 矩形合并策略
多个脏矩形同时存在时,怎么合并最划算?这是个经典问题。
常见的合并策略有四种:
| 策略名称 | 合并方式 | 适用场景 | 性能特点 |
|---|---|---|---|
| 简单包围盒 | 取所有脏矩形的最小外接矩形 | 脏区集中、数量少 | 计算快,但可能包含大量无需更新的区域 |
| 水平/垂直合并 | 按行或列方向合并相邻矩形 | 列表滚动、文本更新 | 减少绘制次数,适合一维变化 |
| 四叉树合并 | 递归分割空间,合并相邻节点 | 复杂界面、大量控件 | 合并精度高,但实现复杂 |
| 贪心合并 | 每次合并面积增加最小的两个矩形 | 通用场景 | 平衡性好,适合大多数嵌入式GUI |
我个人最常用的是贪心合并。为什么?因为它简单、稳定,而且大多数嵌入式场景下,脏矩形的数量不会太多——一般也就3到5个。贪心合并在这类场景下表现非常稳定。
贪心合并的伪代码大概长这样:
// 贪心合并脏矩形列表
function greedyMerge(dirtyRects):
while dirtyRects.length > 1:
bestPair = null
minAreaIncrease = INF
for i in 0..dirtyRects.length-1:
for j in i+1..dirtyRects.length-1:
merged = union(dirtyRects[i], dirtyRects[j])
increase = area(merged) - area(dirtyRects[i]) - area(dirtyRects[j])
if increase < minAreaIncrease:
minAreaIncrease = increase
bestPair = (i, j)
if bestPair == null: break
(i, j) = bestPair
merged = union(dirtyRects[i], dirtyRects[j])
dirtyRects.remove(j)
dirtyRects.remove(i)
dirtyRects.push(merged)
return dirtyRects
注意:合并策略不是越复杂越好。在低端MCU上,四叉树合并的计算开销可能比全屏刷新还大。我见过一个团队在Cortex-M0上跑四叉树合并,结果CPU占用率直接飙到80%。后来换成简单包围盒,反而更流畅。
选策略之前,先算算你的CPU余量。
3.3 碰撞检测优化
脏矩形算法里,碰撞检测是用来判断「这个脏区跟那个脏区有没有重叠」的。听起来简单,但做不好会出大问题。
最基本的碰撞检测是矩形相交判断:
// 判断两个矩形是否相交
bool rectIntersect(Rect a, Rect b) {
return !(a.x + a.w <= b.x ||
b.x + b.w <= a.x ||
a.y + a.h <= b.y ||
b.y + b.h <= a.y);
}
这个判断本身很快,但问题在于——当界面有几十个控件时,每个控件都要跟其他控件做碰撞检测,复杂度是O(n²)。
优化思路主要有三个方向:
- 空间分区——把屏幕划分成网格,每个网格只检测网格内的控件。比如把320×240的屏分成8×6的网格,每个格子40×40像素。控件只跟同网格或相邻网格的控件做碰撞检测。
- 排序剪枝——按x坐标或y坐标排序,一旦发现某个矩形完全在另一个矩形的左侧/右侧/上方/下方,直接跳过后续检测。
- 增量更新——只检测发生变化的控件,不动的控件直接跳过。这个优化在大多数场景下能减少80%以上的检测量。
我曾经在一个医疗设备项目里遇到过碰撞检测的坑。设备界面有30多个控件,每次触摸操作都要做全量碰撞检测,结果触摸响应延迟了100多毫秒。后来加了空间分区,把屏幕分成5×4的网格,碰撞检测量从900次降到平均40次,触摸响应直接降到20毫秒以内。
避坑指南:空间分区的网格大小怎么定?我一般按「最小控件尺寸的2倍」来设。比如最小按钮是32×32,网格就设64×64。太小了网格太多,管理开销大;太大了分区效果不明显。这个经验值是我调了七八个项目才总结出来的。
下面这张图展示了脏矩形算法的完整流程:
最后说一个容易被忽略的点:脏矩形的生命周期管理。每次重绘完成后,一定要及时清除脏矩形标记。我见过有人忘了清除,结果同一个区域被反复重绘,CPU白白浪费。嗯,这种bug最难查,因为它不会报错,只会让系统慢慢变卡。
总结一下脏矩形算法的三个关键:
- 标记要准——脏区范围宁大勿小,但别大太多
- 合并要巧——根据场景选策略,别盲目追求复杂
- 碰撞要快——空间分区是性价比最高的优化手段
把这些做好了,你的嵌入式GUI性能至少能翻一倍。不信你试试。