嵌入式LVGL UI架构实践:MVP分层 + 双链表路由,构建低耦合、易维护的嵌入式界面

嵌入式LVGL UI架构实践:MVP分层 + 双链表路由,构建低耦合、易维护的嵌入式界面

在嵌入式 SOC 的人机交互开发中,LVGL 凭借轻量、丰富的控件生态成为主流选择。但很多项目在页面增多、业务变复杂后,很容易陷入 "单文件堆代码" 的困境:业务逻辑与控件操作混写、页面跳转硬编码、内存管理全靠人工、新增一个页面要改 N 处代码。

本文基于纯 C 语言实现,结合嵌入式资源受限的特点,分享一套可直接落地的 UI 架构方案:页面内采用 MVP 分层解耦业务与 UI,页面间采用双链表路由框架统一管理生命周期与跳转。整套方案不依赖额外组件,适配从 MCU 到 Cortex-A 级各类嵌入式 SOC,可有效解决代码耦合、维护困难、内存不可控等痛点。

一、先看痛点:嵌入式 UI 代码的 "典型坏味道"

在没有架构约束时,LVGL 项目最常见的写法就是 "一锅粥":所有控件创建、事件回调、业务计算、硬件控制全部写在同一个 C 文件里。

c 复制代码
// 反例:典型的无分层温控页面
static int cur_temp = 25;
static bool heat_on = false;

void temp_panel_create(lv_obj_t *parent)
{
    lv_obj_t *label = lv_label_create(parent);
    lv_obj_t *btn = lv_btn_create(parent);
    lv_obj_add_event_cb(btn, heat_click_cb, LV_EVENT_CLICKED, label);
}

void heat_click_cb(lv_event_t *e)
{
    lv_obj_t *label = lv_event_get_user_data(e);
    // 业务逻辑、硬件控制、UI修改全写在回调里
    heat_on = !heat_on;
    HAL_GPIO_WritePin(HEAT_PORT, HEAT_PIN, heat_on ? SET : RESET);
    lv_label_set_text(label, heat_on ? "加热中" : "已关闭");
}

这种写法在单页面、简单业务下看似高效,但项目迭代后会暴露三大核心问题:

  1. 耦合严重,无法测试:业务逻辑与 LVGL 控件深度绑定,想验证温度规则必须烧录到带屏幕的硬件上,无法做纯逻辑单元测试。

  2. 无法复用,改造成本高:同样的温控逻辑换个 UI 样式、换个屏幕分辨率,几乎要重写一遍代码。

  3. 页面管理混乱:多页面跳转时,手动控制控件显隐、手动管理内存、返回逻辑散落在各处,页面越多越容易出野指针和内存泄漏。

我们做架构的目标很明确:在嵌入式有限的资源下,用最小的 overhead 实现职责分离,让业务逻辑可测试、页面跳转可管控、内存分配可预期

二、整体架构总览:分层治理,内外分离

整套架构分为两个层级,各司其职,互不侵入:

  • 页面内层:MVP 分层架构

    解决单个页面内部的代码组织问题,将业务数据、UI 展示、逻辑调度彻底拆开,让业务与 UI 互不干扰。

  • 页面间层:双链表路由框架

    解决多页面的跳转、返回、生命周期、事件分发问题,统一管理所有页面的创建、销毁、前后台切换。

两者的依赖关系是单向的:页面内部的 MVP 实现不需要知道路由的存在,路由框架也不关心页面内部用了什么架构,只通过标准的生命周期回调对接。这种设计保证了两边可以独立迭代、独立测试。

核心设计原则:

  1. 边界清晰:每层只做自己职责内的事,绝不越界;

  2. 内存优先:优先静态分配,限制动态内存使用,避免碎片;

  3. 最小侵入:不强推复杂概念,C 语言原生实现,无额外依赖。

三、页面内架构:C 语言落地 MVP,彻底分离业务与 UI

MVP(Model-View-Presenter)的核心思想是用 Presenter 作为中间层,彻底隔离 View 与 Model。在没有面向对象语法的 C 语言中,我们用「结构体封装状态 + 函数指针模拟接口」的方式实现。

3.1 三层职责与边界铁律

层级 核心职责 可以依赖 绝对禁止
Model(模型层) 业务数据存储、业务规则计算、硬件操作封装 只依赖标准库与硬件驱动 禁止出现任何 LVGL 相关代码,禁止操作 UI 控件
View(视图层) 控件创建、UI 渲染、事件转发 只依赖 LVGL 与 Presenter 接口 禁止编写业务判断逻辑,禁止直接修改业务数据
Presenter(呈现层) 事件接收、业务调度、UI 更新 依赖 Model 与 View 抽象接口 禁止直接操作 LVGL 控件,只能调用 View 接口

📌 MVP 铁律:View 与 Model 永远不能直接通信,所有交互必须经过 Presenter。

3.2 关键设计:用函数指针表抽象 View 接口

解耦的核心是Presenter 不依赖具体的 LVGL 实现,只依赖一个抽象的 View 能力接口。我们用函数指针表模拟面向对象中的 "接口" 概念:

c 复制代码
// temp_view_if.h:View抽象接口,与LVGL完全无关
typedef struct {
    void (*update_temp)(void *ctx, int32_t temp);
    void (*update_heat_state)(void *ctx, bool is_heating);
    void (*show_alarm)(void *ctx, bool show);
} temp_view_if_t;

Presenter 只持有这个接口指针,至于接口背后是 LVGL、数码管还是串口打印,Presenter 完全不关心。这也是 MVP 可测试性的核心:单元测试时可以用 Mock 接口替代真实 UI,纯逻辑验证业务流程。

3.3 完整落地:温控面板 MVP 实现

我们以工业常见的温控面板为例,完整实现三层结构。

① Model 层:纯业务内核

Model 是整个页面的业务心脏,完全独立于 UI,可以单独编译、单独测试。

c 复制代码
// temp_model.h
typedef struct {
    int32_t cur_temp;
    int32_t threshold;
    bool is_heating;
    bool is_alarm;
} temp_model_t;

void temp_model_init(temp_model_t *m, int32_t init_temp, int32_t thr);
void temp_model_update_temp(temp_model_t *m, int32_t new_temp);
void temp_model_toggle_heat(temp_model_t *m);
int32_t temp_model_get_temp(const temp_model_t *m);
bool temp_model_is_heating(const temp_model_t *m);
bool temp_model_is_alarm(const temp_model_t *m);
c 复制代码
// temp_model.c
static void update_state(temp_model_t *m)
{
    // 所有业务规则收敛在这里:温度低于阈值自动加热,超阈值5度告警
    m->is_heating = (m->cur_temp < m->threshold);
    m->is_alarm = (m->cur_temp > m->threshold + 5);
}

void temp_model_update_temp(temp_model_t *m, int32_t new_temp)
{
    m->cur_temp = new_temp;
    update_state(m);
}
② Presenter 层:中间协调者

Presenter 负责承接 View 的事件,调用 Model 处理业务,再通过 View 接口更新 UI,本身不包含任何业务规则,也不接触任何控件。

c 复制代码
// temp_presenter.h
typedef struct {
    temp_model_t *model;
    const temp_view_if_t *view_if;
    void *view_ctx;
} temp_presenter_t;

void temp_presenter_init(temp_presenter_t *pres, temp_model_t *model,
                         const temp_view_if_t *view_if, void *view_ctx);
void temp_presenter_refresh_all(temp_presenter_t *pres);
void temp_presenter_on_heat_click(temp_presenter_t *pres);
void temp_presenter_on_temp_update(temp_presenter_t *pres, int32_t new_temp);
c 复制代码
// temp_presenter.c
void temp_presenter_on_heat_click(temp_presenter_t *pres)
{
    // 1. 通知Model改状态
    temp_model_toggle_heat(pres->model);
    // 2. 通过接口通知View更新UI
    pres->view_if->update_heat_state(pres->view_ctx, temp_model_is_heating(pres->model));
    pres->view_if->show_alarm(pres->view_ctx, temp_model_is_alarm(pres->model));
}
③ View 层:LVGL 具体实现

View 层只做两件事:创建 LVGL 控件、实现 View 接口、把用户事件转发给 Presenter。

c 复制代码
// temp_view_lvgl.c
typedef struct {
    lv_obj_t *container;
    lv_obj_t *label_temp;
    lv_obj_t *label_heat;
    temp_model_t model;
    temp_presenter_t presenter;
} temp_page_t;

// 实现View接口:将抽象调用翻译成LVGL操作
static void view_update_temp(void *ctx, int32_t temp)
{
    temp_page_t *page = (temp_page_t *)ctx;
    lv_label_set_text_fmt(page->label_temp, "当前温度: %d℃", temp);
}

// 按钮事件:纯转发,不做任何业务判断
static void btn_heat_cb(lv_event_t *e)
{
    temp_presenter_t *pres = (temp_presenter_t *)lv_event_get_user_data(e);
    temp_presenter_on_heat_click(pres);
}

3.4 嵌入式 MVP 的适配要点

  1. 静态分配优先:页面结构体、Model、Presenter 全部使用静态变量或静态分配,避免频繁 malloc 产生内存碎片。

  2. 接口粒度适中:不要一个接口只改一个字符,也不要一个接口刷新全页面,按功能模块划分,平衡代码量与刷新效率。

  3. 生命周期对齐:View 的创建与销毁要和 Presenter、Model 的初始化与清理严格对应,防止出现野指针回调。

四、页面间架构:双链表路由框架,统一管理页面生命周期

解决了单页面的分层问题,接下来解决多页面的管理问题。我们采用双链表设计,将 "页面静态定义" 和 "运行时实例" 彻底分开,兼顾扩展性与内存可控性。

4.1 设计思路:双链表各司其职

  • A 链表:路由注册表(Route List)

    存储所有页面的静态定义,包括页面名称、属性标志、生命周期回调、全局参数。它是页面的 "元数据手册",程序启动时注册,全局常驻。

  • B 链表:历史栈(History Stack)

    双向链表实现的栈结构,存储页面的运行时实例。每次打开新页面压栈,返回时出栈,天然支持逐级返回、多级跳转。

这种设计的优势很明显:

  • 新增页面只需要注册一条路由,不影响历史栈逻辑;

  • 页面定义与运行实例分离,同一个路由可以创建多个实例,也可以做单例复用;

  • 双向链表支持灵活的栈操作:压栈、出栈、替换、清空到指定页。

4.2 核心数据结构设计

① 基础常量与标志位

用位标志控制页面行为,无需修改结构体即可扩展特性。

c 复制代码
#define ROUTER_MAX_HISTORY  8   // 最大栈深度,防止无限压栈

// 路由标志位
#define ROUTE_FLAG_ROOT          0x01  // 根页面,不可被清除
#define ROUTE_FLAG_SINGLE_TOP    0x02  // 栈顶复用,避免重复打开
#define ROUTE_FLAG_KEEP_INSTANCE 0x04  // 后台保留实例,不销毁
#define ROUTE_FLAG_NO_HISTORY    0x08  // 不加入历史栈
② 路由定义(A 链表节点)
c 复制代码
// 生命周期回调集
typedef struct {
    void* (*create)(const void *params, const router_nav_info_t *info);
    void  (*enter)(void *instance, const router_nav_info_t *info);
    void  (*exit)(void *instance);
    void  (*destroy)(void *instance);
    int   (*process)(void *instance, const router_event_base_t *event);
} router_handlers_t;

// 路由节点
typedef struct router_node {
    const char *name;               // 路由唯一名称(静态字符串)
    uint32_t flags;                 // 属性标志
    void *route_params;             // 路由级静态参数
    router_handlers_t handlers;     // 生命周期回调
    struct router_node *next;
} router_route_t;
③ 历史栈节点(B 链表节点)

配合静态内存池,零动态分配历史节点:

c 复制代码
typedef struct router_history_node {
    router_route_t *route;
    void *instance;
    bool is_active;
    struct router_history_node *prev;
    struct router_history_node *next;
} router_history_node_t;

// 路由管理器
typedef struct router {
    router_route_t *route_list;
    router_history_node_t *history_top;
    uint8_t history_count;
    // 静态节点池,预分配,避免malloc
    router_history_node_t node_pool[ROUTER_MAX_HISTORY];
    uint8_t pool_used;
} router_t;

4.3 闭环生命周期管理

路由框架最核心的价值,就是统一接管所有页面的生命周期,避免人工管理的疏漏。我们定义四个标准生命周期,状态流转只能由路由触发:

Plain 复制代码
已销毁 → create → 后台 → enter → 前台 → exit → 后台 → destroy → 已销毁

核心导航操作的生命周期流转:

  • PUSH(压栈跳转) :当前页执行exit→ 新页执行create+enter→ 新节点入栈;非常驻页面后台时会执行destroy释放内存。

  • POP(出栈返回) :当前页执行exit+destroy→ 节点出栈 → 上一页执行enter恢复前台。

  • REPLACE(替换):当前页销毁出栈 → 新页创建入栈,历史栈深度不变。

4.4 事件分发机制

嵌入式系统通常有物理按键、定时器、系统消息等全局事件,路由框架提供统一的事件分发入口:

  1. 事件优先派发给当前前台页面;

  2. 页面process回调返回 0 表示事件已消费,终止分发;

  3. 返回非 0 表示未消费,事件冒泡给路由框架做默认处理(比如物理返回键默认执行 POP)。

c 复制代码
int router_dispatch_event(router_t *router, const router_event_base_t *event)
{
    if (!router->history_top) return -1;
    router_route_t *cur = router->history_top->route;
    
    if (cur->handlers.process) {
        int ret = cur->handlers.process(router->history_top->instance, event);
        if (ret == 0) return 0; // 已消费
    }
    
    // 未消费,框架默认处理
    if (event->event_type == ROUTER_EVENT_KEY_BACK) {
        return router_pop(router);
    }
    return -1;
}

4.5 嵌入式专属优化

  1. 静态节点池:历史节点全部预分配在数组里,用完回收,零动态内存碎片。

  2. 栈深度限制:配置最大历史层数,防止用户无限跳转导致内存溢出。

  3. 常驻 / 销毁策略 :常用首页开启KEEP_INSTANCE保留实例,跳转快;临时页面关闭时直接销毁,节省 RAM。

  4. 静态字符串路由名:要求路由名使用静态字符串,不占用堆内存,也避免字符串拷贝开销。

五、双剑合璧:MVP 页面接入路由框架的标准范式

MVP 负责页面内分层,路由负责页面间管理,两者通过生命周期回调无缝对接。接入规则非常简单:页面实例的根容器作为 instance 指针,路由的四个生命周期回调对应 MVP 的初始化、显隐、销毁

5.1 完整接入示例

c 复制代码
// temp_page_router.c:温控页面的路由适配层
typedef struct {
    lv_obj_t *container;   // 页面根容器
    temp_model_t model;
    temp_presenter_t presenter;
} temp_page_inst_t;

// 路由create回调:创建页面实例,初始化MVP
static void* temp_page_create(const void *params, const router_nav_info_t *info)
{
    temp_page_inst_t *inst = malloc(sizeof(temp_page_inst_t));
    inst->container = lv_obj_create(lv_scr_act());
    lv_obj_add_flag(inst->container, LV_OBJ_FLAG_HIDDEN);
    
    // 初始化MVP三层
    temp_model_init(&inst->model, 25, 30);
    temp_presenter_init(&inst->presenter, &inst->model, &temp_view_if, inst);
    return inst;
}

// 路由enter回调:页面进入前台,显示UI
static void temp_page_enter(void *instance, const router_nav_info_t *info)
{
    temp_page_inst_t *inst = (temp_page_inst_t*)instance;
    lv_obj_clear_flag(inst->container, LV_OBJ_FLAG_HIDDEN);
    temp_presenter_refresh_all(&inst->presenter);
}

// 路由exit回调:页面退后台,隐藏UI
static void temp_page_exit(void *instance)
{
    temp_page_inst_t *inst = (temp_page_inst_t*)instance;
    lv_obj_add_flag(inst->container, LV_OBJ_FLAG_HIDDEN);
}

// 路由destroy回调:销毁页面,释放资源
static void temp_page_destroy(void *instance)
{
    temp_page_inst_t *inst = (temp_page_inst_t*)instance;
    lv_obj_del(inst->container);
    free(inst);
}

// 路由定义,注册到路由管理器
router_route_t route_temp_panel = {
    .name = "temp_panel",
    .flags = ROUTE_FLAG_NONE,
    .handlers = {
        .create = temp_page_create,
        .enter = temp_page_enter,
        .exit = temp_page_exit,
        .destroy = temp_page_destroy,
        .process = temp_page_process
    }
};

5.2 全流程调用链路

以 "首页点击按钮跳转到温控面板" 为例,完整调用链如下:

  1. 首页按钮点击事件触发,调用 router_push(router, "temp_panel", NULL, 0)

  2. 路由框架查找对应路由,执行首页的exit回调,首页隐藏并进入后台;

  3. 路由调用温控页的create回调,创建页面实例、初始化 MVP 三层;

  4. 路由调用温控页的enter回调,显示页面并刷新 UI;

  5. 用户操作温控页,事件在 MVP 三层内流转,与路由完全无关;

  6. 用户按返回键,事件分发到路由框架,执行router_pop

  7. 温控页执行exit+destroy,实例销毁;

  8. 首页从后台恢复,执行enter回调,重新显示。

整个流程中,页面内部的 MVP 逻辑完全感知不到路由的存在,路由也完全不侵入页面业务,真正做到了 "高内聚、低耦合"。

六、架构复盘:优缺点与选型建议

6.1 核心优势

  1. 业务逻辑可测试:Model 层完全独立,可在 PC 端做单元测试,不用依赖硬件屏幕,大幅提升调试效率。

  2. 代码复用性强:同样的 Model+Presenter 逻辑,换一套 View 实现即可适配不同屏幕规格,业务代码零修改。

  3. 内存完全可控:静态节点池、栈深度限制、常驻 / 销毁策略,让 RAM 占用可预估、可配置。

  4. 扩展能力充足:新增页面只需注册路由,新增导航模式只需扩展路由 API,不影响现有页面代码。

6.2 局限性与注意事项

  1. 简单页面存在过度设计:如果项目只有 1-2 个简单页面,硬套 MVP 和路由反而会增加代码量,得不偿失。

  2. 有一定学习成本:团队需要理解分层边界、接口抽象、生命周期概念,新人上手需要时间。

  3. 调试链路变长:出现 UI 问题时,需要排查 Model→Presenter→View→路由整条链路,比单文件代码稍复杂。

6.3 选型建议

  • 推荐使用:页面数量≥5 个、业务逻辑复杂、需要长期迭代维护、有单元测试需求的项目;

  • 谨慎使用:极小资源 MCU(RAM<64KB)、页面少于 3 个、一次性交付无需迭代的项目。

七、延伸优化方向

这套基础架构可以根据项目需求继续扩展,常用的优化方向包括:

  1. 跳转结果回传 :增加set_result机制,实现类似 Android 的页面间数据回传;

  2. 转场动画:在 enter/exit 回调中接入 LVGL 动画,实现页面切换的淡入淡出、滑动效果;

  3. 跳转拦截器:注册全局拦截钩子,跳转前做权限校验、密码验证等逻辑;

  4. 全局状态管理:引入轻量全局状态,实现多页面数据共享与联动刷新;

  5. 极致资源优化:用静态数组替代路由链表,编译期确定路由表,零动态内存。

结语

嵌入式开发中的架构设计,从来不是 "越复杂越好",而是在资源约束下,找到可维护性与运行效率的平衡点。MVP + 双链表路由这套方案,没有引入花哨的概念,只用 C 语言原生特性就解决了嵌入式 UI 最核心的耦合与管理问题。

小项目可以只取 MVP 思想做基础分层,大项目可以完整落地路由框架,最终的目标都是一样的:让代码好读、好改、好测,在项目迭代中少踩坑。希望这套实践能给正在做嵌入式 UI 开发的你提供一点参考。

相关推荐
小柯博客1 小时前
01 · 点亮 STM32MP25 的硬件视频编解码:VPU 与那些“安静“的坑
c语言·笔记·stm32·单片机·嵌入式硬件·嵌入式·视频编解码
zbyyd1 小时前
深入理解 Linux 文件 IO 与目录 IO:系统调用与库函数的本质区别
linux·c语言
HZZD_HZZD1 小时前
3T-UEM 架构实战:端-接-算-用四层栈如何承载三级能源计量
struts·架构·能源
mldong2 小时前
会签三兄弟:并行/串行/按比例的实现与取舍
java·架构
末代iOS程序员华仔2 小时前
Codex + Figma 生成 Objective‑C (UIKit) 完整工作流
c语言·开发语言·figma
octopus_c6 小时前
数据结构:堆(Heap)详解
c语言·数据结构·算法
ZYJCSZKJ11 小时前
AI数字人实时交互系统的工程架构与多方言适配实践
人工智能·架构·交互·ai数字人直播系统
心无旁骛~12 小时前
anywhere-labs/deepseek-harness-desktop 如何围绕上游演进:Submodule、版本溯源与非 Fork 架构
人工智能·架构
ZGIAI12 小时前
ZGI Runner 与 Sandbox:两层运行边界
人工智能·架构