3. 开发环境搭建
好,咱们今天聊聊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。
我个人习惯把驱动初始化拆成三步:
- 分配缓冲区 — LVGL需要一个或多个缓冲区来存储渲染中间结果
- 设置回调函数 — 主要是
flush_cb,负责把缓冲区内容刷到屏幕 - 注册驱动 — 调用
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的主循环大致是这样的:
- 调用
lv_task_handler(),内部会遍历所有输入设备驱动,调用read_cb获取输入 - 根据输入事件,修改对象树中对应对象的状态(比如按钮高亮)
- 重新渲染被修改的区域,把结果写入缓冲区
- 调用显示驱动的
flush_cb,把缓冲区刷到屏幕
你看,对象树是“数据”,显示驱动是“输出通道”,输入驱动是“输入通道”。三者通过LVGL内核串联起来。
下面这张图可以帮你理清关系:
从图中可以看到,对象树是中心。输入驱动把用户操作传给对象树,对象树更新状态后,显示驱动把变化刷出来。这个闭环每毫秒都在运行。
3.5 多显示器与多输入设备
LVGL支持同时挂载多个显示器和多个输入设备。比如一个设备有两个屏幕,或者一个屏幕同时支持触摸和按键操作。
注册多个显示器时,每个显示器有独立的 lv_disp_t 句柄。你可以用 lv_disp_set_default(disp) 切换当前操作的显示器。
多个输入设备可以绑定到同一个显示器,也可以绑定到不同显示器。LVGL内部会根据输入设备注册时关联的显示器,把事件分发到对应的对象树上。
实际项目经验:我曾经在一个工业平板项目里,同时接了电容触摸屏和物理按键。触摸屏用于滑动和点击,物理按键用于快速选择菜单项。两个输入设备都注册到同一个显示器上,LVGL会自动处理事件冲突——触摸优先,按键作为补充。