2. LVGL基础概念
说实话,很多开发者刚开始接触 LVGL 时,第一反应是「这玩意儿怎么跑起来的?」。他们看到的是一堆漂亮的控件在屏幕上动,但底层到底怎么组织代码、怎么分配 CPU 时间、怎么响应触摸,往往是一团迷雾。
我个人习惯,在移植任何 GUI 之前,先把它的大脑结构摸清楚。LVGL 的架构设计其实非常清晰,说白了就是四块:软件分层、任务调度、对象树、事件驱动。今天咱们就把这四块掰开揉碎讲明白。
核心观点:LVGL 不是一个大泥球,而是一个分层清晰的软件栈。每一层只关心自己的事,上层不依赖下层的实现细节。这种设计让驱动适配变得异常简单——你只需要关心最底层的那几根线。
1. 软件分层:从硬件到 UI 的阶梯
LVGL 的软件架构,我习惯把它画成一座金字塔。从上到下分别是:
- 应用层(Application):你写的业务逻辑,比如点击按钮切换页面、显示传感器数据。
- 控件层(Widgets):按钮、标签、滑块、图表等现成 UI 组件。
- 核心层(Core):对象管理、事件系统、样式系统、动画引擎。
- 驱动抽象层(Drivers):显示驱动、触摸驱动、文件系统接口。
- 硬件层(Hardware):你的 MCU、LCD 屏、触摸芯片、SPI/I2C 总线。
你想想看,如果你要适配一块新屏幕,需要改哪一层?只需要改驱动抽象层。核心层和控件层完全不用动。这就是分层设计的好处——隔离变化。
我在项目中遇到过一种情况:团队里有人直接把硬件寄存器操作写到了按钮回调函数里。结果换屏的时候,所有回调函数都得重写。嗯,这就是没理解分层的结果。
2. 任务调度机制:LVGL 的脉搏
LVGL 不是实时操作系统,但它有自己的心跳。这个心跳就是 lv_tick_inc() 和 lv_task_handler()。
我刚开始用 LVGL 时犯过一个低级错误:在 main 循环里只调了 lv_task_handler(),忘了给 lv_tick_inc() 喂时间。结果动画卡得像幻灯片。后来才明白,这两个函数缺一不可。
2.1 时间基准:lv_tick_inc()
这个函数负责告诉 LVGL「又过去了 1 毫秒」。你需要在定时器中断里周期性调用它:
// 在 1ms 定时器中断中调用
void timer_isr(void) {
lv_tick_inc(1);
}
为什么需要这个?因为 LVGL 内部有大量的延时、动画、闪烁逻辑,它们都依赖这个时间基准。没有它,动画不会动,长按检测不会触发。
2.2 主循环引擎:lv_task_handler()
这个函数是 LVGL 的调度中心。它负责:
- 检查哪些任务(lv_task)到了执行时间
- 刷新脏区域(需要重绘的部分)
- 处理输入设备的状态变化
- 驱动动画引擎前进
典型的调用方式是这样的:
while(1) {
lv_task_handler(); // LVGL 内部调度
my_delay(5); // 适当延时,避免 CPU 空转
}
经验之谈:lv_task_handler() 的调用间隔建议控制在 5-20ms。太快了浪费 CPU,太慢了 UI 会感觉卡顿。我一般用 10ms,也就是 100Hz 的刷新率,对大多数嵌入式应用已经足够流畅。
2.3 内部任务(lv_task)
LVGL 内部维护了一个任务链表。每个任务有:
| 属性 | 说明 | 典型值 |
|---|---|---|
| 周期(period) | 任务执行的间隔时间 | 10ms、100ms、1000ms |
| 回调(cb) | 任务执行时调用的函数 | lv_anim_tick()、lv_refr_areas() |
| 重复次数(repeat_count) | -1 表示无限循环 | -1 |
你还可以通过 lv_task_create() 注册自己的任务。比如每隔 100ms 读取一次传感器:
lv_task_t * task = lv_task_create(sensor_read_cb, 100, LV_TASK_PRIO_MID, NULL);
static void sensor_read_cb(lv_task_t * t) {
int val = read_sensor();
lv_label_set_text_fmt(label, "温度: %d°C", val);
}
3. 对象树:一切皆父子
LVGL 里的所有 UI 元素都是对象(lv_obj)。它们组织成一棵树——对象树。
根节点是 lv_scr_act(),也就是当前活动屏幕。你在屏幕上添加一个按钮,按钮就是屏幕的子节点。按钮上再加一个标签,标签就是按钮的子节点。
为什么用树结构?因为:
- 继承样式:子节点可以继承父节点的样式属性
- 事件冒泡:事件可以从子节点向上传播到父节点
- 统一管理:删除父节点时,所有子节点自动被删除
我见过有人把几百个控件全部挂在屏幕根节点下,结果想统一改字体时得遍历所有控件。其实只要在屏幕节点上设置一次字体,子控件默认就继承了。这就是树结构的威力。
// 创建对象树的典型代码
lv_obj_t * scr = lv_scr_act(); // 根节点:当前屏幕
lv_obj_t * btn = lv_btn_create(scr); // 子节点:按钮
lv_obj_t * label = lv_label_create(btn); // 孙节点:标签
// 父节点设置样式,子节点自动继承
lv_obj_set_style_text_font(scr, &my_font, 0); // 屏幕设置字体
// 按钮和标签的文本都会使用 my_font,除非它们自己覆盖
注意:对象树的深度会影响性能。每次重绘时,LVGL 需要遍历树来收集脏区域。如果你的树深度超过 10 层,建议考虑重构 UI 结构。我曾经在一个项目里把树深度压到了 4 层以内,重绘性能提升了 30%。
4. 事件驱动模型:用户交互的神经
LVGL 的事件系统,说白了就是「当某件事发生时,通知关心这件事的对象」。这个模型非常像嵌入式开发中的中断——只不过是在软件层面实现的。
4.1 事件类型
LVGL 预定义了大量事件类型,常用的有:
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
| LV_EVENT_CLICKED | 控件被点击并释放 | 按钮响应 |
| LV_EVENT_PRESSING | 控件正在被按下 | 长按检测 |
| LV_EVENT_VALUE_CHANGED | 控件的值发生变化 | 滑块拖动、开关切换 |
| LV_EVENT_REFRESH | 控件需要刷新显示 | 自定义控件重绘 |
| LV_EVENT_DELETE | 控件即将被删除 | 资源清理 |
4.2 事件注册与处理
给控件注册事件处理函数非常简单:
static void btn_event_cb(lv_event_t * e) {
lv_event_code_t code = lv_event_get_code(e);
if(code == LV_EVENT_CLICKED) {
LV_LOG_USER("按钮被点击了!");
// 执行你的业务逻辑
}
}
// 注册事件
lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, NULL);
4.3 事件冒泡与捕获
事件在对象树中传播分为两个阶段:
- 捕获阶段:从根节点向下传播到目标节点
- 冒泡阶段:从目标节点向上传播回根节点
默认情况下,事件处理在冒泡阶段触发。你可以通过 LV_EVENT_PREPROCESS 标志让事件在捕获阶段处理。
这个机制有什么用?举个例子:你有一个容器,里面放了 10 个按钮。与其给每个按钮单独注册事件,不如在容器上注册一个事件,然后通过 lv_event_get_target() 判断具体是哪个按钮被点击。这样代码更简洁,也更容易维护。
// 在容器上统一处理所有子按钮的点击事件
static void container_event_cb(lv_event_t * e) {
lv_obj_t * target = lv_event_get_target(e);
lv_event_code_t code = lv_event_get_code(e);
if(code == LV_EVENT_CLICKED) {
// target 就是被点击的那个按钮
if(target == btn_1) {
// 处理按钮1
} else if(target == btn_2) {
// 处理按钮2
}
}
}
lv_obj_add_event_cb(container, container_event_cb, LV_EVENT_CLICKED, NULL);
避坑指南:我曾经在事件回调里直接调用了 lv_obj_del() 删除触发事件的控件。结果程序直接崩溃。为什么?因为事件处理过程中,LVGL 还在遍历事件链表,你删除了当前节点,链表就断了。正确的做法是用 lv_event_send() 延迟删除,或者设置一个标志位,在主循环里统一处理删除操作。
5. 四者如何协同工作?
现在我们把四个部分串起来,看看一次完整的 UI 交互是怎么发生的:
- 用户触摸屏幕 → 触摸驱动检测到坐标变化
- 驱动抽象层将坐标转换为 LVGL 输入事件
- 核心层根据坐标在对象树中查找命中的控件
- 核心层向该控件发送 LV_EVENT_PRESSING 事件
- 事件回调函数执行业务逻辑(比如改变控件样式)
- 核心层标记该控件区域为「脏区域」
- 下一次
lv_task_handler()调用时,脏区域被重绘 - 重绘数据通过驱动抽象层刷新到 LCD 屏幕
整个过程一气呵成。你想想看,如果没有分层设计,触摸驱动直接操作 LCD 寄存器,那这套流程得乱成什么样?