4、LVGL基础:LVGL架构、对象树、事件驱动模型

好,咱们开始聊LVGL。说实话,我最早接触LVGL是在一个便携式呼吸机的项目上。那时候屏幕小,资源紧,选来选去还是LVGL最靠谱。今天这一章,我带你把它最核心的三个概念捋清楚:架构、对象树、事件驱动。搞懂这些,后面写界面就像搭积木一样简单。

4.1 LVGL的整体架构

LVGL的架构,说白了就是分层。你想想看,一个GUI库要跑在嵌入式设备上,它得把硬件和软件隔开。LVGL就是这么干的。

从上到下,大致分这么几层:

  • 应用层:你写的业务代码,比如呼吸机的参数设置、报警逻辑。
  • LVGL核心层:对象管理、事件分发、样式计算。这是心脏。
  • 硬件抽象层(HAL):负责跟显示屏、触摸屏、定时器打交道。

我个人习惯把HAL层比作「翻译官」。你的屏幕是SPI接口还是RGB接口?触摸是I2C还是ADC?这些差异,HAL层全给你挡掉了。你只需要实现几个简单的回调函数,比如disp_flush()touch_read()

核心要点:LVGL不直接操作硬件寄存器。它通过HAL层跟你板子的驱动交互。所以换屏幕?换主控?只要HAL接口不变,你的UI代码几乎不用动。

嗯,这里要注意一点:HAL层的初始化顺序有讲究。我遇到过有人先初始化了LVGL,再去初始化屏幕驱动,结果屏幕死活不亮。正确的顺序是:先初始化硬件驱动(屏幕、触摸),再调用lv_init(),最后配置HAL。

4.2 对象树:一切UI都是树

LVGL里所有的界面元素,都是对象。按钮是对象,标签是对象,滑块也是对象。这些对象不是孤立的,它们组织成一棵树。

为什么是树?因为GUI天然有层级关系。你想想呼吸机的主界面:

  • 一个屏幕(根对象)
  • 下面挂着几个容器(比如参数区、波形区、报警区)
  • 每个容器里再挂按钮、标签、进度条

这棵树长这样:

lv_scr_act()  // 当前活动屏幕
├── 容器1 (参数面板)
│   ├── 标签 (显示"潮气量")
│   ├── 标签 (显示数值)
│   └── 按钮 (调整参数)
├── 容器2 (波形显示)
│   └── 画布 (绘制波形)
└── 容器3 (报警区域)
    └── 标签 (显示报警信息)

创建对象的API很简单:

lv_obj_t * btn = lv_btn_create(lv_scr_act());  // 在屏幕上创建按钮
lv_obj_t * label = lv_label_create(btn);       // 在按钮上创建标签

第二个参数是父对象。父对象移动,子对象跟着动。父对象隐藏,子对象也看不见。这就是继承关系。

我的经验:别把所有对象都直接挂在屏幕下。那样管理起来会疯掉。我习惯先创建几个容器(panel),把功能分区。比如呼吸机,我会建一个「参数区容器」、「波形区容器」、「报警区容器」。这样后面要整体隐藏或移动某个区域,只需要操作容器对象。

还有一个坑:删除对象时,子对象会自动被删除。但如果你用指针保存了子对象,删除父对象后那个指针就变成野指针了。我曾经因为这个查了两天bug……嗯,后来我养成了一个习惯:删除父对象后,把所有子对象指针置NULL。

4.3 事件驱动模型:点一下,发生了什么?

GUI的本质是交互。用户点一下按钮,屏幕要亮。用户旋转编码器,参数要变。这一切,都是事件驱动的。

LVGL的事件模型,我总结为三步:

  1. 事件源:谁产生了事件?比如按钮被点击、滑块被拖动。
  2. 事件类型:发生了什么?LV_EVENT_CLICKEDLV_EVENT_VALUE_CHANGED
  3. 事件处理函数:你写的回调函数,响应这个事件。

注册事件处理函数的代码长这样:

lv_obj_add_event_cb(btn, my_button_callback, LV_EVENT_CLICKED, NULL);

void my_button_callback(lv_event_t * e) {
    lv_obj_t * btn = lv_event_get_target(e);
    LV_LOG_USER("按钮被点击了!");
    // 在这里写你的业务逻辑,比如切换呼吸模式
}

这里有个细节:lv_event_get_target(e)拿到的是触发事件的对象。如果你在多个按钮上注册了同一个回调,可以用这个函数区分是哪个按钮被点了。

注意:事件回调里不要做耗时操作!比如读写SD卡、网络通信。LVGL的事件循环是单线程的,你卡住了,整个UI就卡死了。我见过有人直接在回调里写delay(1000),结果界面像幻灯片一样。正确的做法是:在回调里设置一个标志位,在主循环里处理耗时任务。

事件类型有很多,常用的我列一下:

事件类型 触发时机 典型用途
LV_EVENT_CLICKED 点击后释放 按钮确认、模式切换
LV_EVENT_VALUE_CHANGED 值发生变化 滑块拖动、开关切换
LV_EVENT_PRESSING 正在按下 长按连续调节
LV_EVENT_RELEASED 手指抬起 确认操作完成

你可能会问:如果多个对象需要响应同一个事件怎么办?比如呼吸机上的「静音报警」按钮,点击后要停止蜂鸣器,还要在屏幕上显示「已静音」。两种做法:

  • 在同一个回调里写两件事
  • 或者给同一个对象注册两个回调

我个人推荐第二种。把不同职责分开,代码更干净。LVGL允许一个对象注册多个回调,它们会按注册顺序依次执行。

4.4 事件传递:冒泡还是捕获?

LVGL的事件传递机制,跟浏览器有点像。事件会从父对象传到子对象,或者反过来。默认是「冒泡」——事件从子对象开始,一路向上传到父对象。

举个例子:你点击了容器里的一个按钮。事件传递顺序是:

  1. 按钮本身
  2. 按钮的父对象(容器)
  3. 容器的父对象(屏幕)

如果你想在容器层统一处理所有子按钮的点击事件,可以给容器注册LV_EVENT_CLICKED回调。这样就不用给每个按钮单独注册了。

实用技巧:在呼吸机的报警处理中,我经常用事件冒泡。所有报警相关的控件都放在一个容器里,容器统一处理报警逻辑。这样新增一个报警控件,不需要改任何代码。

如果你想阻止事件继续冒泡,可以调用lv_event_stop_bubbling(e)。不过说实话,我很少用这个。除非你明确知道自己在做什么,否则别轻易打断事件流。

4.5 小结

这一章的内容,说白了就三句话:

  • LVGL通过HAL层隔离硬件,让你专心写UI。
  • 所有界面元素组成一棵对象树,父管子,子随父。
  • 用户交互通过事件驱动,回调函数里别干重活。

下一章,咱们开始动手画呼吸机的第一个界面。到时候你会看到,对象树和事件模型是怎么在实际项目中跑起来的。

课后动手:打开你的开发板,创建一个屏幕,上面放三个按钮。给每个按钮注册不同的事件回调。然后试试给屏幕(父对象)注册一个事件,看看点击按钮时,父对象的回调会不会被触发。这就是冒泡。