2、LVGL基础框架与渲染原理
这一章,我们来拆解LVGL的骨架和血液。
说白了,LVGL就是一个对象树驱动的图形引擎。你看到的每一个按钮、标签、滑块,都是树上的一片叶子。树怎么长、叶子怎么画、屏幕怎么刷新——搞懂这三件事,你就掌握了LVGL的底层逻辑。
核心三要素:对象树(组织UI结构)→ 显示驱动(告诉LVGL怎么往屏幕写像素)→ 刷新机制(决定什么时候、怎么更新画面)。
2.1 对象树:UI的骨架
LVGL里所有可见元素都是对象(lv_obj_t)。它们以树形结构组织,根节点是屏幕(lv_scr_act()),子节点层层嵌套。
我刚开始用LVGL时,习惯把所有控件都挂在根屏幕上。结果UI一复杂,事件处理乱成一锅粥。后来才明白——对象树就是你的UI架构图,父子关系决定了显示层级和事件传递路径。
// 创建对象树的典型流程
lv_obj_t *scr = lv_scr_act(); // 获取当前屏幕(根节点)
lv_obj_t *cont = lv_obj_create(scr); // 容器作为子节点
lv_obj_t *btn = lv_btn_create(cont); // 按钮挂在容器下
lv_obj_t *label = lv_label_create(btn); // 标签挂在按钮下
树的结构决定了三件事:
- 显示顺序:后创建的子节点在上层(类似z-index)
- 事件冒泡:子节点未处理的事件会向上传递给父节点
- 坐标系统:子节点的位置是相对于父节点的偏移
我的习惯:每个功能模块用一个容器(lv_cont)包裹,模块内部再细分。这样调试时只看容器层级就能定位问题,不用满屏幕找控件。
2.2 显示驱动注册:告诉LVGL你的屏幕长什么样
LVGL不知道你的屏幕是SPI接口还是RGB接口,是320x240还是800x480。你需要注册一个显示驱动(lv_disp_drv_t),告诉它:
- 屏幕分辨率
- 颜色深度(16位、32位等)
- 怎么往屏幕写像素(flush_cb回调)
- 用单缓冲还是双缓冲
// 显示驱动注册示例
static lv_disp_drv_t disp_drv;
static lv_disp_buf_t disp_buf;
static lv_color_t buf[LV_HOR_RES_MAX * 10]; // 缓冲区
lv_disp_buf_init(&disp_buf, buf, NULL, LV_HOR_RES_MAX * 10);
lv_disp_drv_init(&disp_drv);
disp_drv.buffer = &disp_buf;
disp_drv.flush_cb = my_flush_callback; // 你的屏幕写像素函数
disp_drv.hor_res = 320;
disp_drv.ver_res = 240;
lv_disp_drv_register(&disp_drv);
这里有个坑——flush_cb必须尽快返回。我曾经在一个项目里,flush_cb里加了延时等待屏幕刷新完成,结果整个UI卡成PPT。正确的做法是:把像素数据写入屏幕后立即返回,用TE信号或查询方式判断传输完成。
注意:flush_cb里不要做malloc、printf等耗时操作。LVGL的刷新循环是单线程的,你卡住flush_cb,整个UI就卡住了。
2.3 输入设备驱动注册:让LVGL感知触摸和按键
显示驱动负责输出,输入设备驱动负责输入。LVGL支持触摸屏、鼠标、键盘、编码器等多种输入设备。
// 输入设备驱动注册示例
static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER; // 触摸屏类型
indev_drv.read_cb = my_touchpad_read; // 读取触摸坐标的回调
lv_indev_drv_register(&indev_drv);
read_cb里你需要返回:
- 是否有触摸事件(inactive / press / release)
- 触摸点的x、y坐标
我记得有个项目,触摸屏偶尔会误触发。排查了半天,发现是read_cb里没有做去抖处理。触摸屏的ADC采样有噪声,直接上报会导致LVGL认为用户一直在点击。加一个简单的滑动平均滤波就解决了。
避坑指南:我曾经在read_cb里直接调用lv_tick_get()做超时判断,结果导致递归调用崩溃。read_cb里不要调用任何LVGL的API,只做硬件读取和数据转换。
2.4 刷新机制:LVGL怎么知道该画哪里
LVGL不是每帧都全屏重绘的。它采用脏矩形(dirty area)刷新机制——只更新发生变化的部分。
流程是这样的:
- 用户操作或代码修改触发某个对象的状态变化(比如按钮按下)
- LVGL标记该对象所在的区域为“脏区域”
- 在lv_task_handler()循环中,LVGL收集所有脏区域,合并成一个或多个矩形
- 调用flush_cb只刷新这些矩形区域
为什么会这样设计?你想想看,一个320x240的屏幕,全屏刷新要写76800个像素。如果只是更新一个按钮(比如40x30像素),只写1200个像素就够了——性能差距60倍。
关键点:lv_task_handler()的调用频率决定了刷新率。一般建议每5-10ms调用一次,对应100-200fps的刷新能力。但实际帧率受限于flush_cb的写入速度和脏区域大小。
2.5 单缓冲 vs 双缓冲:选哪个?
这是LVGL部署时最常纠结的问题。我直接说结论:
| 模式 | 缓冲区大小 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 单缓冲 | 1行像素(如320x1) | 内存占用极小 | 可能有撕裂、刷新慢 | 内存<64KB、低刷新率要求 |
| 双缓冲 | 1帧像素(如320x240) | 无撕裂、刷新流畅 | 内存占用大 | 内存充足、动画/视频场景 |
| 部分双缓冲 | 多行像素(如320x10) | 折中方案 | 实现稍复杂 | 大多数嵌入式场景 |
单缓冲模式下,LVGL直接在屏幕上绘制。如果flush_cb写入速度跟不上,用户会看到画面从上到下慢慢刷出来——这就是撕裂。
双缓冲模式下,LVGL先在后台缓冲区绘制完整一帧,然后一次性交换到前台。用户看到的是完整画面,没有撕裂。但代价是内存翻倍。
我个人最常用的是部分双缓冲——分配10-20行像素的缓冲区。LVGL会分块渲染,每次渲染一小块就flush一次。这样内存占用只有全双缓冲的5%-10%,但撕裂几乎不可见。
// 部分双缓冲配置示例
#define ROW_COUNT 10 // 10行像素的缓冲区
static lv_color_t buf[LV_HOR_RES_MAX * ROW_COUNT];
lv_disp_buf_init(&disp_buf, buf, NULL, LV_HOR_RES_MAX * ROW_COUNT);
// 注意:第三个参数为NULL表示单缓冲区,不为NULL表示双缓冲区
// 这里只传了一个buf,实际是单缓冲区+部分刷新模式
注意:部分双缓冲模式下,flush_cb可能会被多次调用才能完成一帧。你的flush_cb必须支持部分区域刷新,不能假设每次都是全屏刷新。
2.6 整体流程:一张图看懂
下面这张SVG图,把对象树、显示驱动、输入设备、刷新机制串起来了。
从这张图可以清楚看到:对象树定义UI结构,显示驱动和输入设备驱动负责与硬件交互,刷新机制把变化高效地渲染到屏幕上。三者缺一不可。
2.7 小结
这一章我们拆解了LVGL的四个核心模块:
- 对象树:用父子关系组织UI,决定显示层级和事件传递
- 显示驱动:注册flush_cb,告诉LVGL怎么写屏幕
- 输入设备驱动:注册read_cb,告诉LVGL用户的操作
- 刷新机制:脏矩形刷新 + 单/双缓冲选择