
1. 脏矩形机制到底在解决什么问题1.1 从一次屏幕闪烁说起刚接触 LVGL 那会儿我在一块 STM32F407 加 ILI9341 的板子上跑一个带滚动列表的界面。列表一滑动整屏就开始肉眼可见地撕裂帧率掉到个位数。当时第一反应是 SPI 刷屏太慢于是把 SPI 时钟从 20MHz 拉到 42MHz撕裂感稍微好了一点但帧率还是上不去。后来用逻辑分析仪抓了一下刷屏数据量才发现问题根本不在总线速度——每滑动一帧LVGL 都在把整个 320x240 的屏幕重新刷一遍哪怕屏幕上真正变化的只有中间那一小块列表区域。这就是脏矩形机制要解决的核心问题只刷新屏幕上真正发生变化的那部分区域而不是无脑全屏重绘。在嵌入式 GUI 里显存带宽和 CPU 算力都是稀缺资源。一块 320x240 的 16 位色屏幕全屏刷新一次要搬运 153600 字节如果只刷新一个 200x40 的列表项区域数据量直接降到 16000 字节差了将近十倍。对于用 SPI 接口的屏幕这个差距直接决定了界面是能用还是卡成幻灯片。1.2 脏矩形的本质一块待刷新区域的账本脏矩形Dirty Rectangle这个词听起来唬人其实逻辑非常朴素。你可以把它想象成装修时贴的便利贴墙上哪里需要重新刷漆就在哪里贴一张便利贴最后工人只刷贴了便利贴的地方。LVGL 内部维护的就是这样一套便利贴系统。每当一个控件Widget的状态发生变化——比如按钮被按下、标签文字改了、列表滚动了——LVGL 不会立刻去刷屏而是把这个控件占据的屏幕区域标记为脏dirty记录到一个区域列表里。等到下一次刷新周期通常由定时器或lv_timer_handler驱动LVGL 把所有脏区域合并、裁剪算出最终需要重绘的最小区域集合然后只对这些区域执行渲染和刷屏。这里有个关键点容易被忽略脏矩形标记的是屏幕坐标下的区域不是控件本身。一个控件可能因为父容器滚动而移动到屏幕外也可能被其他控件遮挡这些情况都会影响最终脏区域的计算结果。理解这一点后面看合并和裁剪逻辑时就不会绕晕。1.3 谁需要搞懂这套机制如果你只是用 LVGL 拖拖控件、写写事件回调脏矩形机制对你来说是透明的不懂也能把界面做出来。但下面这几类场景绕不开它屏幕刷新明显卡顿需要判断瓶颈到底在渲染、在刷屏、还是在脏区域计算本身。自绘控件或自定义渲染用lv_draw_*系列 API 自己画东西时必须手动管理脏区域否则会出现残影或刷新不全。做局部刷新优化比如只更新仪表盘指针、只刷新时钟数字需要精确控制脏区域范围。移植到新平台对接flush_cb回调时要理解 LVGL 传给你的区域是怎么来的才能正确搬运像素。调试花屏、残影、撕裂这些问题十有八九和脏区域计算或刷屏时序有关。我见过不少项目界面卡了就一味加内存、换屏幕、提主频其实只要把脏矩形机制用对同样的硬件能跑出完全不同的流畅度。这套机制值得花时间吃透。2. 脏矩形的数据结构与核心原理2.1 lv_area_t一切的基础单元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是右下角且x2和y2是包含在区域内的。也就是说一个{0, 0, 9, 9}的区域实际覆盖 10x10 个像素不是 9x9。这个细节在计算区域面积和缓冲区大小时特别容易出错我自己就曾经因为按开区间算导致缓冲区少分配了一行像素刷出来最后一行永远是花的。LVGL 提供了一组操作lv_area_t的工具函数常用的有函数作用lv_area_set直接设置四个坐标lv_area_copy复制区域lv_area_get_width获取宽度x2-x11lv_area_get_height获取高度y2-y11lv_area_get_size获取像素总数lv_area_intersect求两个区域的交集lv_area_union求两个区域的并集lv_area_is_in判断区域是否被包含lv_area_is_on判断两区域是否有重叠这些函数是脏矩形合并与裁剪的积木理解它们的语义比死记实现更重要。尤其是lv_area_intersect它在裁剪阶段被调用得极其频繁返回值为空表示两区域不相交这个返回值一定要判断否则会算出负宽度的非法区域。2.2 脏区域列表不是单个矩形而是一组很多人以为脏矩形就是一个矩形其实 LVGL 维护的是一个区域数组。在lv_refr.c里可以看到类似这样的定义static lv_area_t inv_areas[LV_INV_BUF_SIZE]; static uint8_t inv_area_joined[LV_INV_BUF_SIZE]; static uint32_t inv_p;LV_INV_BUF_SIZE是编译期可配的宏默认值通常是 32。inv_areas就是脏区域列表inv_p是当前已记录的区域数量。每次调用lv_obj_invalidate或lv_area_invalidate都会尝试往这个列表里追加一个区域。为什么用数组而不是单个矩形因为屏幕上可能同时有多个互不相邻的区域发生变化。比如一个界面上方有个数字时钟在跳秒下方有个进度条在走这两个区域中间隔着一大片静态内容。如果强行用一个矩形把它们框起来中间那片静态区域也会被重绘白白浪费带宽。用区域列表就能分别记录各自独立刷新。提示LV_INV_BUF_SIZE不是越大越好。列表越大合并阶段的两两比较开销越大。默认 32 对大多数界面够用如果你的界面同时有大量小控件频繁变化可以适当调大但要配合实测帧率来决定。2.3 失效Invalidate与刷新Refresh的分离这是 LVGL 脏矩形机制里最精妙的设计之一标记失效和实际刷新是两个完全解耦的阶段。标记失效发生在控件状态改变的那一刻。比如你调用lv_label_set_textLVGL 内部会调用lv_obj_invalidate把标签的屏幕区域加进脏区域列表。但此时什么都不会画只是记账。实际刷新发生在lv_timer_handler被调用时或者你手动调用lv_refr_now。LVGL 会检查脏区域列表是否非空非空才进入刷新流程合并区域、裁剪区域、逐区域渲染、调用flush_cb刷屏。这种分离带来两个好处。第一一帧内多次修改同一个控件只会产生一次实际刷新避免重复劳动。第二刷新时机可控你可以在一个逻辑帧内把所有状态改完再统一刷新保证画面一致性。我早期写代码时犯过一个错在中断里直接调用lv_label_set_text结果界面偶尔花屏。原因就是中断里改了控件状态标记了脏区域但刷新流程在主循环里跑两者时序错乱。正确做法是在中断里只置个标志主循环里再改控件。2.4 区域合并把碎片拼成整块脏区域列表里的区域是零散追加的可能互相重叠、相邻、包含。如果直接逐个刷新重叠部分会被重复绘制相邻部分会产生多次小批量刷屏调用效率很低。所以刷新前必须先做区域合并。LVGL 的合并逻辑大致是这样的遍历脏区域列表对每一对区域判断关系。如果两个区域有重叠或相邻就把它们合并成一个更大的区域并标记被合并的那个为已加入。这个过程会反复迭代直到没有区域可以再合并为止。合并的判定条件里相邻也算可合并这一点值得说明。假设区域 A 是{0,0,9,9}区域 B 是{10,0,19,9}它们紧挨着但不重叠。合并成{0,0,19,9}后虽然多刷了中间那条边界但把两次刷屏调用合并成一次对于 SPI 这类有固定命令开销的总线来说往往更划算。LVGL 默认采用这种策略是权衡了调用开销和像素开销后的结果。不过合并也有代价。如果两个区域离得很远强行合并会框进大片无关区域。所以 LVGL 的合并是有条件的不会无脑把所有区域并成一个大矩形。具体阈值和策略在不同版本里略有差异9.x 版本对合并逻辑做了优化引入了更精细的判定。2.5 区域裁剪被遮挡的部分不该刷合并之后还要做裁剪。一个控件标记为脏不代表它整个区域都需要重绘——它可能被上层控件遮挡被父容器裁剪或者部分在屏幕外。裁剪的核心是计算脏区域与可见区域的交集。LVGL 在渲染每个控件时会维护一个裁剪区域clip area表示当前控件实际能画到的范围。脏区域和裁剪区域求交集得到的才是真正需要重绘的部分。举个例子一个按钮被一个半透明弹窗盖住了一半。按钮状态变化时标记的脏区域是整个按钮但实际只需要重绘没被弹窗盖住的那一半因为被盖住的部分在弹窗渲染时已经画过了。裁剪就是干这个的。屏幕边界裁剪同样重要。如果一个控件部分移出屏幕脏区域会超出屏幕坐标范围。如果不裁剪渲染时可能访问到非法内存或者刷屏时把越界数据发给屏幕驱动轻则花屏重则死机。LVGL 在lv_area_intersect里会处理屏幕边界但自己写自绘代码时一定要记得手动裁剪。3. 刷新流程的完整实操拆解3.1 一次刷新的完整调用链把上面这些概念串起来一次完整的刷新流程大致是这样的主循环调用lv_timer_handler。lv_timer_handler内部调用_lv_refr_now或类似入口。检查inv_p是否大于 0为 0 直接返回什么都不做。进入_lv_refr_areas对脏区域列表执行合并。对合并后的每个区域调用_lv_refr_area。_lv_refr_area内部根据区域大小决定用单缓冲区还是多缓冲区渲染。渲染时遍历该区域覆盖的所有控件从底层到顶层依次绘制。每个控件绘制前计算裁剪区域只画交集部分。渲染完成后调用disp-driver-flush_cb把像素数据交给屏幕驱动。清空脏区域列表inv_p归零。这条链路里第 6 步的缓冲区策略和第 7 步的控件遍历顺序是两个容易出问题的地方下面分别展开。3.2 缓冲区策略单缓冲、双缓冲与全屏缓冲LVGL 渲染时需要一块内存作为画布渲染结果先写进这块内存再通过flush_cb搬到屏幕上。这块内存的大小和数量直接决定了脏矩形机制能发挥多大威力。单缓冲区只有一块缓冲区大小可以小于屏幕。渲染一个脏区域写满缓冲区刷屏再渲染下一个脏区域。优点是省内存缺点是无法在刷屏的同时渲染下一块CPU 和总线串行工作。双缓冲区两块缓冲区一块在刷屏时另一块可以继续渲染下一区域。理论上能提升吞吐但需要屏幕驱动支持异步刷屏比如 DMA。如果flush_cb是阻塞的双缓冲意义不大。全屏缓冲区缓冲区大小等于整个屏幕。这种配置下LVGL 会把所有脏区域渲染到全屏缓冲区最后一次性刷屏。优点是刷屏调用次数少缺点是内存占用大一块 320x240 的 16 位色全屏缓冲要 150KB很多 MCU 根本放不下。缓冲区大小在lv_conf.h里通过LV_DISP_DEF_REFR_PERIOD和显示驱动的buffer_size配置。我的经验是缓冲区高度至少覆盖一个脏区域的高度否则一个脏区域会被拆成多次渲染刷屏反而增加开销。对于列表滚动这类纵向变化为主的界面缓冲区做成全宽 若干行高的条带状性价比最高。/* 典型的条带状缓冲区配置宽度全屏高度 40 行 */ #define MY_DISP_HOR_RES 320 #define MY_DISP_VER_RES 240 static lv_color_t buf_1[MY_DISP_HOR_RES * 40]; static lv_color_t buf_2[MY_DISP_HOR_RES * 40]; static lv_disp_draw_buf_t draw_buf; lv_disp_draw_buf_init(draw_buf, buf_1, buf_2, MY_DISP_HOR_RES * 40);3.3 控件遍历顺序与遮挡关系渲染一个脏区域时LVGL 需要知道这个区域里有哪些控件、按什么顺序画。答案是从最底层到最顶层依次绘制后画的覆盖先画的。控件树的遍历顺序由父子关系和同级控件的创建顺序决定。父控件先于子控件绘制同级控件按创建顺序绘制后创建的在上层。这个顺序决定了遮挡关系也决定了脏区域裁剪时谁裁谁。这里有个实操中常踩的坑动态改变控件层级。如果你在运行时调用lv_obj_move_foreground把某个控件提到最前它的绘制顺序变了但之前标记的脏区域可能还是按旧顺序算的。正确做法是改变层级后手动调用lv_obj_invalidate把相关区域全部标记为脏强制重绘。另一个坑是透明控件。LVGL 里有些控件是部分透明的比如带透明背景的容器渲染时不能简单覆盖需要先画下层内容再叠加。脏区域计算时透明控件的脏区域要向下扩展到它覆盖的所有下层控件否则会出现下层没重绘、透明叠加后颜色不对的问题。9.x 版本对透明和混合模式的处理比 8.x 完善很多如果项目里大量用透明效果建议直接上 9.x。3.4 flush_cb脏矩形的最后一公里渲染完成的像素数据最终要通过flush_cb交给屏幕。这个回调的签名大致是void my_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { /* area 是本次要刷新的屏幕区域 */ /* color_p 是渲染好的像素数据按行优先排列 */ /* 设置屏幕的刷新窗口为 area 指定的范围 */ my_lcd_set_window(area-x1, area-y1, area-x2, area-y2); /* 把 color_p 里的数据写进屏幕 */ uint32_t size lv_area_get_size(area); my_lcd_write_pixels(color_p, size); /* 必须调用通知 LVGL 本次刷新完成 */ lv_disp_flush_ready(disp_drv); }area参数就是脏矩形机制算出来的最终刷新区域color_p是对应的像素数据。理解这个回调就理解了脏矩形机制的出口。几个实操要点lv_disp_flush_ready必须调用否则 LVGL 会一直等界面卡死。如果用 DMA 异步刷屏要等 DMA 传输完成中断里再调用它。area的坐标是屏幕绝对坐标不是相对某个控件的。设置屏幕刷新窗口时直接用即可。color_p的数据排列是行优先即先第一行从左到右再第二行。如果屏幕驱动的显存排列不同比如竖屏旋转需要在这里做转换。不要在这个回调里做耗时操作它会被高频调用。所有准备工作应该在渲染前做好。我见过有人在flush_cb里加printf调试结果帧率暴跌还以为是脏矩形没生效。其实打印本身的开销就够呛调试刷屏相关问题时用 GPIO 翻转配合示波器看时序比打印靠谱得多。4. 常见问题与排查技巧实录4.1 残影脏区域没标全残影是最常见的脏矩形相关问题表现为控件移动或消失后原来的位置还留着旧图像。根因通常是该标记的脏区域没标记。常见场景自绘控件里直接操作了显示缓冲没调用lv_obj_invalidate。控件位置改变后只标记了新位置没标记旧位置。用了lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN)隐藏控件但隐藏前没标记脏区域。排查方法在lv_obj_invalidate里加个计数看控件变化时这个计数有没有增加。如果没增加说明标记环节就漏了。如果增加了但残影还在那就是合并或裁剪环节出了问题。注意LVGL 的lv_obj_invalidate标记的是控件当前占据的区域。如果控件已经移动了调用它标记的是新位置旧位置不会自动标记。移动控件前要先标记旧位置移动后再标记新位置。4.2 花屏区域越界或缓冲区不足花屏的表现是屏幕上出现随机噪点、错位图像或颜色异常。脏矩形相关的花屏多半是区域越界或缓冲区大小不匹配。区域越界脏区域坐标超出屏幕范围渲染时访问了非法内存。检查lv_area_intersect的返回值有没有判断屏幕边界裁剪有没有做。缓冲区不足flush_cb里按lv_area_get_size(area)算出的像素数超过了缓冲区实际容量。这种情况通常发生在脏区域合并后变得很大超过了单块缓冲区能容纳的尺寸。解决办法是限制合并后区域的最大尺寸或者增大缓冲区。坐标闭区间算错前面提过lv_area_t是闭区间。如果按开区间算宽度会少一行一列导致刷屏数据量对不上出现错位。这个坑我踩过查了大半天才发现是1漏了。4.3 帧率上不去脏区域合并过度脏矩形机制用对了应该提升帧率但有时候反而变慢问题往往出在合并过度。如果界面上有多个分散的小区域频繁变化而合并策略把它们并成了一个大矩形实际刷新的像素量可能比不合并还多。判断方法在flush_cb里统计每次刷新的区域面积累加看一帧总刷新像素数。如果这个数接近甚至超过全屏像素数说明合并策略有问题。调整方向减小LV_INV_BUF_SIZE让合并的候选区域变少。检查是否有控件在频繁全区域失效比如某个容器每次刷新都标记整个容器为脏。对于确实分散的刷新需求考虑用多个显示驱动或分区刷新。4.4 常见问题速查表现象可能原因排查方向解决思路残影脏区域未标记检查 invalidate 调用移动/隐藏前标记旧区域花屏区域越界检查 intersect 返回值补屏幕边界裁剪花屏缓冲区不足统计区域面积增大缓冲或限制合并错位闭区间算错检查宽度计算确认 x2-x11帧率低合并过度统计刷新像素总量调整合并策略卡死flush_ready 未调用检查回调确保每次刷新都调用撕裂刷屏与渲染不同步检查缓冲策略用双缓冲或 DMA闪烁刷新区域抖动观察区域变化稳定脏区域范围4.5 几个压箱底的调试技巧用 GPIO 打点看时序在flush_cb入口拉高一个 GPIO出口拉低用示波器看刷屏占用的时间比例。如果刷屏占了帧周期的大头说明脏区域还是太大。统计刷新像素总量在flush_cb里累加lv_area_get_size(area)每秒打印一次。这个数字直接反映脏矩形机制的实际效果。理想情况下静态界面这个值应该接近 0只有局部动画时才有明显数值。可视化脏区域临时修改flush_cb把每次刷新的区域用边框画出来在屏幕上叠加一个矩形框肉眼就能看到哪些区域在被反复刷新。这个方法对定位为什么这个区域一直在刷特别有效。对比全屏刷新临时把脏区域强制设为全屏对比帧率。如果全屏和局部刷新帧率差不多说明瓶颈不在刷屏而在渲染或逻辑处理优化方向要调整。5. 从脏矩形延伸出的优化思路5.1 局部刷新与动画性能脏矩形机制对动画性能的影响最直接。一个旋转的指针、一个滚动的列表、一个跳动的数字如果每次变化都全屏重绘再强的硬件也扛不住。用脏矩形把刷新范围压到最小同样的硬件能跑出几倍的帧率。做动画时有个技巧尽量让动画区域保持矩形且位置稳定。比如一个圆形进度条如果按圆形轮廓标记脏区域区域形状不规则合并和裁剪都麻烦。实际做法是用圆形的最小外接矩形作为脏区域虽然多刷了四个角但区域规整处理效率高。5.2 与屏幕驱动的配合脏矩形算得再准最终还是要靠屏幕驱动把数据搬上去。如果屏幕驱动本身效率低脏矩形的优势会被吃掉。几个配合要点用 DMA 刷屏CPU 把数据准备好后交给 DMA自己继续渲染下一区域实现渲染和刷屏并行。减少刷屏调用次数每次刷屏都有固定开销设置窗口、发命令脏区域合并时适当牺牲像素量换取调用次数减少往往划算。匹配屏幕的显存排列如果屏幕支持局部写入确保flush_cb设置的窗口和 LVGL 给的区域一致不要做多余的坐标转换。5.3 不同版本的差异LVGL 8.x 和 9.x 在脏矩形实现上有明显差异。9.x 重写了渲染管线引入了更精细的区域管理和图层概念对透明、混合、变换的支持更好脏区域计算也更准确。如果项目还在 8.x遇到透明控件相关的刷新问题升级到 9.x 往往能直接解决。不过升级有成本API 有不少变化控件样式系统也改了。我的建议是新项目直接上 9.x老项目如果刷新问题不严重就别折腾如果问题多且集中在渲染层再考虑升级。5.4 自绘控件的脏区域管理自己写自绘控件时脏区域管理要自己负责。核心原则是任何影响显示的改动都要标记对应区域为脏。/* 自绘控件里改变状态的典型写法 */ static void my_widget_set_value(my_widget_t *w, int32_t val) { if (w-value val) return; /* 值没变不标记 */ /* 标记旧值区域为脏 */ lv_obj_invalidate(w-obj); w-value val; /* 标记新值区域为脏 */ lv_obj_invalidate(w-obj); }注意那个提前返回值没变就不标记。这个判断能省掉大量无谓刷新尤其是传感器数据这类高频更新但变化不大的场景。6. 我在实际项目里踩过的坑说几个印象深刻的。第一个坑是在中断里改控件。前面提过中断里调用lv_label_set_text导致花屏。后来改成中断置标志、主循环处理问题消失。这个坑的本质是 LVGL 不是线程安全的所有控件操作必须在同一个上下文里做。第二个坑是缓冲区高度设得太小。当时为了省内存缓冲区只设了 10 行高。结果一个 40 行高的脏区域被拆成 4 次渲染刷屏每次都有固定开销帧率反而比设 40 行还低。后来把缓冲区高度调到能覆盖典型脏区域高度帧率立刻上来了。省内存不能省在刀刃上。第三个坑是忘了调 flush_ready。用 DMA 刷屏时一开始在 DMA 启动后就调了lv_disp_flush_ready结果画面撕裂严重。正确做法是等 DMA 传输完成中断里再调确保数据真的搬完了才通知 LVGL 继续。这个时序问题用示波器看 GPIO 打点一目了然。第四个坑是透明容器导致的残影。一个带透明背景的容器移动时下层内容没跟着重绘移动路径上留下一条残影。原因是透明容器的脏区域没有向下扩展。升级到 9.x 后这个问题自动解决了9.x 的渲染管线会正确处理透明叠加的脏区域传播。这些坑的共同点是脏矩形机制本身没问题问题都出在对接环节——要么是标记时机不对要么是缓冲区配置不当要么是刷屏时序错乱。把机制原理吃透再结合具体平台的特性去调问题都能定位。最后分享一个习惯每次遇到刷新相关的怪问题先别急着改代码先在flush_cb里统计刷新区域和像素量用数据说话。大部分时候数据会直接告诉你问题在哪——是区域太大、调用太频繁还是根本没触发刷新。这比盲目试参数高效得多。