LVGL 刷新机制剖析:刷新流程、脏矩形与 flush 回调

LVGL 的刷新机制,说白了就是一套「哪里变了,就只重画哪里」的增量更新系统。很多刚接触 LVGL 的朋友会误以为它跟传统 GUI 一样,每帧全屏刷新——其实不是。我刚开始移植 LVGL 时也踩过这个坑,以为刷新慢是驱动不行,后来才发现是没搞懂它的刷新策略。

这一章,我们就把它扒开来看清楚。

1. LVGL 的刷新流程:三步走

LVGL 的刷新可以拆成三个核心阶段:

  1. 标记脏区 —— 当某个控件属性变化(比如按钮按下、文本更新),LVGL 会把这个控件所在的区域标记为「脏」。
  2. 合并脏矩形 —— 把所有脏区合并成最少量的矩形,减少绘制次数。
  3. 调用 flush 回调 —— 把合并后的脏矩形数据刷到屏幕上。

你想想看,如果每次只改了一个像素,LVGL 绝不会把整个屏幕重画一遍。它只重画那个像素所在的脏矩形。这就是它高效的根本原因。

核心思想: LVGL 不是「每帧全屏刷新」,而是「按需局部刷新」。脏矩形机制是它的性能基石。

LVGL 刷新流程 控件状态变化 lv_obj_invalidate() 标记脏矩形 加入脏矩形链表 合并脏矩形 减少绘制次数 调用 flush 回调 刷到屏幕 刷新完成 等待下一帧 注意:脏矩形合并发生在 lv_refr_task() 中,每帧只执行一次

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 回调。具体流程是:

  1. lv_task_handler() 被周期性调用(通常在 while(1) 或 RTOS 任务中)
  2. 内部调用 lv_refr_task(),处理所有脏矩形
  3. 对每个脏矩形,调用 lv_draw_... 系列函数绘制到缓冲区
  4. 绘制完成后,调用 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 回调非常有帮助。