脏矩形机制:如何高效计算并合并需要重绘的区域

各位同学,今天我们来聊聊 LVGL 里一个非常核心的优化手段——脏矩形机制。

说白了,就是屏幕这么大,每次更新 UI 时,我们真的需要把整个屏幕都重新画一遍吗?

当然不是。那样太浪费性能了。尤其是嵌入式设备,CPU 和内存都金贵得很。脏矩形机制要解决的问题就是:只重绘那些真正发生变化的小区域,然后把它们高效地合并起来,一次性刷新。

我在做第一个 LVGL 移植项目时,就踩过这个坑。当时没理解脏矩形合并的逻辑,结果屏幕闪烁得厉害,帧率直接掉到 15fps。后来仔细啃了一遍源码,才明白问题出在哪。

什么是脏矩形?

脏矩形,英文叫 dirty area 或 invalid area。你可以把它理解成「屏幕上需要重新绘制的矩形区域」。

举个例子:你点击了一个按钮,按钮的颜色变了。这个变化只发生在按钮那一小块区域。LVGL 不会傻到把整个屏幕都重绘,它只会把这个按钮所在的矩形标记为「脏」,然后在下一次刷新周期里,只重绘这个矩形。

核心思想:每次 UI 更新,只重绘发生变化的矩形区域,而不是全屏刷新。

嗯,这里要注意:脏矩形不一定是单个像素,而是一个矩形块。因为 LVGL 的渲染引擎是基于矩形裁剪的,用矩形来描述脏区域,计算起来最方便。

脏矩形的生命周期

一个脏矩形从诞生到被清除,大致经历这么几个阶段:

  1. 标记脏区域——当某个控件属性变化时(比如位置、大小、可见性、样式),LVGL 会调用 lv_obj_invalidate() 把这个控件所在的区域标记为脏。
  2. 收集脏矩形——所有被标记的脏矩形会被收集到一个全局列表中。
  3. 合并脏矩形——在刷新前,LVGL 会对列表中的脏矩形进行合并,减少重绘次数。
  4. 执行重绘——渲染引擎遍历合并后的脏矩形列表,逐个裁剪并绘制。
  5. 清除脏矩形——重绘完成后,清空列表,等待下一轮。

你看,整个过程其实就是一个「标记-收集-合并-绘制-清除」的循环。

脏矩形合并算法

合并脏矩形,是脏矩形机制里最讲究的部分。为什么需要合并?

假设你同时移动了两个控件,它们的位置不重叠,但离得很近。如果不合并,渲染引擎就要分别裁剪两次、绘制两次。如果合并成一个大的矩形,虽然多画了几个像素,但减少了裁剪和绘制的切换开销。在嵌入式设备上,减少绘制次数往往比减少绘制面积更重要。

LVGL 的合并策略是这样的:

合并条件 说明
矩形 A 完全包含矩形 B 直接丢弃 B,保留 A
矩形 A 与矩形 B 相交 计算并集,生成更大的矩形
矩形 A 与矩形 B 不相交但距离很近 如果合并后的面积增加不超过阈值,则合并
矩形 A 与矩形 B 相距很远 不合并,保留两个独立脏矩形

我个人习惯把这种策略叫做「贪心合并」。它不追求全局最优解,而是每次取两个矩形,能合并就合并,不能合并就留着。这样做的好处是算法简单、速度快,适合实时系统。

小技巧:如果你发现 UI 闪烁严重,可以检查一下脏矩形合并的阈值。在 lv_conf.h 里有个 LV_DPI_DEF 和 LV_INV_BUF_SIZE 的配置,适当调大合并阈值,可以减少重绘次数。

脏矩形在 LVGL 中的数据结构

LVGL 内部用 lv_area_t 来表示一个矩形区域。它的定义很简单:

typedef struct {
    lv_coord_t x1;
    lv_coord_t y1;
    lv_coord_t x2;
    lv_coord_t y2;
} lv_area_t;

四个坐标,左上角 (x1, y1) 和右下角 (x2, y2)。所有脏矩形都存储在一个数组中,数组大小由 LV_INV_BUF_SIZE 决定。默认是 32 个,如果脏矩形太多,超出缓冲区,LVGL 会直接标记全屏刷新——这是一种兜底策略。

避坑指南:我曾经在一个项目里,因为控件嵌套层级太深,导致一次动画触发了上百个脏矩形。缓冲区爆了,结果每次都是全屏刷新,帧率直接崩了。后来我优化了控件结构,减少了无效的 invalidate 调用,才解决问题。

脏矩形合并的代码逻辑

我们来看看 LVGL 源码里脏矩形合并的核心函数。它位于 lv_refr.c 中,函数名叫 lv_refr_join_area()。

简化后的逻辑大致如下:

static void lv_refr_join_area(void) {
    uint32_t i, j;
    for (i = 0; i < inv_area_cnt; i++) {
        for (j = i + 1; j < inv_area_cnt; j++) {
            lv_area_t *a = &inv_areas[i];
            lv_area_t *b = &inv_areas[j];
            
            // 判断两个矩形是否可以合并
            if (lv_area_is_on(&a, &b) || lv_area_is_near(&a, &b)) {
                // 计算并集
                lv_area_union(&a, a, b);
                // 移除被合并的矩形
                inv_area_cnt--;
                // 重新开始检查
                i = 0;
                break;
            }
        }
    }
}

你看,代码并不复杂。两层循环,暴力检查所有矩形对。一旦发现可以合并,就计算并集,然后移除被合并的矩形,重新开始扫描。

为什么用暴力法?因为脏矩形的数量通常很少(一般不超过 10 个),暴力法反而比复杂的空间索引更高效。

脏矩形与裁剪的关系

脏矩形确定之后,渲染引擎会用它来设置裁剪区域。LVGL 的绘制函数在画任何东西之前,都会先检查当前像素是否在脏矩形范围内。如果不在,直接跳过。

这就引出了一个重要的概念:脏矩形是裁剪的输入。没有脏矩形,裁剪就不知道要裁哪里;没有裁剪,脏矩形就只是一块空区域。

我画了一张图,帮你理清脏矩形从产生到消费的完整流程:

脏矩形生命周期与合并流程 控件状态变化 标记脏区域 收集脏矩形 合并脏矩形 设置裁剪区域 执行重绘 清除脏矩形 合并策略说明: 1. 完全包含 → 丢弃小矩形,保留大矩形 2. 相交 → 计算并集,生成更大矩形 3. 距离很近 → 面积增加不超过阈值则合并 4. 相距很远 → 不合并,保留独立脏矩形

实际项目中的优化建议

基于我自己的经验,给你几个实际可用的优化建议:

  • 减少无效的 invalidate 调用——不是每次属性变化都需要标记脏区域。比如你连续修改一个控件的多个样式属性,可以等所有修改完成后再调用一次 lv_obj_invalidate()。
  • 合理设置脏矩形缓冲区大小——如果你的 UI 动画复杂,脏矩形数量多,可以适当调大 LV_INV_BUF_SIZE。默认 32 不够就改成 64 或 128。
  • 避免全屏刷新——一旦脏矩形数量超过缓冲区大小,LVGL 会回退到全屏刷新。这是最耗性能的情况。尽量控制脏矩形的数量。
  • 利用脏矩形调试——LVGL 提供了 lv_draw_mask 相关的调试接口,可以可视化地看到当前哪些区域被标记为脏。这在排查闪烁问题时非常有用。

个人经验:我在一个智能手表项目里,用脏矩形机制把帧率从 20fps 提升到了 55fps。关键就是合并策略的阈值调优。当时我把合并阈值从默认的 10 像素改成了 30 像素,结果重绘次数减少了 40%。

总结

脏矩形机制是 LVGL 图形渲染性能的基石。它通过「只重绘变化区域」和「高效合并相邻区域」两个手段,大幅减少了不必要的绘制开销。

理解了这个机制,你就能解释为什么 LVGL 在低端 MCU 上也能跑得流畅。说白了,就是每一帧都在做减法——减掉不需要重绘的像素,减掉不必要的绘制调用。