3. LVGL基础架构:软件架构、对象树模型、消息循环与刷新机制

学LVGL,很多人一上来就画控件、调样式,觉得挺爽。但真到了项目里,界面卡了、内存爆了、触摸没反应了,才发现根本不知道底层怎么跑的。

我刚开始做嵌入式GUI那会儿,也踩过这个坑。后来花了两个晚上把LVGL的架构啃了一遍,才明白——搞懂架构,比背100个API都管用。

这一章,咱们就把LVGL的骨架拆开看看。说白了就三件事:软件怎么分层、对象怎么组织、屏幕怎么刷新。

3.1 软件架构:三层分工,各司其职

LVGL的代码结构非常清晰,我习惯把它分成三层:

层级 名称 职责 典型文件
应用层 Application 用户界面逻辑、业务代码 main.c, user_app.c
中间层 LVGL Core 对象管理、事件分发、样式计算、布局 lv_obj.c, lv_event.c, lv_style.c
底层 Driver & Porting 显示驱动、触摸驱动、内存管理、系统时钟 lv_port_disp.c, lv_port_indev.c

你写应用代码的时候,其实是在最上层调用中间层的API。中间层帮你处理完逻辑,最后通过底层驱动把画面刷到屏幕上。

我的习惯: 移植阶段先把底层驱动调稳,再碰上层代码。显示和触摸没搞定,后面全是白费功夫。

这里有个关键点——LVGL不依赖操作系统。它自己有一套完整的事件循环和内存管理。当然,如果你想跑在FreeRTOS上,它也支持得很好。我有个项目就是在RTOS里给LVGL单独开了一个任务,优先级设得比通信任务低一点,效果不错。

3.2 对象树模型:一切皆对象,对象皆父子

LVGL里所有的界面元素都是对象(Object)。按钮是对象,标签是对象,面板是对象,甚至屏幕本身也是对象。

这些对象不是散落的,它们组织成一棵树。

lv_obj_t *scr = lv_scr_act();              // 获取当前活动屏幕
lv_obj_t *panel = lv_obj_create(scr);      // 在屏幕上创建面板
lv_obj_t *btn = lv_btn_create(panel);      // 在面板上创建按钮
lv_obj_t *label = lv_label_create(btn);    // 在按钮上创建标签

你看,每个对象创建时都要指定一个父对象。这样就形成了一棵从屏幕到具体控件的树。

为什么会这样设计?

你想想看,如果所有对象都是平级的,那移动一个面板的时候,面板上的按钮、标签、滑块全都要手动跟着移,多麻烦。有了父子关系,父对象移动,子对象自动跟着走。父对象隐藏,子对象也看不见。父对象销毁,子对象也一起清理。

核心规则: 子对象的坐标是相对于父对象的。父对象的位置变了,子对象的位置(相对于屏幕)也跟着变,但子对象自己的x、y属性不变。

我在项目中遇到过一个问题:创建了一个弹窗,弹窗上有个关闭按钮。结果弹窗移位置了,按钮还留在原地。后来一查,按钮的父对象设成了屏幕,不是弹窗。嗯,这种低级错误,犯过一次就记住了。

3.3 对象树的可视化

下面这张图,是我用SVG画的一个典型对象树结构。你可以直观地看到层级关系。

LVGL 对象树模型示例 活动屏幕 (scr) 背景面板 (panel) 标题标签 (title) 状态栏 (status) 按钮 (btn_ok) 按钮 (btn_cancel) 标签 "确定" 标签 "取消" 父子关系规则 • 子对象坐标相对父对象 • 父隐藏 → 子隐藏 • 父销毁 → 子自动销毁

这棵树,你可以在运行时随时遍历。LVGL提供了lv_obj_get_child()和lv_obj_get_parent()来上下查找。调试的时候特别有用。

3.4 消息循环:LVGL的心脏

LVGL能跑起来,全靠一个死循环。这个循环在lv_task_handler()里,你需要在主循环中反复调用它。

while(1) {
    lv_task_handler();    // LVGL 核心处理
    my_delay(5);          // 适当延时,让出CPU
}

这个函数每次调用,LVGL会做以下几件事:

  1. 处理输入事件:检查触摸、按键、编码器有没有新数据。
  2. 触发事件回调:如果有点击、滑动、长按,调用你注册的事件处理函数。
  3. 执行动画:更新所有正在运行的动画帧。
  4. 处理定时器:检查lv_timer创建的任务是否到期。
  5. 标记脏区域:记录哪些区域需要重绘。

我刚开始用LVGL的时候,以为lv_task_handler()调用越快越好。结果CPU占用率飙到90%,界面反而卡。后来发现,5ms调用一次就足够了,太快了反而浪费。

注意: 不要在中断服务函数里调用 lv_task_handler()。LVGL内部很多操作不是线程安全的。如果用了RTOS,记得把LVGL相关操作放在同一个任务里。

3.5 刷新机制:脏区域与双缓冲

LVGL不会每帧都重绘整个屏幕。那样太慢了。它只重绘发生变化的部分,这部分叫做脏区域(Dirty Area)。

举个例子:你点击了一个按钮,按钮颜色变了。LVGL会把这个按钮所在的矩形区域标记为“脏”。下一次刷新时,只重绘这个矩形,其他区域保持不变。

具体流程是这样的:

// 伪代码:LVGL内部刷新流程
1. 用户操作 → 对象属性变化
2. lv_obj_invalidate(btn) → 标记按钮区域为脏
3. lv_refr_join() → 合并相邻的脏区域
4. lv_refr_area() → 调用 flush_callback 刷新合并后的区域
5. flush_callback → 把数据写入显示缓冲区 → 送到屏幕

这里有个重要的配置——显示缓冲区。LVGL支持三种模式:

模式 缓冲区大小 特点 适用场景
单缓冲 1/10 屏幕 内存占用小,但可能有撕裂 资源紧张的MCU
双缓冲 1/6 屏幕 × 2 无撕裂,流畅 大多数项目
全屏双缓冲 全屏 × 2 最流畅,内存占用大 带外部RAM的设备

我个人建议,能用双缓冲就用双缓冲。单缓冲在快速滑动时容易出现画面撕裂,用户体验很差。我曾经在一个STM32F429的项目上,硬是抠出了两个1/6屏幕的缓冲区,效果比单缓冲好太多了。

3.6 刷新流程全景图

下面这张SVG图,把整个刷新流程串起来了。你可以看到从用户操作到画面显示的全链路。

LVGL 刷新机制流程图 用户操作(触摸/按键) 事件处理 / 属性变化 lv_obj_invalidate() 合并脏区域 → 调用 flush_cb 关键优化点 • 脏区域合并减少刷新次数 • 双缓冲消除撕裂 • flush_cb 要快,别在里面做复杂计算

你看,整个流程其实不复杂。关键就两个地方:标记脏区域要准,flush回调要快。

我曾经在一个项目里,flush_callback里加了个延时,结果整个界面卡成PPT。后来把延时去掉,用DMA传输数据,瞬间流畅了。嗯,这种坑,踩过一次就记住了。

3.7 本章小结

这一章我们拆了LVGL的骨架:

  • 软件架构:应用层、中间层、驱动层,各层职责清晰。
  • 对象树模型:父子层级管理,移动、隐藏、销毁都自动联动。
  • 消息循环:lv_task_handler()是心脏,5ms调用一次刚刚好。
  • 刷新机制:脏区域 + 双缓冲,只重绘变化的部分,高效省资源。