3、UI架构对比:屏幕管理机制、页面生命周期、事件驱动模型差异

好,咱们今天聊点硬核的。屏幕怎么管、页面怎么生怎么死、事件怎么跑——这三个问题,是迁移路上绕不开的坎。我当年从TouchGFX往LVGL切的时候,第一个踩的坑就是屏幕管理。TouchGFX那套Screen-based的思维太根深蒂固了,换到LVGL的Object tree,脑子转不过弯来。

说白了,两个框架的哲学完全不同。一个像页面栈,一个像节点树。你想想看,这背后的差异会影响到你写代码的每一个细节。

3.1 屏幕管理机制:Screen vs Object Tree

TouchGFX里,每个屏幕是一个Screen对象。你切屏,框架帮你做入栈出栈。每个Screen有自己的生命周期,你重写setupScreen()和tearDownScreen()就行。简单、粗暴、好理解。

LVGL呢?它没有“屏幕”这个概念。你看到的“页面”,其实是一个lv_obj,挂在lv_scr_act()下面。你想切屏?把旧的删了,建个新的,或者隐藏旧的显示新的。全得自己来。

核心差异一句话: TouchGFX帮你管页面栈,LVGL只给你一棵树,你怎么砍怎么栽,自己说了算。

我刚开始迁移时,习惯性地在LVGL里找ScreenManager,找了半天没找到。后来才明白,LVGL的设计哲学是“给你工具,不给你流程”。你得自己搭一套页面管理器。

下面这张图,把两个框架的屏幕管理逻辑画清楚了:

TouchGFX vs LVGL 屏幕管理机制对比 TouchGFX:Screen Stack Screen C(当前活动) Screen B(栈中) Screen A(栈底) push push push LVGL:Object Tree lv_scr_act()(根) 页面A(lv_obj) 页面B(lv_obj) 按钮(子节点) 标签(子节点) TouchGFX:框架管理页面栈,开发者只需关注单个Screen内部逻辑 LVGL:开发者需要自行实现页面管理器,但灵活性更高
我的建议: 迁移时别想着把TouchGFX的Screen概念硬搬到LVGL。不如自己写一个轻量级的页面管理器,用lv_obj_t*数组维护页面栈,配合lv_obj_set_hidden()做显隐切换。我项目里就是这么干的,代码量不到200行。

3.2 页面生命周期:谁在管生管死?

TouchGFX的页面生命周期是框架驱动的。你继承Screen,重写下面几个方法:

// TouchGFX 页面生命周期
virtual void setupScreen();    // 页面创建时调用
virtual void tearDownScreen(); // 页面销毁时调用
virtual void handleTickEvent(); // 每帧调用

框架会在合适的时机自动调用这些方法。你不需要关心什么时候创建、什么时候销毁——框架全包了。

LVGL呢?它没有setupScreen、tearDownScreen这种概念。页面的创建和销毁,完全由你手动控制。你调用lv_obj_create(),页面就活了;你调用lv_obj_del(),页面就死了。中间的状态变化,靠事件来通知。

我刚开始写LVGL时,老想着找一个“页面加载完成”的回调。找了半天,发现LVGL的设计思路是:你创建完对象,它就是加载完成了。没有中间状态,没有生命周期钩子。

那怎么办?自己加呗。我常用的做法是:

// LVGL 手动模拟页面生命周期
typedef struct {
    lv_obj_t *page;
    void (*on_create)(lv_obj_t *page);
    void (*on_destroy)(lv_obj_t *page);
    void (*on_tick)(lv_obj_t *page);
} page_manager_t;

void page_create(page_manager_t *mgr) {
    mgr->page = lv_obj_create(lv_scr_act());
    if (mgr->on_create) mgr->on_create(mgr->page);
}

void page_destroy(page_manager_t *mgr) {
    if (mgr->on_destroy) mgr->on_destroy(mgr->page);
    lv_obj_del(mgr->page);
    mgr->page = NULL;
}
注意: LVGL里删除父对象会自动删除所有子对象。如果你在on_destroy里还想访问子对象,记得先保存指针,或者用lv_obj_del_async()延迟删除。我曾经因为没注意这个,在删除页面时顺手把定时器对象也删了,结果定时器回调里访问了野指针——直接硬死机。

两个框架的生命周期差异,我整理了一张表:

生命周期阶段 TouchGFX LVGL
创建 框架自动调用 setupScreen() 手动调用 lv_obj_create()
显示 框架自动切换 手动 lv_scr_load() 或显隐控制
更新 handleTickEvent() 每帧调用 lv_timer_create() 或 LV_ANIM
销毁 框架自动调用 tearDownScreen() 手动调用 lv_obj_del()
资源释放 框架自动回收 需手动释放动态分配的内存

3.3 事件驱动模型:回调 vs 事件系统

TouchGFX的事件处理,说白了就是回调函数。按钮点击?绑定一个回调。滑动条变化?绑定一个回调。简单直接,但有个问题:回调多了,代码容易变成“回调地狱”。

// TouchGFX 事件绑定示例
button1.setAction(button1ClickedHandler);
slider1.setValueChangedAction(slider1ValueChangedHandler);

LVGL的事件模型就灵活多了。它用的是事件系统,所有事件都通过lv_event_t传递。你可以给任意对象绑定事件处理器,而且支持事件冒泡——子对象的事件可以向上传递给父对象。

// LVGL 事件绑定示例
lv_obj_add_event_cb(btn1, btn_event_handler, LV_EVENT_CLICKED, NULL);

static void btn_event_handler(lv_event_t *e) {
    lv_event_code_t code = lv_event_get_code(e);
    lv_obj_t *target = lv_event_get_target(e);
    
    if (code == LV_EVENT_CLICKED) {
        LV_LOG_USER("按钮被点击了");
    }
}

你想想看,这个事件冒泡机制能带来什么好处?比如你有一个页面,里面有很多按钮。你不需要给每个按钮单独绑定事件处理器,只需要在父容器上绑定一个,然后通过lv_event_get_target()判断是哪个子对象触发的。代码量直接减半。

迁移要点: TouchGFX里你习惯给每个控件单独绑回调,到了LVGL,试着用事件冒泡做统一处理。我迁移第一个页面时,把12个按钮的回调合并成了1个事件处理器,代码从80行缩到了20行。

不过,LVGL的事件系统也有坑。我记得有一次,我给一个列表项绑定了LV_EVENT_CLICKED,结果点击时触发了两次。查了半天才发现,列表项本身会触发一次,它的父容器(列表)也会因为事件冒泡再触发一次。解决方案很简单:在事件处理器里检查lv_event_get_target()和lv_event_get_current_target()是否一致。

// 避免事件冒泡导致重复处理
static void list_item_handler(lv_event_t *e) {
    lv_obj_t *target = lv_event_get_target(e);
    lv_obj_t *current = lv_event_get_current_target(e);
    
    if (target != current) return; // 只处理直接绑定的事件
    
    // 真正的处理逻辑
    LV_LOG_USER("列表项被点击");
}

最后说一句,LVGL的事件系统还有一个杀手级功能:用户可以自定义事件。你可以定义LV_EVENT_USER_START + 1、LV_EVENT_USER_START + 2,用来做页面间的通信。这个在TouchGFX里实现起来比较麻烦,但在LVGL里,几行代码就搞定了。

个人经验: 我习惯在项目里定义一个全局事件ID枚举,把页面切换、数据更新、弹窗显示这些都做成自定义事件。这样代码的耦合度大大降低,维护起来特别舒服。你试试看,会上瘾的。