嵌入式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 ? "加热中" : "已关闭");
}
这种写法在单页面、简单业务下看似高效,但项目迭代后会暴露三大核心问题:
-
耦合严重,无法测试:业务逻辑与 LVGL 控件深度绑定,想验证温度规则必须烧录到带屏幕的硬件上,无法做纯逻辑单元测试。
-
无法复用,改造成本高:同样的温控逻辑换个 UI 样式、换个屏幕分辨率,几乎要重写一遍代码。
-
页面管理混乱:多页面跳转时,手动控制控件显隐、手动管理内存、返回逻辑散落在各处,页面越多越容易出野指针和内存泄漏。
我们做架构的目标很明确:在嵌入式有限的资源下,用最小的 overhead 实现职责分离,让业务逻辑可测试、页面跳转可管控、内存分配可预期。
二、整体架构总览:分层治理,内外分离
整套架构分为两个层级,各司其职,互不侵入:
-
页面内层:MVP 分层架构
解决单个页面内部的代码组织问题,将业务数据、UI 展示、逻辑调度彻底拆开,让业务与 UI 互不干扰。
-
页面间层:双链表路由框架
解决多页面的跳转、返回、生命周期、事件分发问题,统一管理所有页面的创建、销毁、前后台切换。
两者的依赖关系是单向的:页面内部的 MVP 实现不需要知道路由的存在,路由框架也不关心页面内部用了什么架构,只通过标准的生命周期回调对接。这种设计保证了两边可以独立迭代、独立测试。
核心设计原则:
-
边界清晰:每层只做自己职责内的事,绝不越界;
-
内存优先:优先静态分配,限制动态内存使用,避免碎片;
-
最小侵入:不强推复杂概念,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 的适配要点
-
静态分配优先:页面结构体、Model、Presenter 全部使用静态变量或静态分配,避免频繁 malloc 产生内存碎片。
-
接口粒度适中:不要一个接口只改一个字符,也不要一个接口刷新全页面,按功能模块划分,平衡代码量与刷新效率。
-
生命周期对齐: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 事件分发机制
嵌入式系统通常有物理按键、定时器、系统消息等全局事件,路由框架提供统一的事件分发入口:
-
事件优先派发给当前前台页面;
-
页面
process回调返回 0 表示事件已消费,终止分发; -
返回非 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 嵌入式专属优化
-
静态节点池:历史节点全部预分配在数组里,用完回收,零动态内存碎片。
-
栈深度限制:配置最大历史层数,防止用户无限跳转导致内存溢出。
-
常驻 / 销毁策略 :常用首页开启
KEEP_INSTANCE保留实例,跳转快;临时页面关闭时直接销毁,节省 RAM。 -
静态字符串路由名:要求路由名使用静态字符串,不占用堆内存,也避免字符串拷贝开销。
五、双剑合璧: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 全流程调用链路
以 "首页点击按钮跳转到温控面板" 为例,完整调用链如下:
-
首页按钮点击事件触发,调用
router_push(router, "temp_panel", NULL, 0); -
路由框架查找对应路由,执行首页的
exit回调,首页隐藏并进入后台; -
路由调用温控页的
create回调,创建页面实例、初始化 MVP 三层; -
路由调用温控页的
enter回调,显示页面并刷新 UI; -
用户操作温控页,事件在 MVP 三层内流转,与路由完全无关;
-
用户按返回键,事件分发到路由框架,执行
router_pop; -
温控页执行
exit+destroy,实例销毁; -
首页从后台恢复,执行
enter回调,重新显示。
整个流程中,页面内部的 MVP 逻辑完全感知不到路由的存在,路由也完全不侵入页面业务,真正做到了 "高内聚、低耦合"。
六、架构复盘:优缺点与选型建议
6.1 核心优势
-
业务逻辑可测试:Model 层完全独立,可在 PC 端做单元测试,不用依赖硬件屏幕,大幅提升调试效率。
-
代码复用性强:同样的 Model+Presenter 逻辑,换一套 View 实现即可适配不同屏幕规格,业务代码零修改。
-
内存完全可控:静态节点池、栈深度限制、常驻 / 销毁策略,让 RAM 占用可预估、可配置。
-
扩展能力充足:新增页面只需注册路由,新增导航模式只需扩展路由 API,不影响现有页面代码。
6.2 局限性与注意事项
-
简单页面存在过度设计:如果项目只有 1-2 个简单页面,硬套 MVP 和路由反而会增加代码量,得不偿失。
-
有一定学习成本:团队需要理解分层边界、接口抽象、生命周期概念,新人上手需要时间。
-
调试链路变长:出现 UI 问题时,需要排查 Model→Presenter→View→路由整条链路,比单文件代码稍复杂。
6.3 选型建议
-
推荐使用:页面数量≥5 个、业务逻辑复杂、需要长期迭代维护、有单元测试需求的项目;
-
谨慎使用:极小资源 MCU(RAM<64KB)、页面少于 3 个、一次性交付无需迭代的项目。
七、延伸优化方向
这套基础架构可以根据项目需求继续扩展,常用的优化方向包括:
-
跳转结果回传 :增加
set_result机制,实现类似 Android 的页面间数据回传; -
转场动画:在 enter/exit 回调中接入 LVGL 动画,实现页面切换的淡入淡出、滑动效果;
-
跳转拦截器:注册全局拦截钩子,跳转前做权限校验、密码验证等逻辑;
-
全局状态管理:引入轻量全局状态,实现多页面数据共享与联动刷新;
-
极致资源优化:用静态数组替代路由链表,编译期确定路由表,零动态内存。
结语
嵌入式开发中的架构设计,从来不是 "越复杂越好",而是在资源约束下,找到可维护性与运行效率的平衡点。MVP + 双链表路由这套方案,没有引入花哨的概念,只用 C 语言原生特性就解决了嵌入式 UI 最核心的耦合与管理问题。
小项目可以只取 MVP 思想做基础分层,大项目可以完整落地路由框架,最终的目标都是一样的:让代码好读、好改、好测,在项目迭代中少踩坑。希望这套实践能给正在做嵌入式 UI 开发的你提供一点参考。