嵌入式GUI页面栈链表实现:头插法push/pop与双向数据传参

上一篇聊环形缓冲区,说它和链表是一对好搭档:一个管流式数据,一个管离散节点。这篇就把链表请出来,讲它在嵌入式 GUI 里最值钱的一个用法——页面管理和数据流转。

做带屏项目的朋友应该都有体会:项目初期两三个页面,几个全局变量加 switch-case 就能搞定。可页面一多,问题全冒出来了。页面之间怎么跳转?返回键怎么处理?A 页面的参数怎么传给 B?B 改了数据怎么通知 A 刷新?这些事看着琐碎,处理不好代码就迅速腐化。我见过不少项目,GUI 部分占了整个工程一半以上代码量,大半还是各种跳转判断和数据搬运的重复逻辑。

后来我用链表统一管理页面生命周期和数据交互,效果出奇地好。链表天然适合"动态增减、顺序访问"的场景,在资源有限的 MCU 上也足够轻量。

原始switch-case痛点 vs 链表页面栈对比

先看"原始"写法有多难受

static uint8_t current_page = PAGE_HOME;

void key_event_handler(uint8_t key)
{
    switch (current_page) {
        case PAGE_HOME:
            if (key == KEY_ENTER) {
                page_home_exit();
                page_setting_enter();
                current_page = PAGE_SETTING;
            }
            break;
        case PAGE_SETTING:
            if (key == KEY_ENTER) {
                page_setting_exit();
                page_wifi_enter();
                current_page = PAGE_WIFI;
            } else if (key == KEY_BACK) {
                page_setting_exit();
                page_home_enter();
                current_page = PAGE_HOME;
            }
            break;
        /* ... 每加一个页面,这里就多一坨 */
    }
}

这种写法的毛病一堆:每个页面都要"认识"其他页面,耦合死死的;返回路径硬编码,跳转链路一变就得到处改;数据传递靠全局变量,页面一多就分不清谁在读谁在写;没有统一的生命周期管理,资源申请释放全靠人记。

加一个页面,要在 switch 里加一坨 case,还要在所有能跳到它的页面里加跳转逻辑。页面数到十几个的时候,这种代码没人敢动,动一处崩三处。

为什么链表天然适合干这个

GUI 页面管理的核心需求就三条:动态打开关闭页面(数量不固定)、记住打开顺序(用于返回)、相邻页面间传数据。

这不就是链表最擅长的事吗?用数组也能做页面栈,但数组大小要预定义,RAM 紧张的 MCU 上不灵活。链表用多少申请多少,页面关了立刻释放,内存利用率高。而且链表的"头插法"天然就是一个栈——新页面插到头部当栈顶,关闭就摘掉栈顶露出下一个,返回逻辑零成本。

这是链表比数组香的关键:数组栈要预定义最大深度,链表栈深度随用随长。MCU 上 RAM 按字节抠,预定义大了浪费、小了溢出,链表把这个两难化解了。

数据结构:一个节点管一个页面

typedef void (*page_callback_t)(void *user_data);
typedef void (*page_event_handler_t)(uint8_t event, void *user_data);

typedef struct page_node {
    struct page_node      *next;       /* 链表指针,指向下一个页面 */
    const char            *name;       /* 页面名称,方便调试 */
    page_callback_t        on_create;  /* 页面创建时调用 */
    page_callback_t        on_show;    /* 页面显示时调用 */
    page_callback_t        on_hide;    /* 页面隐藏时调用 */
    page_callback_t        on_destroy; /* 页面销毁时调用 */
    page_event_handler_t   on_event;   /* 按键/触摸等事件处理 */
    void                  *user_data;  /* 页面私有数据,页面间交互载体 */
} page_node_t;

typedef struct {
    page_node_t *top;        /* 栈顶页面(当前显示的页面) */
    uint8_t      page_count;
} page_manager_t;

static page_manager_t g_page_mgr = { .top = NULL, .page_count = 0 };

重点看这个结构。next 指针把页面串成链表;四个生命周期回调(on_create/on_show/on_hide/on_destroy)管资源;on_event 处理按键触摸;user_data 是页面私有数据,也是页面间传数据的载体。

这套设计的关键是把"页面"抽象成一个带行为的节点,管理器只管链表操作和回调触发,不关心具体页面长啥样。页面自己实现回调,管理器统一调度,解耦干净。这其实就是上一篇事件驱动思路在 GUI 层的延伸——把页面也当成"事件 + 回调"来组织。

page_node结构 + 头插法页面栈 push/pop

核心操作:push 和 pop

push 用头插法:先把当前栈顶隐藏,新页面插到头部成栈顶,再依次调 on_create、on_show。

int page_push(page_node_t *page, void *user_data)
{
    if (page == NULL) return -1;

    /* 先隐藏当前栈顶 */
    if (g_page_mgr.top && g_page_mgr.top->on_hide) {
        g_page_mgr.top->on_hide(g_page_mgr.top->user_data);
    }

    page->user_data = user_data;
    page->next = g_page_mgr.top;   /* 头插法:新页面成栈顶 */
    g_page_mgr.top = page;
    g_page_mgr.page_count++;

    if (page->on_create) page->on_create(user_data);
    if (page->on_show)   page->on_show(user_data);
    return 0;
}

pop 反过来:隐藏并销毁栈顶,栈顶指向下一个,恢复显示上一个页面(再调一次 on_show)。

int page_pop(void)
{
    page_node_t *top = g_page_mgr.top;
    if (top == NULL) return -1;

    if (top->on_hide)    top->on_hide(top->user_data);
    if (top->on_destroy) top->on_destroy(top->user_data);

    g_page_mgr.top = top->next;
    top->next = NULL;
    g_page_mgr.page_count--;

    /* 恢复显示上一个页面 */
    if (g_page_mgr.top && g_page_mgr.top->on_show) {
        g_page_mgr.top->on_show(g_page_mgr.top->user_data);
    }
    return 0;
}

事件分发更简单,直接丢给栈顶页面的 on_event:

void page_dispatch_event(uint8_t event)
{
    if (g_page_mgr.top && g_page_mgr.top->on_event) {
        g_page_mgr.top->on_event(event, g_page_mgr.top->user_data);
    }
}

注意 push 里"先隐藏旧的再创建新的"、pop 里"先销毁再恢复上一个"的顺序。这俩顺序是生命周期正确性的关键,反了会出现两个页面同时显示、或者资源没释放的 bug。我自己就踩过 pop 时先恢复上一个再销毁当前页的坑,结果新页面的 on_show 里访问的资源被当前页 on_destroy 释放了,直接野指针。

页面生命周期回调时序:create→show→[hide→show]→hide→destroy

数据交互:正向传参和反向传参

页面间传数据是 GUI 的老大难。这套方案用一个 user_data 字段搞定两个方向。

正向传参(打开页面时带数据):push 时把数据指针塞进 user_data,目标页面的 on_create 里取出来用。

typedef struct {
    char     ssid[32];
    char     password[64];
    uint8_t  signal_strength;
    bool     is_connected;
} wifi_cfg_t;

void on_home_enter_wifi(void)
{
    static wifi_cfg_t wifi_data;
    strcpy(wifi_data.ssid, "MyRouter_5G");
    wifi_data.signal_strength = 75;
    wifi_data.is_connected = true;
    page_push(&page_wifi_setting, &wifi_data);
}

void wifi_page_on_create(void *user_data)
{
    wifi_cfg_t *cfg = (wifi_cfg_t *)user_data;
    label_set_text(lbl_ssid, cfg->ssid);
    progressbar_set_value(bar_signal, cfg->signal_strength);
}

反向传参(子页面把结果带回父页面)是最巧妙的地方。子页面 pop 后,父页面的 on_show 会被重新触发。在这个时机去读子页面留下的数据就行——子页面把结果写到一个父页面能访问到的结构体里,on_show 里刷新 UI。

这个设计把"返回刷新"变成了生命周期回调的自然副产品,不用专门搞个回调注册机制,不用发事件,不用全局变量。父页面只要设计成"on_show 里重新读数据刷新",就自动支持子页面回传。这是我觉得这套方案最优雅的地方。

数据交互:正向user_data传参 + 反向on_show回读

落地建议

第一,页面超 5 个再上这套,两三个页面 switch-case 更直白,别过度设计。第二,user_data 尽量传结构体指针别传零散变量,类型清楚好维护,别用 void* 一把梭然后到处强转。第三,on_create 申请的资源必须在 on_destroy 释放,配对写,别漏,漏一个就是内存泄漏。第四,反向传参靠 on_show 重新触发,父页面要设计成"可重复进入刷新"的,别假设只 on_create 一次,状态别在 on_show 里重置。第五,链表节点可以是静态分配(每个页面一个全局静态节点),不一定动态 malloc,RAM 可控还省去内存管理。

这套方案不依赖特定 GUI 库,LVGL、emWin 都能套,核心代码两三百行。它真正解决的不是"怎么画界面",而是"怎么让页面和数据有组织地流动"——这才是嵌入式 GUI 做到后面真正拼的东西。画界面是美工的事,组织好页面和数据才是工程师的活。

下一篇聊聊数组和链表的选型,把这对搭档的取舍讲透。

有用的话点个在看,让更多被 GUI 跳转逻辑绕晕的嵌入式工程师看到。


标签:嵌入式 链表 GUI 页面管理 数据结构

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值