3、LVGL基础架构:组件体系、对象树与父子关系、事件驱动模型、显示与输入设备抽象层

学LVGL,说白了就是学它的骨架。骨架搭对了,后面填肉就顺了。这一章我们聊聊LVGL的四大核心:组件体系、对象树、事件驱动、显示与输入抽象。这四块搞明白,你基本就摸清LVGL的脾气了。

3.1 组件体系:万物皆对象

LVGL里所有可见的东西,都叫“对象”(lv_obj_t)。按钮是对象,标签是对象,滑块是对象,连屏幕本身也是对象。我刚开始接触时觉得这概念有点虚,后来发现——这恰恰是LVGL最聪明的地方。

每个对象都继承自一个基础结构,这个结构里存了坐标、大小、样式、事件回调等通用信息。你创建一个按钮,本质上就是创建了一个lv_obj_t,然后给它套上按钮的行为和外观。

核心组件分类

  • 基础组件:lv_btn(按钮)、lv_label(标签)、lv_img(图片)
  • 输入组件:lv_textarea(文本框)、lv_dropdown(下拉菜单)、lv_keyboard(键盘)
  • 容器组件:lv_cont(容器)、lv_page(页面)、lv_tabview(标签页)
  • 可视化组件:lv_chart(图表)、lv_canvas(画布)、lv_led(指示灯)
  • 其他:lv_slider(滑块)、lv_switch(开关)、lv_bar(进度条)

每个组件都有自己专属的创建函数,比如lv_btn_create()、lv_label_create()。但注意,它们都返回lv_obj_t *指针。这意味着你可以用同一套API操作所有组件——设置大小、位置、样式,完全通用。

// 创建一个按钮
lv_obj_t *btn = lv_btn_create(lv_scr_act());
lv_obj_set_size(btn, 100, 40);
lv_obj_align(btn, LV_ALIGN_CENTER, 0, 0);

// 创建一个标签
lv_obj_t *label = lv_label_create(btn);
lv_label_set_text(label, "点击我");
lv_obj_center(label);

你看,按钮和标签的创建方式几乎一样。这就是“万物皆对象”的好处——学习成本低,复用性强。

3.2 对象树与父子关系

LVGL的界面不是平铺的,而是一棵树。每个对象都有一个父对象,父对象可以有多个子对象。这棵树从根节点——活动屏幕(lv_scr_act())开始,向下生长。

我做过一个智能家居面板,界面层级深到第四层。当时如果没理解透父子关系,布局早就乱套了。嗯,这里有个关键点:子对象的位置是相对于父对象的。父对象移动,子对象跟着动;父对象隐藏,子对象全看不见。

个人习惯:我一般把屏幕当作根,第一层放容器(比如lv_cont),第二层放具体组件。这样调整布局时,只需要移动容器,里面的组件自动跟着走。

对象树的遍历也很重要。LVGL提供了几个常用函数:

// 获取父对象
lv_obj_t *parent = lv_obj_get_parent(child);

// 获取第一个子对象
lv_obj_t *first_child = lv_obj_get_child(parent, 0);

// 获取下一个兄弟对象
lv_obj_t *sibling = lv_obj_get_sibling(child);

为什么要遍历?举个例子:你想批量修改某个容器下所有按钮的样式。用循环遍历子对象,一行代码搞定。否则你得一个一个找,累死。

我曾经踩过的坑:删除对象时,如果忘了处理父子关系,子对象会变成“孤儿”——内存泄漏的元凶。正确做法是用lv_obj_del_async(),它会安全地递归删除所有子对象。

下面这张图帮你理清对象树的结构:

LVGL 对象树结构 lv_scr_act() lv_cont(容器A) lv_cont(容器B) lv_btn(按钮C) lv_label lv_btn lv_slider lv_switch lv_label 说明 • 每个对象有且只有一个父对象 • 父对象移动/隐藏,子对象自动跟随 • 删除父对象会递归删除所有子对象 • 子对象坐标相对于父对象

3.3 事件驱动模型

LVGL是事件驱动的。什么意思?就是界面不会主动干活,只有你点了、滑了、按了,它才响应。每个对象都可以绑定事件回调,当特定事件发生时,回调函数被触发。

事件类型很多,常用的有:

事件宏 触发时机 典型用途
LV_EVENT_CLICKED 点击完成(松开后) 按钮响应
LV_EVENT_PRESSING 正在按下 长按检测
LV_EVENT_VALUE_CHANGED 值发生变化 滑块、开关
LV_EVENT_KEY 键盘按键 文本输入
LV_EVENT_DRAW_PART_BEGIN 开始绘制某部分 自定义绘制

绑定事件很简单:

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);

这里有个细节:同一个对象可以绑定多个事件回调。比如一个按钮,既想处理点击,又想处理长按,那就绑两个回调。LVGL内部会按注册顺序依次调用。

我建议:事件回调里不要做耗时操作。比如读写SD卡、网络请求,这些会卡住UI。正确的做法是发一个消息给任务队列,让后台线程去处理。

事件传递还有个“冒泡”机制。子对象的事件没被处理,会向上传递给父对象。你想想看,这有什么用?比如你有一个容器,里面放了10个按钮。你可以在容器上绑一个事件,统一处理所有按钮的点击,省得每个按钮单独绑。

// 在容器上统一处理子按钮的点击
static void container_event_cb(lv_event_t *e) {
    lv_obj_t *target = lv_event_get_target(e);  // 实际被点击的对象
    lv_obj_t *current = lv_event_get_current_target(e); // 当前处理事件的对象(容器)
    
    if(lv_event_get_code(e) == LV_EVENT_CLICKED) {
        LV_LOG_USER("容器内的 %p 被点击了", (void*)target);
    }
}

lv_obj_add_event_cb(container, container_event_cb, LV_EVENT_CLICKED, NULL);

3.4 显示与输入设备抽象层

LVGL能跑在那么多硬件上,靠的就是抽象层。显示抽象层叫“显示驱动”,输入抽象层叫“输入设备驱动”。你只需要实现几个底层函数,LVGL就能接管一切。

显示驱动

核心就是告诉LVGL:你的屏幕怎么画像素。通常需要实现一个“刷新回调”:

static void my_disp_flush(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) {
    // 将 color_p 中的像素数据写入屏幕的 area 区域
    // 比如用SPI发送数据到LCD
    lv_disp_flush_ready(disp_drv);  // 通知LVGL刷新完成
}

void my_display_init(void) {
    lv_disp_drv_t disp_drv;
    lv_disp_drv_init(&disp_drv);
    disp_drv.flush_cb = my_disp_flush;
    disp_drv.hor_res = 320;  // 屏幕宽度
    disp_drv.ver_res = 240;  // 屏幕高度
    lv_disp_drv_register(&disp_drv);
}

注意那个lv_disp_flush_ready(),必须调用。我曾经忘了加,结果屏幕死活不显示——嗯,LVGL以为刷新还没完,一直在等。

输入设备驱动

触摸屏、按键、编码器,都算输入设备。你需要实现一个“读取回调”:

static void my_touchpad_read(lv_indev_drv_t *indev_drv, lv_indev_data_t *data) {
    // 从硬件读取触摸坐标
    int16_t x, y;
    bool pressed = touchpad_get_xy(&x, &y);
    
    if(pressed) {
        data->state = LV_INDEV_STATE_PR;
        data->point.x = x;
        data->point.y = y;
    } else {
        data->state = LV_INDEV_STATE_REL;
    }
}

void my_touchpad_init(void) {
    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);
}

输入设备类型有几种:

类型 说明 常见硬件
LV_INDEV_TYPE_POINTER 指针设备(触摸屏、鼠标) 电阻屏、电容屏
LV_INDEV_TYPE_KEYPAD 键盘/矩阵按键 4x4矩阵键盘
LV_INDEV_TYPE_ENCODER 旋转编码器 EC11旋钮
LV_INDEV_TYPE_BUTTON 外部物理按键 GPIO按键

我曾经踩过的坑:触摸屏的坐标和屏幕的显示方向不一致。比如屏幕是横屏,触摸屏坐标却是竖屏的。解决办法是在read_cb里做坐标映射,或者用lv_disp_set_rotation()统一旋转。

抽象层的好处很明显:换屏幕或换触摸芯片,只需要改驱动文件,上层UI代码完全不用动。我做过一个项目,从ILI9341换成ST7789,只改了显示驱动,界面代码一行没改——这就是抽象层的威力。

3.5 小结

这一章我们聊了LVGL的四大基础架构:

  • 组件体系:万物皆对象,所有组件共用一套基础API
  • 对象树:父子层级管理,布局和生命周期都依赖它
  • 事件驱动:用户操作触发回调,支持冒泡传递
  • 抽象层:显示和输入设备通过驱动接口与硬件解耦

这四块是LVGL的基石。后面章节我们会基于这些概念,一步步搭建真正的嵌入式GUI界面。你先把这些基础消化掉,后面学起来会轻松很多。