3. 架构设计对比:LVGL的组件化架构 vs TouchGFX的MVC架构
聊到嵌入式GUI,很多人上来就比控件多、动画炫。但我个人觉得,架构设计才是决定项目能走多远的关键。你想想看,一个跑在MCU上的界面,如果任务调度乱成一锅粥,事件循环卡死,再漂亮的UI也是白搭。
这一章,我们就来扒开LVGL和TouchGFX的底层骨架。看看它们各自是怎么组织代码、怎么调度任务、怎么处理事件的。
3.1 LVGL的组件化架构:一切皆对象
LVGL的设计哲学很直接——万物皆组件。屏幕是一个组件,按钮是一个组件,标签也是一个组件。它们都继承自同一个基类 lv_obj_t。
我在做智能家居面板时,就用这种思路搭过界面。每个传感器卡片都是一个独立组件,修改一个不会影响其他,爽得很。
3.1.1 组件树与父子关系
LVGL里所有组件构成一棵树。根节点是屏幕(lv_scr_act()),下面挂载各种容器和控件。子组件的位置、旋转、透明度,都会受父组件影响。
// 创建一个屏幕
lv_obj_t *scr = lv_obj_create(NULL);
lv_scr_load(scr);
// 创建一个容器(父组件)
lv_obj_t *cont = lv_obj_create(scr);
lv_obj_set_size(cont, 200, 150);
// 创建一个按钮(子组件)
lv_obj_t *btn = lv_btn_create(cont);
lv_obj_set_pos(btn, 10, 10);
这种树状结构的好处是:布局管理非常直观。你只需要关心父子关系,不用手动计算全局坐标。
3.1.2 组件的生命周期
每个组件从创建到销毁,经历四个阶段:
| 阶段 | 触发时机 | 典型操作 |
|---|---|---|
| 创建 | lv_xxx_create() |
分配内存、初始化属性 |
| 布局 | 父组件大小变化时 | 自动调用 lv_obj_set_pos/size |
| 渲染 | 每帧刷新时 | 绘制背景、边框、内容 |
| 销毁 | lv_obj_del() |
释放内存、移除事件 |
lv_obj_del_async() 在中断中删除组件,结果直接死机。LVGL的组件操作不是线程安全的,一定要在主循环或任务中执行。
3.2 TouchGFX的MVC架构:分离的艺术
TouchGFX走的是另一条路——MVC模式。Model、View、Controller各司其职。说白了,就是把数据、界面、逻辑拆得干干净净。
我第一次接触TouchGFX时,觉得这玩意儿太“重”了。但后来做一个需要频繁更新数据的仪表盘项目,才发现MVC真香。数据变了,界面自动刷新,不用手动去翻控件。
3.2.1 三层的职责划分
- Model(模型):存放业务数据。比如温度值、开关状态。不关心怎么显示。
- View(视图):负责界面显示。由TouchGFX Designer生成的UI代码都在这里。
- Controller(控制器):处理用户交互,更新Model,通知View刷新。
// Model 层
class Model {
public:
int getTemperature() { return temperature; }
void setTemperature(int t) {
temperature = t;
// 通知监听器
if (listener) listener->modelChanged();
}
private:
int temperature = 25;
ModelListener *listener;
};
// View 层(由 Designer 生成)
class ScreenView : public View {
public:
void updateTemperature(int value) {
// 更新文本控件
textArea.setValue(value);
}
};
// Controller 层
class ScreenPresenter : public Presenter {
public:
void buttonClicked() {
model->setTemperature(model->getTemperature() + 1);
}
};
3.2.2 数据绑定与自动刷新
TouchGFX通过 ModelListener 接口实现数据绑定。Model变化时,自动调用View的更新方法。你不需要在循环里轮询数据。
3.3 任务调度机制对比
嗯,这里要注意。两个框架的任务调度思路完全不同,直接决定了你的主循环怎么写。
3.3.1 LVGL:手动滴答驱动
LVGL不依赖RTOS。它只需要你定期调用两个函数:
// 1. 提供系统心跳(通常每1ms调用一次)
lv_tick_inc(1);
// 2. 在主循环中调用任务处理
while(1) {
lv_task_handler(); // 处理所有LVGL内部任务
// 你的其他业务代码
delay(5);
}
lv_task_handler() 内部会遍历所有注册的任务(动画、刷新、输入处理),按优先级和时间片执行。默认情况下,它每5ms处理一次。
lv_task_handler(),避免阻塞高优先级的中断或通信任务。
3.3.2 TouchGFX:OS封装与事件队列
TouchGFX天生为RTOS设计。它内部维护一个事件队列,所有交互都通过队列传递。
// TouchGFX 的典型主循环(在OS任务中)
while(1) {
// 处理来自触摸、按键的事件
osMessageQueueGet(queue, &msg, NULL, portMAX_DELAY);
// 将事件分发到当前屏幕
presenter->handleEvent(msg);
// 触发渲染
hal->vSync();
}
TouchGFX的渲染由VSync信号驱动。它不会像LVGL那样持续刷新,而是只在需要时重绘。这大大降低了CPU占用。
| 特性 | LVGL | TouchGFX |
|---|---|---|
| 调度方式 | 手动调用 lv_task_handler() |
OS事件队列 + VSync驱动 |
| RTOS依赖 | 可选(裸机也能跑) | 强烈推荐(裸机需自己实现队列) |
| 刷新策略 | 周期性全刷新 | 按需局部刷新 |
| CPU占用 | 较高(持续刷新) | 较低(事件驱动) |
3.4 事件循环机制深度拆解
事件循环是GUI的“心脏”。两个框架的处理方式,体现了它们的设计哲学差异。
3.4.1 LVGL:事件冒泡与分组
LVGL的事件机制类似Web的DOM事件。点击一个按钮,事件会从按钮冒泡到父容器,直到被处理或到达屏幕。
// 为按钮添加点击事件
lv_obj_add_event_cb(btn, btn_click_cb, LV_EVENT_CLICKED, NULL);
// 事件回调
static void btn_click_cb(lv_event_t *e) {
lv_obj_t *btn = lv_event_get_target(e);
LV_LOG_USER("Button clicked!");
}
事件类型有几十种:LV_EVENT_PRESSED、LV_EVENT_VALUE_CHANGED、LV_EVENT_REFRESH 等等。你可以为同一个组件注册多个事件回调。
lv_obj_del() 删除触发事件的组件,导致野指针崩溃。正确的做法是用 lv_event_send() 延迟处理,或者用 lv_async_call()。
3.4.2 TouchGFX:虚拟函数与屏幕栈
TouchGFX的事件处理更“C++化”。每个屏幕(View)重写虚函数来处理特定事件:
class ScreenView : public View {
public:
// 处理触摸事件
virtual void handleClickEvent(const ClickEvent &evt) {
if (evt.getType() == ClickEvent::RELEASED) {
// 调用Presenter处理业务逻辑
presenter->onButtonReleased();
}
}
// 处理按键事件
virtual void handleKeyEvent(uint8_t key) {
if (key == KEY_ENTER) {
presenter->onConfirm();
}
}
};
TouchGFX维护一个屏幕栈。切换屏幕时,旧屏幕入栈,新屏幕出栈。返回上一屏时,直接从栈顶弹出,状态自动恢复。
3.5 架构对比总结:一张图看懂
下面这张SVG图,把两个框架的架构差异画得明明白白。我建议你保存下来,做项目选型时对照着看。
3.6 选型建议:什么时候用哪个?
说了这么多,到底怎么选?我根据自己的项目经验,给你几个判断标准:
- 资源受限(Flash < 512KB,RAM < 128KB):选LVGL。它能在裸机上跑,内存占用可控。
- 需要复杂数据绑定和屏幕切换:选TouchGFX。MVC架构让代码更清晰,尤其适合多页面应用。
- 团队C++功底强:TouchGFX的虚函数和继承体系用起来很顺手。
- 团队习惯C语言:LVGL的纯C接口更友好,学习成本低。
- 项目需要频繁OTA更新UI:LVGL的组件化设计更容易做动态加载。