3、LVGL基础架构:对象树、显示驱动与输入设备驱动模型

好,咱们今天聊聊LVGL的骨架。说白了,就是搞清楚这三样东西:对象树、显示驱动、输入设备驱动。我刚开始接触LVGL时,觉得不就是画几个按钮嘛,后来发现如果不理解这三者的关系,写出来的代码迟早会崩。

你想想看,一个嵌入式GUI系统,本质上就是三件事:管理界面元素、把画面刷到屏幕上、接收用户的触摸或按键。LVGL把这三点抽象成了三个独立的模块,彼此配合,又互不干扰。

3.1 对象树:一切UI元素的组织方式

LVGL里所有的可见元素,都叫“对象”(Object)。按钮是对象,标签是对象,滑块也是对象。它们不是散落一地的,而是以树形结构组织起来的。

为什么用树?因为GUI天然就有层级关系。一个窗口里放几个按钮,按钮里再放个图标——这就是父子关系。父对象移动,子对象跟着动;父对象隐藏,子对象也看不见。我在项目里见过有人把所有对象都平铺在根节点下,结果做滑动页面时,坐标计算把自己绕晕了。

核心概念:每个对象都有一个父对象,可以有零个或多个子对象。根节点是 lv_scr_act(),也就是当前活动屏幕。

创建对象的API很简单:

lv_obj_t * btn = lv_btn_create(lv_scr_act());  // 父对象是当前屏幕
lv_obj_t * label = lv_label_create(btn);        // 父对象是按钮

这里有个坑,我踩过。如果你创建对象时传了错误的父对象,比如传了一个已经被删除的对象,LVGL不会立刻报错,但后续操作会莫名其妙地崩溃。所以,永远确保父对象是有效的。

对象树的操作,常用的就这几个:

  • lv_obj_set_parent(obj, new_parent) — 把对象挪到另一个父节点下
  • lv_obj_get_child(parent, idx) — 获取第idx个子对象
  • lv_obj_del(obj) — 删除对象,它的子对象也会被递归删除

嗯,这里要注意:删除父对象时,子对象会自动被清理。但如果你在子对象上还挂着动态分配的内存,记得先解绑,否则会内存泄漏。

3.2 显示驱动:告诉LVGL怎么画像素

LVGL本身不知道你的屏幕是什么型号、用什么接口。它只负责计算“这个像素应该是什么颜色”,然后把数据交给显示驱动。驱动负责把数据刷到物理屏幕上。

显示驱动的核心是一个结构体:lv_disp_drv_t。你需要填充它,然后注册到LVGL。

我个人习惯把驱动初始化拆成三步:

  1. 分配缓冲区 — LVGL需要一个或多个缓冲区来存储渲染中间结果
  2. 设置回调函数 — 主要是 flush_cb,负责把缓冲区内容刷到屏幕
  3. 注册驱动 — 调用 lv_disp_drv_register

代码示例:

static lv_disp_drv_t disp_drv;
static lv_disp_buf_t disp_buf;
static lv_color_t buf[LV_HOR_RES_MAX * 10];  // 10行缓冲区

void my_display_init(void) {
    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;
    lv_disp_drv_register(&disp_drv);
}

static void my_flush_callback(lv_disp_drv_t * drv, const lv_area_t * area, lv_color_t * color_p) {
    // 把 color_p 中的数据刷到屏幕的 area 区域
    // 刷完后必须调用 lv_disp_flush_ready(drv)
    lv_disp_flush_ready(drv);
}

曾经有个坑:我忘了调用 lv_disp_flush_ready,结果屏幕一直黑着。LVGL会等待驱动确认“刷完了”才继续下一帧。不调用这个函数,整个UI就卡死了。

缓冲区大小怎么选?我建议至少屏幕宽度的10行像素。太小了渲染效率低,太大了浪费RAM。如果你的MCU内存紧张,可以用1行缓冲区,但画面刷新会慢一些。

3.3 输入设备驱动:让LVGL知道用户碰了哪里

输入设备驱动和显示驱动是对称的。显示驱动负责“输出”,输入驱动负责“输入”。

核心结构体是 lv_indev_drv_t。你需要实现一个 read_cb 回调,LVGL会周期性地调用它,询问“当前有没有输入事件?”

常见的输入设备类型:

类型 典型设备 回调返回的数据
LV_INDEV_TYPE_POINTER 触摸屏、鼠标 坐标 (x, y) 和按下状态
LV_INDEV_TYPE_KEYPAD 矩阵键盘、实体按键 键值 (key)
LV_INDEV_TYPE_ENCODER 旋转编码器 差值 (diff) 和按键状态
LV_INDEV_TYPE_BUTTON 外部独立按键 按键ID和状态

触摸屏驱动的典型实现:

static lv_indev_drv_t indev_drv;

void my_touch_init(void) {
    lv_indev_drv_init(&indev_drv);
    indev_drv.type = LV_INDEV_TYPE_POINTER;
    indev_drv.read_cb = my_touch_read_callback;
    lv_indev_drv_register(&indev_drv);
}

static bool my_touch_read_callback(lv_indev_drv_t * drv, lv_indev_data_t * data) {
    // 从触摸芯片读取坐标
    data->point.x = touch_x;
    data->point.y = touch_y;
    data->state = touch_pressed ? LV_INDEV_STATE_PR : LV_INDEV_STATE_REL;
    return false;  // 没有更多数据了
}

小技巧:如果触摸屏的坐标和屏幕方向不一致,可以在 read_cb 里做坐标映射。我曾经遇到过触摸屏的X轴和屏幕的X轴是反的,加了一句 data->point.x = LV_HOR_RES_MAX - touch_x; 就解决了。

3.4 三者如何协同工作

现在我们把三样东西串起来。LVGL的主循环大致是这样的:

  1. 调用 lv_task_handler(),内部会遍历所有输入设备驱动,调用 read_cb 获取输入
  2. 根据输入事件,修改对象树中对应对象的状态(比如按钮高亮)
  3. 重新渲染被修改的区域,把结果写入缓冲区
  4. 调用显示驱动的 flush_cb,把缓冲区刷到屏幕

你看,对象树是“数据”,显示驱动是“输出通道”,输入驱动是“输入通道”。三者通过LVGL内核串联起来。

下面这张图可以帮你理清关系:

LVGL 基础架构:对象树 + 显示驱动 + 输入驱动 对象树 (Object Tree) lv_scr_act() 根节点 ├── 容器 (lv_cont) │ ├── 按钮 (lv_btn) │ │ └── 标签 (lv_label) │ └── 滑块 (lv_slider) 父子层级管理UI元素 显示驱动 (Display Driver) lv_disp_drv_t 缓冲区 → flush_cb → 屏幕 支持双缓冲、部分刷新 lv_disp_flush_ready() 负责把像素数据刷到物理屏幕 输入设备驱动 (Input Driver) lv_indev_drv_t 触摸屏 / 按键 / 编码器 read_cb → 坐标/键值 lv_indev_data_t 负责把用户操作传给LVGL内核 渲染结果 输入事件 状态更新 对象树管理UI结构,显示驱动负责输出,输入驱动负责输入,三者通过LVGL内核协同工作

从图中可以看到,对象树是中心。输入驱动把用户操作传给对象树,对象树更新状态后,显示驱动把变化刷出来。这个闭环每毫秒都在运行。

3.5 多显示器与多输入设备

LVGL支持同时挂载多个显示器和多个输入设备。比如一个设备有两个屏幕,或者一个屏幕同时支持触摸和按键操作。

注册多个显示器时,每个显示器有独立的 lv_disp_t 句柄。你可以用 lv_disp_set_default(disp) 切换当前操作的显示器。

多个输入设备可以绑定到同一个显示器,也可以绑定到不同显示器。LVGL内部会根据输入设备注册时关联的显示器,把事件分发到对应的对象树上。

实际项目经验:我曾经在一个工业平板项目里,同时接了电容触摸屏和物理按键。触摸屏用于滑动和点击,物理按键用于快速选择菜单项。两个输入设备都注册到同一个显示器上,LVGL会自动处理事件冲突——触摸优先,按键作为补充。