
1. 脏矩形机制到底在解决什么问题第一次接触 LVGL 的刷新机制时很多人会有一个疑问屏幕上那么多像素每次界面变化难道都要全部重绘一遍如果真是这样那在 STM32 这类主频只有一两百兆的 MCU 上刷个屏估计要卡成幻灯片。实际上 LVGL 用了一套非常聪明的办法——脏矩形Dirty Rectangle刷新机制只重绘真正发生变化的那一小块区域其余部分原封不动。这个机制的核心思想可以用一个生活场景来类比。假设你家里客厅墙上挂了一幅巨大的拼图某天你发现其中三块拼错了需要替换。正常人的做法是把整幅拼图拆下来重新拼一遍吗显然不是你只会把那三块抠出来换掉其他部分碰都不碰。LVGL 的脏矩形就是这个道理——它把屏幕划分成若干区域当某个控件状态改变时只把受影响的矩形区域标记为“脏”下一帧刷新时只处理这些脏区域。为什么这个机制如此重要因为在嵌入式设备上渲染性能直接决定用户体验。一块 320x240 的屏幕全屏刷新一次需要处理 76800 个像素点而如果只是某个按钮被按下可能只需要刷新 60x30 的矩形区域像素量直接降到 1800 个相差 40 多倍。这个差距在低端 MCU 上就是“流畅”和“卡顿”的分水岭。脏矩形机制主要解决三个层面的问题。第一是降低 CPU 负载减少不必要的像素计算和内存拷贝第二是降低总线带宽占用特别是在 SPI 接口的屏幕上数据传输量直接决定刷新速度第三是降低功耗对于电池供电的设备来说少刷一个像素就少消耗一点电量。适合阅读这篇内容的人包括正在学习 LVGL 的嵌入式开发者、遇到界面卡顿需要优化性能的工程师、准备在 STM32 或 ESP32 上移植 LVGL 的同学以及想深入理解 GUI 框架底层渲染原理的技术爱好者。不管你用的是 LVGL 8.x 还是 9.x脏矩形的核心逻辑是一致的只是 API 层面有些差异我会在文中标注清楚。2. 脏矩形的数据结构与核心原理拆解2.1 脏矩形在 LVGL 内部是怎么表示的LVGL 内部用一个lv_area_t结构体来表示矩形区域它包含四个成员x1、y1、x2、y2分别代表矩形左上角和右下角的坐标。注意这里的坐标是闭区间也就是说x2和y2对应的像素是包含在区域内的。这一点和很多图形库用开区间表示的习惯不同写自定义控件时如果搞错了会导致边缘少刷一个像素。typedef struct { lv_coord_t x1; lv_coord_t y1; lv_coord_t x2; lv_coord_t y2; } lv_area_t;在 LVGL 的显示刷新上下文lv_disp_t9.x 中改为lv_display_t中维护了一个脏矩形数组。这个数组的大小由LV_INDEV_DEF_READ_PERIOD无关而是由刷新缓冲区的配置决定。默认情况下LVGL 会维护一定数量的脏矩形当脏矩形数量超过上限时会触发合并操作。脏矩形的产生来源主要有这么几个控件调用了lv_obj_invalidate()或其变体、控件的位置或大小发生了变化、样式属性被修改、屏幕发生了滚动或动画。每次调用lv_obj_invalidate_area()LVGL 就会把对应的区域加入到脏矩形列表中。2.2 脏矩形的合并策略与算法逻辑脏矩形最核心的难点不在于“标记”而在于“合并”。如果每次控件变化都往列表里塞一个新矩形那一个复杂界面动一下可能产生几十个脏矩形最后刷新时反而比全屏还慢。所以 LVGL 有一套合并逻辑我把它拆成几个关键步骤来讲。第一步是判断新脏矩形是否与已有脏矩形相交。如果相交就把两个矩形合并成一个更大的矩形这个更大的矩形是能同时包含两者的最小矩形。这里用的是轴对齐包围盒AABB的思路合并后的矩形面积可能比原来两个矩形面积之和大但减少了矩形数量整体上更划算。第二步是判断新脏矩形是否被已有脏矩形完全包含。如果是直接丢弃新的因为已有区域已经覆盖了它。反过来如果新矩形完全包含了某个已有矩形就把那个旧矩形删掉。第三步是数量上限控制。LVGL 内部有一个LV_INV_BUF_SIZE宏默认值通常是 32当脏矩形数量达到这个上限时LVGL 会采取更激进的合并策略甚至可能退化为全屏刷新。这个设计是为了防止极端情况下脏矩形列表无限膨胀。注意LV_INV_BUF_SIZE可以在lv_conf.h中调整。如果你做的界面控件非常多、动画很复杂可以适当调大这个值但代价是占用更多 RAM。在 STM32F103 这种 RAM 只有 20KB 的芯片上建议保持默认甚至调小。2.3 刷新缓冲区与脏矩形的配合关系脏矩形决定了“刷哪里”而刷新缓冲区决定了“怎么刷”。LVGL 支持三种缓冲模式单缓冲、双缓冲和全屏缓冲。这三种模式对脏矩形的处理方式有细微差别。单缓冲模式下LVGL 把脏矩形区域的渲染结果写入缓冲区然后通过flush_cb回调把缓冲区数据发送到屏幕。由于缓冲区可能比脏矩形区域小所以一个脏矩形可能需要分多次传输。双缓冲模式下有两个缓冲区交替使用渲染和传输可以并行效率更高。全屏缓冲就是缓冲区大小等于屏幕大小一次渲染完整个脏矩形区域再统一发送。这里有个关键点脏矩形区域的大小和缓冲区大小的关系直接影响刷新效率。如果脏矩形区域远大于缓冲区LVGL 需要把脏矩形切分成多个小块逐个渲染传输这会增加开销。所以在实际项目中合理设置缓冲区大小很重要。一般来说缓冲区设为屏幕大小的 1/10 到 1/4 是比较常见的做法。3. 从源码角度看脏矩形的完整生命周期3.1 标记阶段invalidate 系列函数做了什么当你在代码里调用lv_obj_invalidate(obj)时LVGL 内部实际执行的操作比想象中复杂。这个函数首先会检查对象是否可见、是否在屏幕内然后获取对象的坐标区域最后调用_lv_inv_area()把这个区域加入到脏矩形列表。void lv_obj_invalidate(const lv_obj_t * obj) { if(lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) return; if(lv_obj_get_parent(obj) NULL) return; lv_area_t obj_area; lv_obj_get_coords(obj, obj_area); /* 考虑样式中的阴影、轮廓等超出边界的部分 */ lv_area_t ext_area; lv_obj_get_ext_draw_size(obj, ext_area); lv_area_union(obj_area, obj_area, ext_area); _lv_inv_area(lv_obj_get_disp(obj), obj_area); }注意这里有一个容易被忽略的细节扩展绘制区域。很多控件有阴影、发光、圆角等效果这些效果的绘制范围会超出控件本身的坐标区域。如果只标记控件本身的区域阴影部分就不会被刷新导致画面上出现残影。LVGL 通过lv_obj_get_ext_draw_size()获取扩展尺寸把这块也纳入脏矩形。_lv_inv_area()是真正操作脏矩形列表的函数。它会遍历当前的脏矩形数组尝试合并如果合并失败且数组未满就追加新条目如果数组已满就触发全屏刷新。这个函数的逻辑虽然不复杂但它是整个刷新机制的心脏。3.2 合并阶段脏矩形列表的维护细节脏矩形列表的合并过程可以用一个具体的例子来说明。假设屏幕上先后产生了三个脏矩形A(10,10,50,50)、B(40,40,80,80)、C(100,100,120,120)。处理 A 时列表为空直接加入列表变为 [A]。处理 B 时发现 B 与 A 相交因为 4050 且 4050于是合并成 D(10,10,80,80)列表变为 [D]。处理 C 时C 与 D 不相交直接加入列表变为 [D, C]。最终只需要刷新两个矩形区域。但如果 C 是 (70,70,90,90)它和 D(10,10,80,80) 相交合并后变成 (10,10,90,90)列表又变回一个矩形。可以看到合并策略的效果和脏矩形的产生顺序、位置分布密切相关。实操心得如果你的界面上有多个独立的小控件频繁变化比如一排 LED 指示灯它们各自产生脏矩形且互不相交这时候脏矩形列表会保持多个条目。如果数量接近上限LVGL 可能会触发全屏刷新反而降低性能。这种情况下可以考虑把这些小控件放在一个容器里统一 invalidate 容器让它们合并成一个大矩形。3.3 渲染阶段脏矩形如何驱动实际绘制到了刷新阶段LVGL 的主循环会调用_lv_disp_refr_timer()这个函数遍历脏矩形列表对每个脏矩形执行渲染。渲染过程大致分为这几步先调用draw_rect相关的函数把背景画上然后遍历该区域内所有需要重绘的对象按层级从低到高依次绘制。这里有一个优化点值得注意LVGL 在渲染脏矩形时会做裁剪clipping。也就是说即使一个对象很大但只有一部分落在脏矩形内LVGL 也只会绘制落在脏矩形内的那部分。这个裁剪操作是在绘制函数内部通过设置裁剪区域实现的能进一步减少实际绘制的像素量。渲染完成后LVGL 调用你注册的flush_cb回调把缓冲区数据发送到显示屏。在flush_cb中你需要根据传入的area参数确定数据要写到屏幕的哪个位置。这个area就是当前正在刷新的脏矩形区域。void my_flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p) { /* 设置屏幕的刷新窗口为 area 指定的区域 */ set_display_window(area-x1, area-y1, area-x2, area-y2); /* 把 color_p 中的数据写入屏幕 */ send_pixels_to_display((uint16_t *)color_p, lv_area_get_size(area)); /* 必须调用通知 LVGL 刷新完成 */ lv_disp_flush_ready(disp_drv); }3.4 清除阶段脏矩形列表的重置时机一帧刷新完成后LVGL 会清空脏矩形列表等待下一轮的标记。但这里有个时序问题如果在刷新过程中又有新的 invalidate 调用这些新的脏矩形会被记录到下一帧处理。LVGL 通过一个标志位来区分当前帧和下一帧的脏矩形确保不会丢失刷新请求。在 LVGL 9.x 中这部分逻辑有一些调整引入了lv_display_refr_timer和更精细的刷新控制。但核心思路没变标记、合并、渲染、清除四个阶段循环往复。4. 实际项目中的脏矩形优化实战4.1 缓冲区大小与脏矩形刷新效率的平衡在实际项目中缓冲区大小的选择直接影响脏矩形的刷新效率。我拿 STM32F407 驱动 ILI9341 屏幕320x240做过一组对比测试结果如下缓冲区大小占屏幕比例平均刷新时间RAM 占用320x104.2%8.5ms6.4KB320x4016.7%5.2ms25.6KB320x8033.3%4.1ms51.2KB320x240100%3.8ms153.6KB从数据可以看出缓冲区从 10 行增加到 40 行刷新时间下降了近 40%但继续增加到全屏收益就很小了。这是因为脏矩形区域通常不会太大缓冲区达到一定大小后大部分脏矩形都能一次装下不需要分块传输。实操心得在 RAM 有限的 MCU 上缓冲区设为屏幕高度的 1/8 到 1/4 是比较甜的点。以 320x240 屏幕为例320x30 或 320x60 的缓冲区大小既能保证大部分脏矩形一次刷完又不会占用太多 RAM。另外记得把缓冲区定义为全局数组或使用 DMA 可访问的内存区域否则 DMA 传输会出问题。4.2 控件布局对脏矩形合并的影响控件的布局方式会显著影响脏矩形的合并效果。我做过一个实验在屏幕上放 10 个按钮排成一行每个按钮 60x30 像素间距 5 像素。当依次点击这些按钮时观察脏矩形的产生情况。如果按钮直接放在屏幕上每次点击产生一个 60x30 的脏矩形由于按钮之间有间距脏矩形不相交列表里会积累多个条目。但如果把这 10 个按钮放在一个容器里点击按钮时同时 invalidate 容器脏矩形就变成了整个容器的区域虽然面积大了但只有一个条目合并和管理的开销更小。这个实验说明一个道理脏矩形的优化不只是减少面积还要考虑数量。在某些场景下适当增大单个脏矩形的面积来减少数量反而能提升整体性能。特别是当脏矩形数量接近LV_INV_BUF_SIZE上限时合并成大矩形比触发全屏刷新要划算得多。4.3 动画场景下的脏矩形行为分析动画是脏矩形机制面临的最大挑战。一个旋转的指针、一个滑动的列表每帧都会产生新的脏矩形。如果处理不当动画会变得非常卡顿。以圆弧进度条为例当进度值变化时LVGL 需要重绘圆弧的一部分。如果每次只 invalidate 变化的那一小段弧线脏矩形会非常碎合并开销大。LVGL 的实际做法是 invalidate 整个圆弧控件的区域虽然面积大了但保证了脏矩形的完整性。对于列表滚动这种场景脏矩形的处理更复杂。滚动时列表内容整体位移理论上只需要刷新新进入视野的部分和移出视野的部分。但 LVGL 目前的实现是 invalidate 整个列表区域然后重新绘制。这在列表项不多时没问题但如果列表很长性能就会下降。注意如果你在做长列表滚动可以考虑用 LVGL 的lv_obj_scroll_to_view()配合局部刷新或者自己实现分页加载减少单次刷新的区域。另外LVGL 9.x 对滚动刷新做了一些优化如果项目允许升级到 9.x 会有改善。4.4 自定义控件中的脏矩形处理技巧写自定义控件时脏矩形的处理是最容易出错的地方。我踩过的一个坑是自定义了一个带阴影的卡片控件只 invalidate 了卡片本身的区域结果阴影部分在状态变化时出现残影。后来在LV_EVENT_DRAW_MAIN回调里用lv_obj_get_ext_draw_size()获取扩展区域把阴影也纳入脏矩形问题才解决。另一个坑是局部刷新与全局刷新的冲突。有些开发者为了优化性能在自定义控件里手动调用lv_obj_invalidate_area()只刷新变化的部分。但如果这个控件同时参与了动画或样式过渡局部刷新可能导致画面撕裂。我的建议是除非你非常清楚控件的绘制逻辑否则优先使用lv_obj_invalidate()让 LVGL 自己决定刷新区域。5. 常见问题排查与性能调优速查5.1 画面残影与撕裂问题的排查思路残影是脏矩形机制最常见的症状表现为控件移动或消失后原来的位置还残留着旧图像。排查残影问题我通常按这个顺序检查首先确认flush_cb中是否正确设置了刷新窗口。如果窗口设置错误数据会写到错误的位置导致残影。其次检查控件的扩展绘制区域是否被正确 invalidate特别是带阴影、圆角、边框的控件。然后确认LV_INV_BUF_SIZE是否够用如果脏矩形数量经常触顶会触发全屏刷新理论上不会残影但性能会下降。撕裂问题通常和缓冲区模式有关。单缓冲模式下如果flush_cb是阻塞式的渲染和传输不能并行可能出现撕裂。改用双缓冲模式并确保flush_cb中使用 DMA 传输可以缓解这个问题。5.2 刷新率上不去的几个典型原因刷新率低是另一个高频问题。我整理了一个排查表按可能性从高到低排列可能原因排查方法解决方向缓冲区太小查看脏矩形是否被分块传输增大缓冲区到屏幕 1/8 以上SPI 时钟太低测量实际 SPI 时钟频率提高到屏幕支持的上限flush_cb 阻塞检查是否用了 DMA改用 DMA 传输脏矩形过多打印脏矩形数量优化布局减少独立控件绘制函数太慢用 GPIO 翻转测绘制耗时简化样式减少渐变和阴影全屏刷新频繁检查是否触发了全屏 invalidate排查代码中的全屏刷新调用实操心得用 GPIO 翻转配合示波器测量刷新时间是最直接的方法。在flush_cb开头拉高一个 GPIO在lv_disp_flush_ready()前拉低示波器上就能看到每次刷新的实际耗时。如果发现某次刷新特别长大概率是触发了全屏刷新可以重点排查那次刷新前后的代码逻辑。5.3 LVGL 8.x 与 9.x 在脏矩形上的差异LVGL 9.x 对显示刷新架构做了较大重构脏矩形的处理也有一些变化。主要差异体现在这几个方面9.x 引入了lv_display_t替代 8.x 的lv_disp_tAPI 名称有变化但功能对应。9.x 的刷新定时器逻辑更精细支持按显示设备独立配置刷新周期。9.x 对脏矩形的合并算法做了优化在高密度脏矩形场景下性能更好。另外 9.x 新增了lv_display_set_flush_wait_cb()等回调对 DMA 传输的同步控制更灵活。如果你正在用 8.x 且项目稳定没必要为了脏矩形优化专门升级。但如果是新项目建议直接上 9.x刷新相关的 API 设计更合理文档也更完善。5.4 高频踩坑点与避坑清单最后整理一份我在实际项目中踩过的坑和对应的避坑建议忘记调用lv_disp_flush_ready()这是最致命的错误LVGL 会一直等待刷新完成整个界面卡死。每次写完flush_cb都要检查这一句。在中断中调用 invalidateLVGL 的 invalidate 不是线程安全的在中断中调用可能导致脏矩形列表损坏。如果需要在中断中触发刷新用lv_async_call()或设置标志位在主循环中处理。缓冲区没有对齐DMA 传输通常要求缓冲区地址对齐如果缓冲区定义在奇数地址DMA 可能传输错误数据。用__attribute__((aligned(4)))确保对齐。脏矩形区域超出屏幕自定义控件的坐标计算错误可能导致脏矩形超出屏幕范围LVGL 虽然会做裁剪但可能引发断言失败。在 invalidate 前用lv_area_intersect()和屏幕区域求交集。频繁全屏 invalidate有些操作会隐式触发全屏刷新比如修改屏幕背景色、切换主题。如果发现刷新率突然下降优先排查这类操作。脏矩形机制看起来只是“只刷变化的部分”这么简单一句话但真正用好它需要对 LVGL 的渲染流程、缓冲区管理、控件生命周期都有深入理解。我在实际项目中的体会是大部分性能问题不是脏矩形本身的问题而是控件布局和刷新策略的问题。把界面结构设计得合理一些让脏矩形自然合并比事后各种优化都有效。