UI 设计架构
用途:说明 UI 系统如何实际工作(渲染/输入/菜单架构)与设计考量(为什么这样做)。
配套文档:LVGL模拟器部署与使用说明.md(模拟器部署/使用)、UI_中文字体生成与配置.md(字体生成)。
一、UI 系统速览
1.1 总体方案
MCU 无物理屏幕:LVGL 做 160×160 1bpp 单色离屏渲染,帧缓冲(3200 B)经 RemoDispBus 协议(TCP 端口 7003)按需推给远端客户端(DTU-RemoteLCD)显示;远端按键走同一通道回传。"屏幕"实际是 PC 端客户端。
1.2 文件地图
| 文件 |
端 |
职责 |
Drivers/BSP/UI/ui_port.c/h |
MCU |
LVGL 离屏显示端口(I1 + flush 到内存帧缓冲)+ 远端 keypad 输入 |
Drivers/BSP/UI/ui_menu.c/h |
共用 |
菜单导航逻辑层:持有导航状态(路径/选中/深度/滚动,共 17 B)+ 键语义,单向驱动显示层;不持 LVGL 对象 |
Drivers/BSP/UI/ui_menu_view.c/h |
共用 |
菜单显示层:横向级联框渲染(右贴靠/自适应宽/顶底对齐),持有全部 LVGL 对象与样式、无导航状态 |
Drivers/BSP/UI/ui_menu_tab.c/h |
共用 |
菜单数据层:g_menu_tab[] 父子索引表 + 查询 API(生成物,勿手改) |
Drivers/BSP/UI/ui_net_sink.c/h |
MCU |
RemoDispBus 服务端(收键 + 回送帧缓冲) |
Drivers/BSP/UI/ui_font_cn_12.c |
共用 |
中文 1bpp 子集字体(lv_font_conv 生成物) |
App/task/ui_task.c/h |
MCU |
UI 任务:等 NET_READY → 初始化 → 10ms 刷新循环 |
Lib/lv_conf.h |
MCU |
LVGL 配置(1bpp / 40KB 内存池 / 默认字体) |
simulator/(main.c / sim_port.c / lv_conf.h / CMakeLists.txt) |
PC |
SDL2 模拟器(替代 ui_port,共用全部 UI 源码);支持 --screenshot / --keys <seq> 无头截图验证 |
tools/font_gen/(extract_cn.py / make_font.py)+ ui_cn_chars.txt |
工具 |
字符集提取 / 字体生成 |
tools/menu_gen/gen_menu.py + ui_menu_tree.txt |
工具 |
菜单声明源 → 生成 ui_menu_tab.c/h |
1.3 关键参数
| 项 |
值 |
位置 |
| 屏幕 |
160×160,1bpp(LV_COLOR_FORMAT_I1) |
ui_port.h |
| 帧缓冲 |
3200 B(g_ui_fbuf,flush 回调整帧 memcpy) |
ui_port.c |
| LVGL 内存池 |
40 KB(LV_MEM_SIZE) |
Lib/lv_conf.h |
| 刷新周期 |
10 ms(ui_port_step(10) + osDelay(10)) |
ui_task.c |
| 推流端口 |
7003(TCP,与 DTU-RemoteLCD 客户端对齐) |
ui_net_sink.h |
| 默认字体 |
ui_font_cn_12(12px 1bpp,78 汉字 + ASCII,约 35KB) |
Lib/lv_conf.h |
| uiTask 栈 |
16384 words(BelowNormal) |
Src/freertos.c |
1.4 硬约束(改 UI 必须遵守)
- RAM 紧张:新增页面/控件/缓冲前必须核算 LVGL 池(40KB)与任务栈余量(见
待处理内容.md)
- 新增汉字必须重新生成字体(
tools/font_gen/ 两步),否则显示空白
- 文本一律用静态字符串(
lv_label_set_text_static),不 strdup
- UI 相关文件为 UTF-8 带 BOM(
Drivers/BSP/UI/*、ui_task.c、Lib/lv_conf.h),其余源码无 BOM;禁止 PowerShell 整文件重写(编码铁律,见 AGENTS.md)
- 模拟器与 MCU 共用 UI 源码(
ui_menu*.c / ui_font_cn_12.c),禁止在 simulator/ 下复制副本;新增文件须同时注册进 Keil .uvprojx 与模拟器 CMakeLists(双端注册)
- 验证顺序:模拟器预览 →
@build(0 错 0 警)→ @test(Required 加 UI port init: OK;UI menu init: OK)
LV_DRAW_SW_COMPLEX 必须为 1(两端均显式声明):主界面顶部"开口浅 V"装饰含斜线,lv_line 的斜段仅在复杂渲染器下绘制(draw_line_skew),关闭则只画水平线、两腿消失。且 LV_DRAW_SW_CIRCLE_CACHE_SIZE 须非零(默认 4),否则零长数组编译报错(C2466)
二、渲染链路(画面怎么到"屏幕")
2.1 MCU 端(产品实际路径)
- MCU 无物理屏幕:"屏幕"是 PC 端客户端,帧缓冲只是网络传输的载体
- 按需拉取:不主动推帧,客户端要才给(菜单是低频交互,够用且省带宽)
- LVGL 视角:
ui_port.c 就是一个普通 display(I1 格式 + flush 回调),不知道帧最终走网络
2.2 模拟器端(PC 开发路径)
- 两端场景层完全相同(同一份 UI 源码),只有"渲染结果去向"不同:MCU 进帧缓冲走网络,模拟器进 SDL 窗口
- 模拟器必须初始化 mono 主题(
lv_theme_mono_init):1bpp 下无主题 = 黑底黑字,全黑屏
三、输入链路(按键怎么到菜单)
3.1 统一模型(两端相同)
- keypad 输入设备经
lv_group_send_data 把键码发给焦点对象(模拟器 --keys 无头验证走同一路径)
- LVGL v9 的输入组不自动注册默认键回调(方向键移动焦点需焦点对象自己实现)
→ 4 个深度容器都在组内,但同一时刻仅最右框容器获焦,按键一律由逻辑层统一处理,无焦点争抢
- 按下时输入设备捕获焦点对象(indev_obj_act),释放时向其回发 RELEASED/CLICKED
→ 焦点对象(容器)必须存活,这是"容器持久不删"的原因之一(见 §5.2)
3.2 MCU 端
3.3 模拟器端
- ⚠️ 主循环不得再调
SDL_PollEvent:会先消费事件队列,按键全部失效(2026-09-02 实际踩坑,见 §5.7)
3.4 键位语义
| 键 |
行为 |
| ↑ / ↓ |
移动选中(首尾环绕);选中行反色高亮 + 自动滚动到可见区 |
| → / Enter |
选中条目右侧弹出子框(叶子节点建空白终点框) |
| ← / Esc |
关最右框(其行销毁,焦点回父框,选中态不变) |
四、菜单架构(核心)
显示模型细节(右贴靠/自适应宽/顶底对齐的像素规则与实测值)见 菜单页面设计.md §四⑧;本节只讲代码架构。
4.1 三层拆分(高内聚低耦合)
| 层 |
文件 |
职责 |
持 LVGL 对象? |
| 数据层 |
ui_menu_tab.c/h(生成物) |
g_menu_tab[] 父子索引表 + 查询 API(children/has_children/name/valid) |
否 |
| 显示层 |
ui_menu_view.c/h |
级联框渲染:样式 / 持久容器 / 框体(行与根框装饰、状态行均自绘、无对象);几何现算按层缓存 |
是(全部) |
| 逻辑层 |
ui_menu.c/h |
导航状态(s_path/s_sel/s_depth/s_scroll,共 17 B)+ 键语义;单向驱动显示层建/毁/刷新 |
否 |
- 依赖方向:逻辑 → 显示,两者都用数据查询 API;显示层无导航状态、逻辑层无 LVGL 对象
- 改菜单只编辑
ui_menu_tree.txt 后重跑 python tools/menu_gen/gen_menu.py(再生成 ui_menu_tab.c/h)
4.2 数据:父子索引表
g_menu_tab[]:42 节点,每项 = 名称 + parent 下标(MENU_ROOT=0xFF 为根);树序即表序
- "页" = 对表的一次过滤:节点 N 的页条目 = 全部
parent == N 的行(按表序)
- 菜单 vs 叶子由"是否有子项"自动推导:
has_children(N) 非空 → 菜单行(带 ▶),为空 → 叶子(无箭头,ENTER 进空白终点框)
4.3 对象树
- 持久容器永不删(唯一焦点/键入口,见 §5.2);子对象全部绝对定位(级联框须同屏叠加,层间不用 flex 堆叠)
- 框体 =
lv_obj(透明底 + 1px 白边框 + 自动滚动条),行在框体内自绘(无子对象)
4.4 条目行与选中态
- 行 = 数据(节点下标
s_row_node[d][i],无对象)+ 框体 LV_EVENT_DRAW_MAIN 自绘:选中行整行白带 + 每行 lv_draw_label 文本(左留白 2px)/箭头(紧跟文本、间距 2px;叶子无箭头)
- 行无对象、不入输入组:纯展示,按键一律由容器 → 逻辑层处理
- 选中行 = 整行反色(白带、黑字),箭头随反色变黑(参考图 02/04 实测:选中行保留 ▶);非选中行黑底白字
4.5 框生命周期(按需建毁)
- 容器持久、框动态:行自绘无对象,池内存 = 屏上框体(实测根框 4872 B、最深四级级联 6128 B,远低于 40KB 池)
- 同节点再次进入:还原该框上次选中与滚动位(
s_sel/s_scroll);换节点则清零
- 叶子节点(无子项):ENTER 建空白终点框(w=6、h=2 占位),仅 ESC 可返回
- 深度保护:depth+1 ≥ 4 不再下钻
4.6 焦点与按键处理
- 焦点对象 = 最右(最深)框的容器;进/退时
lv_group_focus_obj 显式转移
- 每层容器挂
menu_key_cb(user_data = depth),统一分发到逻辑层 menu_key_handle:↑↓ 移选(首尾环绕)+ 跟随滚动、→/ENTER 进、←/ESC 退
- 选中变化只改选中数据 + 跟随滚动 + 重绘框体,不重建;滚动位回读持久化到
s_scroll[d]
五、设计决策与考量(为什么这样做)
lv_menu 已被 LVGL 官方废弃;官方建议用基元控件构建菜单:
lv_obj 构建页面 + 返回按钮切换可见页面(参考官方示例 lv_example_menu_navigation)
- 基元控件方案的好处:无废弃风险、样式完全自主可控、与 mono 主题/1bpp 单色兼容性好
- 配套动作:
LV_USE_MENU 两端配置置 0(MCU Lib/lv_conf.h + 模拟器 simulator/lv_conf.h),省代码空间
5.2 页面容器持久不删
- keypad 输入设备在按下时捕获焦点对象,释放时向其回发 RELEASED/CLICKED
- 若在按键处理器内删除焦点对象(如"返回时销毁当前页"),释放事件将落在已释放内存上 → use-after-free
- 容器永不删除,回发事件始终落在存活对象上,天然安全;要释放的只是容器内的框体(行自绘、无对象)
5.3 框按需建/销
- LVGL 池仅 40KB(
LV_MEM_SIZE),全量预建 42 个节点的所有页面必然爆内存
- 只建"当前路径"上的框:行自绘无对象,池内存 = 屏上框体(实测根框 used=4872 B、最深四级级联 used=6128 B,远低于 40KB 池)
- 返回即
lv_obj_delete 释放,内存随时回落到根框水位
5.4 持久容器是唯一焦点对象
- v9 输入组不自动处理方向键(无默认键回调),框深度无需由焦点对象携带
- 持久容器为组内唯一焦点对象(初始化时入输入组并获焦,永不删、焦点永不迁移);容器集中处理按键:逻辑单点、行为可预测、无多对象焦点争抢
- 框深度由逻辑层
s_depth 决定(4 容器时代的 user_data=depth 已冗余,去掉)
- 框体(含自绘行)不入组:纯展示,不参与焦点/点击,避免与容器抢键
5.5 条目行自绘而非对象化
- 每行对象有"机制税":
lv_obj_t 结构体 + 样式向量 + 子 label(文本/箭头)状态,约 2 KB/行;屏上 2~3 框 × 多行会逼近 40 KB 池 → 行降级为数据(节点下标 s_row_node[4][16],1 B/行)
- 框体
LV_EVENT_DRAW_MAIN 自绘:选中行整行白带(lv_draw_rect),每行文本/箭头经 lv_draw_label(与 label 同字体光栅化路径,像素级一致)
- 行不需要对象语义(焦点/点击/按压反馈):输入由容器统一处理,行是纯展示
- 自绘同时根除了旧对象模型的两类问题:样式继承"白底白字"坑(2026-09-02 实际踩坑)、行对象池内存膨胀
5.6 flat 样式剥离主题 card
- mono 主题给所有基元对象附加 card 样式(背景/边框/圆角/内边距/文字色)
- 160×160 小屏 1bpp 菜单不需要卡片观感(边框圆角浪费像素)
- flat 样式(透明背景/无边框/无圆角/零内边距)后加优先,逐项覆盖 card
5.7 SDL 事件只由 LVGL 驱动轮询
- LVGL SDL 驱动内部有 5ms 定时器(
lv_sdl_window.c 的 sdl_event_handler)统一 SDL_PollEvent 并分发
- 主循环若再
SDL_PollEvent,会先消费事件队列 → 驱动定时器轮询时队列已空 → 键盘全部失效
- 关窗退出由驱动
LV_SDL_DIRECT_EXIT=1 自动 exit(0),主循环无需处理 SDL_QUIT
六、变更记录(简表)
| 日期 |
变更 |
| 2026-08-30 |
LVGL v9.3.0 入库;初始 UI(ui_port / ui_menu / ui_net_sink / ui_task);开启 192K 全部 RAM |
| 2026-08-31 |
推流/按键链路打通;移除 LVGL 未用 demos/tests;搭建 PC 模拟器(CMake + SDL2) |
| 2026-09-01 |
代码规范钩子落地,UI 代码随全库规范修复 |
| 2026-09-02 |
菜单重构:弃用 lv_menu → 基元控件页面导航(§4/§5);字体 83→78 字(补「返/回」);修复模拟器主循环吞事件致键盘失效(§5.7);修复选中行白底白字(§5.5) |
| 2026-09-03 |
关键约束 7:LV_DRAW_SW_COMPLEX 须=1(顶部"浅 V"斜线依赖);两端 lv_conf 显式固化,LV_DRAW_SW_CIRCLE_CACHE_SIZE=4 防零长数组 |
| 2026-09-04 |
菜单重构为"横向级联框"模型(见 菜单页面设计.md §四⑧):数据/显示/逻辑三层拆分(新增 ui_menu_view 显示层、ui_menu_tab 生成物,ui_menu 瘦身为纯状态机);字体重生成补缺字形(78 字,含菜单树新增的整/控/软/板等);模拟器加 --keys <seq> 无头注键截图验证 |
| 2026-09-08 |
菜单 RAM 收敛两连:行对象→框体自绘(行降为数据,§4.4/§5.5);4 层容器→单一持久容器(焦点恒在其上,框深度由逻辑层 s_depth 决定,§4.3/§5.4)+ 根框装饰/状态行容器自绘(无对象);LVGL 池实测 根框 4872 B / 最深四级级联 6128 B |