3. 性能指标体系

做嵌入式GUI,最怕什么?

最怕整个屏幕从头刷到尾。你想想看,一个320×240的屏,全屏刷新一次可能要十几毫秒,要是再加上透明叠加、字体渲染,帧率直接掉到个位数。我早年做一款手持设备,就因为全屏刷新太频繁,电池续航硬生生少了两个小时。

脏矩形算法,说白了就是「哪里变了刷哪里」。这招不是新东西,但在资源受限的嵌入式系统里,它几乎是GUI性能的命门。今天咱们就把这块掰开揉碎了讲。

3.1 局部刷新原理

脏矩形的核心思想很简单:只更新屏幕上发生变化的那一小块区域。每次用户操作、数据更新、动画切换,系统都会标记一个「脏矩形」——也就是需要重绘的区域。

核心公式:

刷新耗时 = 脏矩形面积 × 单位像素绘制耗时

全屏刷新面积 = 屏幕宽 × 屏幕高

局部刷新面积 = 脏矩形宽 × 脏矩形高

性能提升比例 ≈ 全屏面积 / 脏矩形面积

举个例子。一个320×240的屏,全屏面积是76800像素。如果只更新一个64×64的按钮,面积只有4096像素。性能提升接近19倍。嗯,这就是脏矩形的魅力。

我在项目中遇到过一种情况:明明只改了一个数字,整个仪表盘都在闪。后来一查,是底层驱动没做脏矩形判断,每次数值变化都触发了全屏刷新。加了个简单的脏矩形裁剪,帧率从12fps直接跳到45fps。

脏矩形的生命周期一般分三步:

  1. 标记脏区——当控件状态变化时,计算并记录需要重绘的区域
  2. 合并脏区——多个脏区合并成更少的矩形,减少绘制次数
  3. 执行重绘——只对最终脏矩形区域进行绘制和刷新

我的习惯:在标记脏区时,多留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。太小了网格太多,管理开销大;太大了分区效果不明显。这个经验值是我调了七八个项目才总结出来的。

下面这张图展示了脏矩形算法的完整流程:

脏矩形算法核心流程 用户操作 / 数据更新 步骤1:标记脏矩形区域 步骤2:矩形合并策略(贪心/包围盒) 步骤3:碰撞检测优化(空间分区) 步骤4:执行局部重绘 关键:合并策略和碰撞检测直接影响重绘效率,选对了性能翻倍,选错了可能比全屏刷新还慢

最后说一个容易被忽略的点:脏矩形的生命周期管理。每次重绘完成后,一定要及时清除脏矩形标记。我见过有人忘了清除,结果同一个区域被反复重绘,CPU白白浪费。嗯,这种bug最难查,因为它不会报错,只会让系统慢慢变卡。

总结一下脏矩形算法的三个关键:

  • 标记要准——脏区范围宁大勿小,但别大太多
  • 合并要巧——根据场景选策略,别盲目追求复杂
  • 碰撞要快——空间分区是性价比最高的优化手段

把这些做好了,你的嵌入式GUI性能至少能翻一倍。不信你试试。