LVGL 刷新机制剖析:刷新流程、脏矩形与 flush 回调
LVGL 的刷新机制,说白了就是一套「哪里变了,就只重画哪里」的增量更新系统。很多刚接触 LVGL 的朋友会误以为它跟传统 GUI 一样,每帧全屏刷新——其实不是。我刚开始移植 LVGL 时也踩过这个坑,以为刷新慢是驱动不行,后来才发现是没搞懂它的刷新策略。
这一章,我们就把它扒开来看清楚。
1. LVGL 的刷新流程:三步走
LVGL 的刷新可以拆成三个核心阶段:
- 标记脏区 —— 当某个控件属性变化(比如按钮按下、文本更新),LVGL 会把这个控件所在的区域标记为「脏」。
- 合并脏矩形 —— 把所有脏区合并成最少量的矩形,减少绘制次数。
- 调用 flush 回调 —— 把合并后的脏矩形数据刷到屏幕上。
你想想看,如果每次只改了一个像素,LVGL 绝不会把整个屏幕重画一遍。它只重画那个像素所在的脏矩形。这就是它高效的根本原因。
核心思想: LVGL 不是「每帧全屏刷新」,而是「按需局部刷新」。脏矩形机制是它的性能基石。
2. 脏矩形机制:LVGL 的「聪明眼睛」
脏矩形(Dirty Rectangle)是 LVGL 刷新系统的核心数据结构。每个被标记为「需要重绘」的区域,都会被记录在一个链表中。
2.1 脏矩形的产生
什么情况下会触发脏矩形?我列几个常见场景:
- 控件属性变化:lv_label_set_text()、lv_btn_set_state() 等
- 控件创建/删除:新增或移除控件时
- 控件移动/大小变化:位置或尺寸改变
- 滚动/滑动:滚动条移动时
- 动画进行中:每帧动画都会触发脏区
我个人习惯在调试阶段打开 LV_USE_PERF_MONITOR,这样能实时看到脏矩形的数量和大小。有一次我发现某个页面脏矩形数量异常多,排查后发现是一个透明背景的容器在反复触发无效刷新——嗯,这就是典型的「过度无效脏区」问题。
2.2 脏矩形的合并策略
LVGL 不会直接拿单个脏矩形去刷屏。它会先做一次合并优化:
| 合并策略 | 说明 | 适用场景 |
|---|---|---|
| 相邻合并 | 两个脏矩形相邻或重叠,合并成一个更大的矩形 | 连续滚动、动画 |
| 包含合并 | 一个矩形完全包含另一个,只保留大的 | 父子控件同时变化 |
| 分离保留 | 两个矩形距离太远,不合并,分别绘制 | 屏幕两端独立变化 |
避坑指南: 我曾经遇到过一个性能问题——脏矩形合并后反而变慢了。原因是两个相距很远的脏矩形被强行合并成一个巨大的矩形,导致绘制面积暴增。后来我在 lv_conf.h 里调整了 LV_DPI_DEF 和脏矩形合并阈值,才解决。
3. flush 回调函数详解
flush 回调是 LVGL 和底层显示驱动之间的「桥梁」。LVGL 把绘制好的像素数据通过这个回调交给驱动,驱动再把它刷到屏幕上。
3.1 flush 回调的原型
static void my_flush_cb(lv_disp_drv_t * disp_drv,
const lv_area_t * area,
lv_color_t * color_p)
{
// area - 需要刷新的矩形区域
// color_p - 该区域的像素数据缓冲区
// 1. 把 color_p 中的数据写入屏幕对应的区域
// 2. 通知 LVGL 刷新完成
lv_disp_flush_ready(disp_drv);
}
这里有个关键点:lv_disp_flush_ready() 必须调用。如果不调,LVGL 会认为驱动还在忙,不会继续发送下一帧数据。我见过不少新手在这里卡住——屏幕黑屏,查了半天发现是忘了调这个函数。
3.2 flush 回调的执行时机
LVGL 在 lv_task_handler() 中调用 flush 回调。具体流程是:
lv_task_handler()被周期性调用(通常在 while(1) 或 RTOS 任务中)- 内部调用
lv_refr_task(),处理所有脏矩形 - 对每个脏矩形,调用
lv_draw_...系列函数绘制到缓冲区 - 绘制完成后,调用 flush 回调把缓冲区内容刷到屏幕
重要: flush 回调是在 LVGL 的主循环上下文中执行的。不要在 flush 回调里做耗时操作(比如延时等待),否则会阻塞整个 GUI 的刷新。如果驱动需要等待,请使用 DMA 或硬件加速。
3.3 flush 回调的常见实现模式
根据硬件不同,flush 回调的实现方式也不同。我总结了几种常见模式:
| 模式 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 直接写屏 | memcpy 或逐像素写入 | 实现简单 | CPU 占用高,速度慢 |
| DMA 传输 | 启动 DMA 后立即返回 | 不阻塞 CPU | 需要 DMA 中断配合 |
| SPI 并行 | SPI 发送 + 等待完成 | 适合常见 LCD | SPI 速度瓶颈 |
| 双缓冲 | 后台缓冲 + 前台切换 | 无撕裂,流畅 | 内存翻倍 |
3.4 一个完整的 flush 回调示例
下面是我在实际项目中用过的 SPI LCD 驱动 flush 回调:
static void my_flush_cb(lv_disp_drv_t * disp_drv,
const lv_area_t * area,
lv_color_t * color_p)
{
int32_t x, y;
uint16_t * buf = (uint16_t *)color_p;
// 设置显示窗口
lcd_set_window(area->x1, area->y1, area->x2, area->y2);
// 逐行发送数据
for(y = area->y1; y <= area->y2; y++) {
lcd_send_data(&buf[(y - area->y1) * (area->x2 - area->x1 + 1)],
(area->x2 - area->x1 + 1) * 2);
}
// 通知 LVGL 刷新完成
lv_disp_flush_ready(disp_drv);
}
注意这里我用了 lcd_set_window 来限定写入区域,而不是全屏写入。这样做的好处是:即使脏矩形很小,也不会浪费带宽去刷整屏。
4. 刷新机制的常见陷阱
最后分享几个我踩过的坑:
- 忘记调 lv_disp_flush_ready() —— 屏幕黑屏,LVGL 卡死。这是最常见的错误。
- 在 flush 回调里做延时 —— 导致 GUI 刷新帧率暴跌。用 DMA 或硬件加速代替。
- 脏矩形合并过度 —— 两个远距离脏矩形被合并成一个大矩形,绘制面积暴增。调整合并策略或阈值。
- 缓冲区大小不匹配 —— LVGL 的缓冲区大小必须能容纳至少一个脏矩形。如果缓冲区太小,LVGL 会分多次刷新,效率降低。
我的建议: 在移植初期,先把 LV_USE_LOG 打开,设置日志级别为 LV_LOG_LEVEL_INFO。这样你能看到脏矩形的数量、大小和刷新耗时。这些信息对调试 flush 回调非常有帮助。