现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名, 不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体 run_boot_test.py 的真实调用。 - AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改 「编译(/keil-build)」「串口回归(/serial-boot-test)」 - keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree 方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围) - 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入 等 41 行历史证据保留旧名 - gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑 - 顺手补并行方案同行漏网计数 51→52 钩子 验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52 钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
56 KiB
菜单页面设计
用途:定义 DTU 主菜单的内容(43 节点层级树)、视觉规范、三层代码架构(数据层 / 显示层 / 逻辑层),以及叶子节点进入后的内容页模块(§九)。 范围:菜单层级(节点/父子/层级/进出导航)+ 三个已实现的内容页(装置信息/交流量/直流量,§九);其余叶子的内容页后续按同模式扩展。 来源:用户提供的主菜单截图 7 屏(根页 + 实时数据/参数定值/三遥设置/装置维护/通讯参数/记录查询各一级子页 + 参数定值·整定二级)。 导航模型:树形逐级进入——
ENTER/→进入子页,ESC/←返回上一级;最多 4 级(主界面 → 三级菜单)。
阅读指南:
你想… 读 搞懂哪段代码干什么、三层怎么配合 §一 架构总览(职责速览 / 调用矩阵 / 一次按键的旅程 / 初始化时序 / 层间不变量),再按需深入 §三 / §五 / §六 深入某一层 §三 数据层 / §五 显示层 / §六 逻辑层(各章自包含:职责边界 + 字段 + API) 改菜单(增删/改名/换序条目) §二(内容)+ §3.7(只改文本源、重跑生成器,代码零改动) 改视觉(框/行/几何) §四(显示规范)+ §五(显示层实现) 内容页(装置信息/交流量/直流量) §九(对象树/生命周期/页面定义表/API/内存预算) 搞懂内存(什么存、什么现算) §7.3 + §7.5 验证 / 调试 §八
一、架构总览:三层拆分与协作
1.1 为什么拆三层
"菜单表 + 导航状态 + LVGL 渲染" 三块职责若同处一个文件则互相耦合(改菜单要动导航代码、改渲染易破坏导航)。因此拆为三层,一层一文件、各管一件事:
- 数据层——只回答"有什么条目":只读菜单表 + 4 个查询 API;
- 显示层——只回答"怎么画":持有全部 LVGL 对象,零导航状态;
- 逻辑层——只回答"用户在哪、选中哪个":唯一 17 B 导航状态 + 键语义(移/进/退)。
3 个文件 MCU / PC 模拟器双端共用(双端注册:Keil .uvprojx + simulator/CMakeLists.txt,见 UI_设计架构.md §1.4 约束 5)。
1.2 三层职责速览
flowchart TD
K(["按键(远端键 / 模拟器键)"]) --> L["逻辑层 ui_menu.c<br/>唯一 17 B 导航状态(path/选择/深度/滚动)<br/>键语义:移动 / 进入 / 退出"]
L -- "单向驱动: frame_build / frame_destroy / frame_refresh / focus" --> V["显示层 ui_menu_view.c/h<br/>持全部 LVGL 对象,纯渲染,零导航状态"]
L -- "只读查询: children / valid" --> T["数据层 ui_menu_tab.c/h(生成物,只读)<br/>g_menu_tab[43] 父子索引表 + 4 个查询 API"]
V -- "只读查询: children / has_children / name" --> T
| 层 | 文件 | 一句话职责 | 持 LVGL 对象? | 持导航状态? |
|---|---|---|---|---|
| 数据层 | Drivers/BSP/UI/ui_menu_tab.c/h(生成物) |
回答"有什么条目":菜单表 + 查询 API | 否 | 否 |
| 逻辑层 | Drivers/BSP/UI/ui_menu.c/h |
回答"用户在哪、选中哪个":导航状态 + 键语义 | 否 | 是(唯一,17 B) |
| 显示层 | Drivers/BSP/UI/ui_menu_view.c/h |
回答"怎么画":级联框渲染 | 是(全部) | 否 |
记忆口诀:数据层管"料",逻辑层管"账",显示层管"画"。
1.3 依赖方向与调用矩阵
依赖严格单向:逻辑 → 显示 → 数据,数据层不依赖任何人;禁止反向调用(显示层不调逻辑层;数据/显示层不引用导航状态)。
| 调用方 ↓ / 被调 → | 数据层 | 显示层 | 逻辑层 |
|---|---|---|---|
数据层 ui_menu_tab |
— | ✗ | ✗ |
显示层 ui_menu_view |
✓ 查询 API | — | ✗ |
逻辑层 ui_menu |
✓ 查询 API | ✓ 8 个显示接口 | — |
层间"词汇"只有两组接口(各层章节有完整签名):
| 方向 | 接口 |
|---|---|
| 逻辑 → 显示 | ui_menu_view_init / container / frame_build / frame_destroy / frame_refresh / set_scroll / get_scroll / focus(§5.5) |
| 逻辑/显示 → 数据 | ui_menu_tab_children / has_children / name / valid(§3.4) |
1.4 按键协作:一次按键的旅程
sequenceDiagram
participant K as 按键(远端键 / 模拟器键)
participant G as LVGL 默认输入组(lv_group)
participant L as 逻辑层 ui_menu.c
participant V as 显示层 ui_menu_view
participant T as 数据层 ui_menu_tab
K->>G: 键码
Note over G: 焦点 = 持久容器 s_cont(唯一键入口;帧深度由逻辑层 s_depth 决定)
G->>L: LV_EVENT_KEY → menu_key_cb(深度=s_depth)→ menu_key_handle
L->>L: 状态变更:移动 s_sel[d] / 进入 s_depth+1+s_path / 退出 s_depth−1
L->>V: frame_refresh(高亮+跟滚)/ frame_build(建新框)/ frame_destroy(拆最右框)+ focus(移焦点)
V->>T: children / has_children / name(条目现算不落盘)
V-->>L: get_scroll(读回滚动位置,逻辑层写入 s_scroll[d])
Note over V: LVGL 下一帧渲染 → 屏幕变化
两个要点:
- 逻辑层决定"改成什么状态",显示层决定"画面怎么变"——显示层只执行、不决策;数据层全程不参与(只被显示层查询条目/名字/菜单叶子)。
- 全程单向同步:一次按键 = 一次完整的"状态变更 → 画面更新",无异步、无回调上行。
1.5 初始化时序(ui_menu_init)
uiTask 等 SYS_EVT_NET_READY 后调 ui_menu_init(),三层按此顺序配合:
sequenceDiagram
participant U as uiTask
participant L as 逻辑层 ui_menu.c
participant V as 显示层 ui_menu_view
participant T as 数据层 ui_menu_tab
U->>L: ui_menu_init()
L->>T: ui_menu_tab_valid() 表自检
alt 父索引越界(表损坏)
T-->>L: 不过 → 打 ERROR、放弃建菜单(返回 -1)
else 自检通过
T-->>L: 通过
end
L->>L: 清零 17 B 导航状态(s_path/s_sel/s_scroll=0,s_depth=0)
L->>V: ui_menu_view_init()(样式 → 屏幕根容器 → 持久容器(装饰/状态行自绘回调) → 根框本体(含自绘回调))
L->>V: 持久容器 lv_group_add_obj(默认组)+ 挂 menu_key_cb(深度取 s_depth)
L->>V: ui_menu_view_frame_build(0) 注册根框行数据 + focus()
Note over L: lv_mem_monitor 打印建前/建后 LVGL 池
L-->>U: 0(成功)
- 日志标记:
UI menu init: OK(uiTask 打印)——/serial-boot-test回归可作 Required 标记。 - LVGL 池实测(模拟器 16 KB 池):根框建完
used=5368 B(旧行对象实现为 13312 B,自绘 + 容器收敛省 8.3 KB);最深四级级联used=6040 B(增量 1256 B = 3 个子框体),远小于池余量。
1.6 层间不变量(每层"绝不做")
| 层 | 绝不做 | 为什么 |
|---|---|---|
| 数据层 | 业务逻辑(4 个 API 是纯表过滤)、依赖 LVGL、修改表(const) |
表是唯一事实源;改表 = 改菜单(走生成器 §3.6) |
| 显示层 | 持导航状态(17 B 全在逻辑层)、解释按键(输入回调挂在逻辑层) | 显示层 = "状态的纯函数":同样状态必画同样画面 → 易验证、双端可复用 |
| 逻辑层 | 创建/修改 LVGL 对象(建对象全在显示层)、持 lv_obj_t* 句柄 |
唯一例外:输入接线 2 调 lv_group_add_obj / lv_obj_add_event_cb(容器须入输入组)+ 读事件参数 + lv_mem_monitor 内存监控 |
二、菜单内容:43 节点层级树
2.1 层级总览
主菜单为 4 级树:
| 层级 | 含义 | 例子 |
|---|---|---|
| 主界面(根) | 主界面,7 个一级条目 | 主界面 |
| 一级菜单 | 根页的 7 个条目 | 装置信息、实时数据、参数定值、三遥设置、装置维护、通讯参数、记录查询 |
| 二级菜单 | 一级子菜单的子项 | 交流量、整定、遥测死区、时钟设置、通讯设置、SOE记录 … |
| 三级菜单 | 仅"参数定值 → 整定/查看"下的子项 | 参数、定值、控制字、软压板 |
节点分两类(决定导航行为):
| 类型 | 标识 | 行为 |
|---|---|---|
| 菜单节点 | 行尾 ▶ 箭头 | ENTER 进入 → 展开下一级子页 |
| 叶子节点 | 无箭头 | ENTER 进入 → 有内容页(node 1/3/4,§九)则切内容屏;否则进空白终点框 |
根页默认选中第 1 项"装置信息"(整行反色:白带、黑字)。
2.2 完整层级树
主界面
├─ 装置信息 [叶子]
├─ 实时数据 ▶ [菜单]
│ ├─ 交流量 [叶子]
│ ├─ 直流量 [叶子]
│ └─ 遥信量 [叶子]
├─ 参数定值 ▶ [菜单]
│ ├─ 整定 ▶ [菜单]
│ │ ├─ 参数 [叶子]
│ │ ├─ 定值 [叶子]
│ │ ├─ 控制字 [叶子]
│ │ └─ 软压板 [叶子]
│ └─ 查看 ▶ [菜单]
│ ├─ 参数 [叶子]
│ ├─ 定值 [叶子]
│ ├─ 控制字 [叶子]
│ └─ 软压板 [叶子]
├─ 三遥设置 ▶ [菜单]
│ ├─ 遥测死区 [叶子]
│ ├─ 遥测系数 [叶子]
│ ├─ 遥信类型 [叶子]
│ ├─ 遥信防抖 [叶子]
│ └─ 双点遥信 [叶子]
├─ 装置维护 ▶ [菜单]
│ ├─ 时钟设置 [叶子]
│ ├─ 强制复归 [叶子]
│ ├─ 手动录波 [叶子]
│ └─ 清除记录 [叶子]
├─ 通讯参数 ▶ [菜单]
│ ├─ 通讯设置 [叶子]
│ ├─ 网口设置 [叶子]
│ └─ SNTP设置 [叶子]
└─ 记录查询 ▶ [菜单]
├─ SOE记录 [叶子]
├─ 事故记录 [叶子]
├─ 操作记录 [叶子]
├─ 保护告警 [叶子]
├─ 保护启动 [叶子]
├─ 遥控记录 [叶子]
├─ 自检记录 [叶子]
├─ 运行记录 [叶子]
├─ 运行报告 [叶子]
└─ 录波回放 [叶子]
统计:菜单节点 9 个(含根),叶子节点 34 个,共 43 个节点;最大深度 4 级。
2.3 节点定义表(逻辑层级定义)
扁平表,与 §3.3 生成物
g_menu_tab[]一一对应:#= 数组下标,父节点= 父菜单的#,子项数= 直接子项个数(叶子为 0)。
| # | 名称 | 层级 | 类型 | 父节点 | 子项数 |
|---|---|---|---|---|---|
| 0 | 主界面 | 主界面 | 菜单(根) | — | 7 |
| 1 | 装置信息 | 一级菜单 | 叶子 | 0 | 0 |
| 2 | 实时数据 | 一级菜单 | 菜单 | 0 | 3 |
| 3 | 交流量 | 二级菜单 | 叶子 | 2 | 0 |
| 4 | 直流量 | 二级菜单 | 叶子 | 2 | 0 |
| 5 | 遥信量 | 二级菜单 | 叶子 | 2 | 0 |
| 6 | 参数定值 | 一级菜单 | 菜单 | 0 | 2 |
| 7 | 整定 | 二级菜单 | 菜单 | 6 | 4 |
| 8 | 参数 | 三级菜单 | 叶子 | 7 | 0 |
| 9 | 定值 | 三级菜单 | 叶子 | 7 | 0 |
| 10 | 控制字 | 三级菜单 | 叶子 | 7 | 0 |
| 11 | 软压板 | 三级菜单 | 叶子 | 7 | 0 |
| 12 | 查看 | 二级菜单 | 菜单 | 6 | 4 |
| 13 | 参数 | 三级菜单 | 叶子 | 12 | 0 |
| 14 | 定值 | 三级菜单 | 叶子 | 12 | 0 |
| 15 | 控制字 | 三级菜单 | 叶子 | 12 | 0 |
| 16 | 软压板 | 三级菜单 | 叶子 | 12 | 0 |
| 17 | 三遥设置 | 一级菜单 | 菜单 | 0 | 5 |
| 18 | 遥测死区 | 二级菜单 | 叶子 | 17 | 0 |
| 19 | 遥测系数 | 二级菜单 | 叶子 | 17 | 0 |
| 20 | 遥信类型 | 二级菜单 | 叶子 | 17 | 0 |
| 21 | 遥信防抖 | 二级菜单 | 叶子 | 17 | 0 |
| 22 | 双点遥信 | 二级菜单 | 叶子 | 17 | 0 |
| 23 | 装置维护 | 一级菜单 | 菜单 | 0 | 4 |
| 24 | 时钟设置 | 二级菜单 | 叶子 | 23 | 0 |
| 25 | 强制复归 | 二级菜单 | 叶子 | 23 | 0 |
| 26 | 手动录波 | 二级菜单 | 叶子 | 23 | 0 |
| 27 | 清除记录 | 二级菜单 | 叶子 | 23 | 0 |
| 28 | 通讯参数 | 一级菜单 | 菜单 | 0 | 3 |
| 29 | 通讯设置 | 二级菜单 | 叶子 | 28 | 0 |
| 30 | 网口设置 | 二级菜单 | 叶子 | 28 | 0 |
| 31 | SNTP设置 | 二级菜单 | 叶子 | 28 | 0 |
| 32 | 记录查询 | 一级菜单 | 菜单 | 0 | 10 |
| 33 | SOE记录 | 二级菜单 | 叶子 | 32 | 0 |
| 34 | 事故记录 | 二级菜单 | 叶子 | 32 | 0 |
| 35 | 操作记录 | 二级菜单 | 叶子 | 32 | 0 |
| 36 | 保护告警 | 二级菜单 | 叶子 | 32 | 0 |
| 37 | 保护启动 | 二级菜单 | 叶子 | 32 | 0 |
| 38 | 遥控记录 | 二级菜单 | 叶子 | 32 | 0 |
| 39 | 自检记录 | 二级菜单 | 叶子 | 32 | 0 |
| 40 | 运行记录 | 二级菜单 | 叶子 | 32 | 0 |
| 41 | 运行报告 | 二级菜单 | 叶子 | 32 | 0 |
| 42 | 录波回放 | 二级菜单 | 叶子 | 32 | 0 |
三、数据层:ui_menu_tab(生成物,只读)
一句话职责:提供"有什么条目"——一张扁平父子索引表(43 节点,
const存 Flash、只读)+ 4 个查询 API,供逻辑层 / 显示层共用。 绝不做:持有任何状态、依赖 LVGL、被手改(改菜单走 §3.6 生成器)。
3.1 选型:扁平"父子索引表"(邻接表 / 关系表模型)
成熟树存储方案对比(参考 Wikipedia "Tree (abstract data type)"、文件系统/关系库的层级表示):
| 方案 | 原理 | 本方案取舍 |
|---|---|---|
| 指针树(动态节点,父/子指针) | 运行时动态增删 | ✗ 需堆 + 指针,嵌入端 GC/碎片/内存风险,const 入 Flash 困难 |
| 首子-兄弟(left-child right-sibling) | 每节点只存 first_child / next_sibling | 紧凑但"取某节点全部子项"需链式遍历,不如邻接直观,调试不友好 |
| 数组位置表示(堆式) | 靠下标位置推父子(2i/2i+1) | ✗ 要求满二叉/固定出度;菜单出度不定且有命名,不适用 |
| 邻接表 / 父子索引表(关系表) | 每行自带 parent 索引,子项=parent 等于本下标的行 |
✓ 选用:无指针、每行自描述、增删改=改行、可入 Flash、启动可校验 |
LVGL 8 已移除
lv_menu控件(lv_conf.h中LV_USE_MENU 0),本项目的扁平表方案与"无 lv_menu"约束一致,且比控件更贴合嵌入端。
核心不变式:
- 子项 = 表中所有
parent == 本下标的行,按表序排列(表序即显示序)。 - 菜单 vs 叶子由"是否有子项"自动推导:加子项→自动出现 ▶;删光子项→自动变叶子。无需单独的类型字段,替换时零维护。
3.2 数据结构定义
#include <stdint.h>
#include <stdbool.h>
#define MENU_ROOT 0xFFu /* 根哨兵:仅作根节点(主界面 #0)的 parent,表示"它是顶层";8 位,上限 255 项 */
/* 菜单节点 = 扁平表一行;子项 = parent 等于本下标的所有行(按表序) */
typedef struct {
uint8_t parent; /* 父节点下标(0..254);仅根节点(主界面#0)为 MENU_ROOT(0xFF) */
const char *name; /* 显示文本(字面量,存 Flash) */
} ui_menu_node_t;
实现落在生成器产物
Drivers/BSP/UI/ui_menu_tab.h(见 §3.6);字段/语义与此一致。
3.3 完整 43 节点表(按 §2.3 节点定义表落地)
产物
ui_menu_tab.c(§3.6 生成):const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT],内容与下表一致。
const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT] = {
/* parent name 下标 */
{ MENU_ROOT, "主界面" }, /* 0 主界面菜单(根) */
{ 0, "装置信息" }, /* 1 一级菜单叶子 */
{ 0, "实时数据" }, /* 2 一级菜单 */
{ 2, "交流量" }, /* 3 二级菜单叶子 */
{ 2, "直流量" }, /* 4 二级菜单叶子 */
{ 2, "遥信量" }, /* 5 二级菜单叶子 */
{ 0, "参数定值" }, /* 6 一级菜单 */
{ 6, "整定" }, /* 7 二级菜单 */
{ 7, "参数" }, /* 8 三级菜单叶子 */
{ 7, "定值" }, /* 9 三级菜单叶子 */
{ 7, "控制字" }, /* 10 三级菜单叶子 */
{ 7, "软压板" }, /* 11 三级菜单叶子 */
{ 6, "查看" }, /* 12 二级菜单 */
{ 12, "参数" }, /* 13 三级菜单叶子 */
{ 12, "定值" }, /* 14 三级菜单叶子 */
{ 12, "控制字" }, /* 15 三级菜单叶子 */
{ 12, "软压板" }, /* 16 三级菜单叶子 */
{ 0, "三遥设置" }, /* 17 一级菜单 */
{ 17, "遥测死区" }, /* 18 二级菜单叶子 */
{ 17, "遥测系数" }, /* 19 二级菜单叶子 */
{ 17, "遥信类型" }, /* 20 二级菜单叶子 */
{ 17, "遥信防抖" }, /* 21 二级菜单叶子 */
{ 17, "双点遥信" }, /* 22 二级菜单叶子 */
{ 0, "装置维护" }, /* 23 一级菜单 */
{ 23, "时钟设置" }, /* 24 二级菜单叶子 */
{ 23, "强制复归" }, /* 25 二级菜单叶子 */
{ 23, "手动录波" }, /* 26 二级菜单叶子 */
{ 23, "清除记录" }, /* 27 二级菜单叶子 */
{ 0, "通讯参数" }, /* 28 一级菜单 */
{ 28, "通讯设置" }, /* 29 二级菜单叶子 */
{ 28, "网口设置" }, /* 30 二级菜单叶子 */
{ 28, "SNTP设置" }, /* 31 二级菜单叶子 */
{ 0, "记录查询" }, /* 32 一级菜单 */
{ 32, "SOE记录" }, /* 33 二级菜单叶子 */
{ 32, "事故记录" }, /* 34 二级菜单叶子 */
{ 32, "操作记录" }, /* 35 二级菜单叶子 */
{ 32, "保护告警" }, /* 36 二级菜单叶子 */
{ 32, "保护启动" }, /* 37 二级菜单叶子 */
{ 32, "遥控记录" }, /* 38 二级菜单叶子 */
{ 32, "自检记录" }, /* 39 二级菜单叶子 */
{ 32, "运行记录" }, /* 40 二级菜单叶子 */
{ 32, "运行报告" }, /* 41 二级菜单叶子 */
{ 32, "录波回放" }, /* 42 二级菜单叶子 */
};
UI_MENU_TAB_COUNT(=43)与MENU_ROOT在ui_menu_tab.h定义。
3.4 查询 API(逻辑层 / 显示层共用)
4 个查询 API 统一模块前缀 ui_menu_tab_*(与逻辑层 menu_* 函数名区分),参数 uint8_t(菜单上限 255 项),随表生成在 ui_menu_tab.c:
/* ui_menu_tab.h(生成物;逻辑层与显示层共用) */
uint8_t ui_menu_tab_children(uint8_t node, uint8_t out[], uint8_t max); /* 取 node 直接子项(按表序),最多 max 个;返回实际个数 */
bool ui_menu_tab_has_children(uint8_t node); /* 有子项=菜单(带▶);无=叶子 */
const char *ui_menu_tab_name(uint8_t node); /* 显示名 */
bool ui_menu_tab_valid(void); /* 启动自检:非根 parent 下标均有效 */
各层用哪几个(谁问数据层什么问题):
| API | 逻辑层(键分发) | 显示层(建框渲染) |
|---|---|---|
children |
✓ 取子项/子项数(判空框、取选中子节点) | ✓ 取本框条目列表 |
has_children |
— | ✓ 判行尾有无 ▶ |
name |
— | ✓ 行文本 |
valid |
✓ 初始化自检(§1.5 第 1 步) | — |
建框 = 对"当前层节点"做一次过滤(条目现算不落地,见 §7.3):
/* 显示层 ui_menu_view_frame_build(depth, node, sel, scroll) 内: */
uint8_t kids[MENU_ITEM_MAX];
uint8_t cnt = ui_menu_tab_children(node, kids, MENU_ITEM_MAX); /* node = s_path[depth] */
for (uint8_t i = 0; i < cnt; i++) {
/* 注册一行:s_row_node[depth][i] = kids[i](框体自绘时现查名称/箭头) */
}
导航/显示一律按
parent == 节点过滤,改名/换序/增删子项均不影响导航逻辑。
3.5 启动自检:ui_menu_tab_valid()
实现随表生成在
ui_menu_tab.c:校验非根节点 parent 下标均 <UI_MENU_TAB_COUNT(捕获表内笔误/生成器缺陷)。 由ui_menu_init()首行调用(§1.5 第 1 步);失败 → 打DBG_ERROR("menu table: INVALID…")并放弃建菜单(返回 -1)。 校验取 parent 界校验即够——生成器保证 parent 恒为"上级节点下标"(结构上无环),界校验足以兜底。
3.6 声明式生成器:ui_menu_tree.txt → ui_menu_tab.c/.h(与字体生成 ui_cn_chars.txt → make_font.py 同模式)
菜单层级声明在缩进即层级的文本源 ui_menu_tree.txt(仓库根,每级 2 空格、# 注释),
脚本读源、推导每个节点 parent 下标、产出 C 表。改菜单只编辑文本源,重跑脚本即可,导航/显示代码零改动。
| 文件 | 说明 |
|---|---|
ui_menu_tree.txt |
人工编辑源(缩进即层级);改菜单改这里 |
tools/menu_gen/gen_menu.py |
生成器(同 make_font.py 模式):解析缩进→推导 parent→产出 C 表;含根唯一/缩进对齐/层级上限自检 |
Drivers/BSP/UI/ui_menu_tab.h |
产物:ui_menu_node_t 定义 + MENU_ROOT + UI_MENU_TAB_COUNT + extern g_menu_tab[] |
Drivers/BSP/UI/ui_menu_tab.c |
产物:g_menu_tab[UI_MENU_TAB_COUNT] 完整 43 节点表 |
重新生成:
python tools/menu_gen/gen_menu.py
产物
ui_menu_tab.c/.h含 43 节点(9 菜单 + 34 叶子,最深 三级菜单;2026-10-29 录波道加两叶后由 42 升到 43);ui_menu.c不持有本地表,导航/显示一律走ui_menu_tab_*查询 API(§3.4)。
编码陷阱(已踩):产物含中文字符串字面量,必须 UTF-8 带 BOM(同
ui_menu.c)。 无 BOM 时 cl.exe(代码页 936/GBK)会按 GBK 误读 UTF-8 字节 → 字符串解析错乱(C2001/C4819)。gen_menu.py已用utf-8-sig输出。
3.7 改菜单流程
1. 编辑 ui_menu_tree.txt —— 缩进即层级(每级 2 空格),# 为注释;增/删/改名/换序条目都只改这里;
行尾可选 ` #P` = 该叶子需密码(密码页按所输密码授权查看/修改权限,
密码值在 Drivers/BSP/UI/ui_pwd_def.h 编译期配置)
2. python tools/menu_gen/gen_menu.py —— 重跑生成器:表 / UI_MENU_TAB_COUNT / 8 位护栏 全部同步更新
3. 完成:导航(逻辑层)与渲染(显示层)代码零改动
注意:新增中文汉字时,须同步加入字体字符集
ui_cn_chars.txt并重生成字体(见UI_中文字体生成与配置.md),否则显示成方框。
3.8 Flash 占用(const,只读)
| 符号 | 定义处 | 内容 | 占用 |
|---|---|---|---|
ui_menu_node_t |
ui_menu_tab.h |
{ uint8_t parent; uint8_t pwd; const char *name; }(32 位 8 B/项) |
类型定义 |
g_menu_tab[UI_MENU_TAB_COUNT] |
ui_menu_tab.c(生成) |
43 节点扁平父子表 | ≈ 43×8 B ≈ 344 B |
| 名字字面量 | 同上 | 43 个 const char[] |
≈ 400 B |
UI_MENU_TAB_COUNT / MENU_ROOT |
ui_menu_tab.h |
节点数 43 / 根哨兵 0xFF(8 位,上限 255 项) | 宏 |
逻辑层/显示层只读它(§3.4 的 API 都是对它的过滤/查询),从不修改。合计 ≈ 0.7 KB Flash。
四、显示规范:横向级联子框模型
本章是显示层(§五)要实现的"图纸":讲"页"(每级的子项列表)在屏上怎么摆——每级一个框,贴靠在其父框右侧——一棵横向的树(往右生长)。 以下规则由参考图 02~07(
docs/UI设计/mockups/)逐像素实测得出(设备 px,误差 ≤3px);参考图为手工 mockup,与实现的偏差见 §8.2 注 5。
4.1 总览
- 每级一个框,框 = 该级条目列表(紧凑列表,无逐框页头/返回按钮,返回靠
←); - 无滑动动画(1bpp 推流);
- 屏上几框 = 深度 × 各框宽 决定(不固定)。
4.2 水平布局(X):右贴靠 + 自适应宽
- 子框左缘 = 父框右缘:共享边框列、零间隙;逐级向右生长(实测:各截图子框均恰好贴在父框右缘 x≈67/106 处)。
- 框宽 = 该框最宽条目(文字 + ▶ + 边距 + 边框),按内容自适应(公式见 §5.3)。参考图实测:4字+箭头 ≈68、4字无箭头 ≈60、3字 ≈46、2字+箭头 ≈40;实现实测(模拟器像素统计):66 / 54 / 46 / 42,另有空终点框 6(偏差 2/8/0/2 px)。
- 最深框不钉屏幕右缘(实测:实时数据子框止于 x≈113/160;三遥/装置维护/记录查询子框止于 x≈127/160)。
- 本菜单最深路径宽 = 实现实测 66+42+42+6−3(共享边框)= 153 < 160 → 级联不会超屏宽;>160 参考图未覆盖,实际行为 = 框被全屏容器在屏缘裁切(右缘截断),无特殊处理。
4.3 垂直布局(Y):每框独立(顶对齐 + 底对齐上移)
- 默认:顶边 = 父框"选中行"行顶(实时数据/整定两例实测 Δ≤2px);框高 = 条目数 × 行高(按内容,不强顶屏底)。
- (顶边 + 框高) 超出屏底 → 整框上移使子框底边对齐父"选中行"行底(不是贴屏底):参考图实测 三遥设置 top=26 / 装置维护 top=54 / 通讯参数 top=82 / 记录查询 top=12;实现实测 28 / 58 / 88 / 13(Δ=2/4/6/1 px——四个样本不满足同一公式,实现取"底边对齐选中行行底"这一自洽规则,两端极例(三遥/记录查询)精确吻合,见 §8.2 注 5)。
- 上移后框可叠到屏顶状态行/装饰区(记录查询子框 top≈12)——顶部不钳制。
- 比整屏还高 →
↑/↓滚(初始对齐"选中行")。
4.4 选中态与焦点
- 选中态:整行反色(白带、黑字);菜单行有 ▶,叶子行无;选中行保留 ▶(箭头随反色变黑,参考图 02/04 实测);非选中行黑底白字(白箭头)。
- 焦点 = 最右(最深)框;框无页头/返回按钮(框即紧凑列表,返回靠
←);仅根框带主界面装饰(顶部"浅 V" + 状态行"当前模式 : 无模式")。
五、显示层:ui_menu_view(持有全部 LVGL 对象)
一句话职责:按"状态 + 数据表"渲染级联框——持有全部 LVGL 对象(样式 / 持久容器 / 框体;根框装饰与状态行由容器自绘、无对象),由逻辑层经 8 个接口(§5.5)驱动建/毁/刷新。行不是对象,由框体自绘。 绝不做:持有导航状态(17 B 在逻辑层);解释按键(输入回调挂在逻辑层)。
5.1 职责边界
- 持有全部 LVGL 对象(样式/持久容器/框体);根框装饰与状态行由容器自绘(无对象)
- 按
frame_build/destroy/refresh调用建/毁/刷新(依据"状态 + 数据表") - 现算框几何(§5.3 公式)并按层缓存(
s_frame[]) - 提供滚动位读写(
set_scroll/get_scroll)供逻辑层持久化
5.2 布局常量(设备 px,定义于 ui_menu_view.c)
| 常量 | 值 | 含义 |
|---|---|---|
MENU_ROW_H |
15 | 行高(字体行高 13 + 上下各 1) |
MENU_ROW_TXT_TOP |
1 | 行内文字垂直偏移(居中) |
MENU_ROW_PAD_L / MENU_ROW_PAD_R |
2 / 2 | 行内左/右留白(紧凑,保证级联总宽 <160) |
MENU_ARROW_GAP |
2 | 行内 文本→箭头 间距 |
MENU_FRAME_BORDER |
1 | 框边框宽(子框与父框共享该边框列) |
MENU_ROOT_BOX_X / MENU_ROOT_BOX_Y |
0 / 44 | 根框左上(左对齐、贴左缘) |
MENU_ROOT_STATUS_X / MENU_ROOT_STATUS_Y |
17 / 20 | 状态行"当前模式 : 无模式" |
MENU_DECO_LEG_W / MENU_DECO_HORIZ_W |
3 / 2 | 顶部"浅 V"腿线宽 / 水平线宽(1bpp 斜线边缘易被阈值吃掉,腿须比水平线粗) |
字体度量(ui_font_cn_12,宋体 12px 1bpp):汉字步进 12 px/字、▶ 步进 10 px(adv_w=165/16)、行高 13 px(见 UI_中文字体生成与配置.md)。
5.3 几何计算(ui_menu_view_frame_build())
frame_build 拿到 (depth, node, sel, scroll) 后,先算三个数——框宽 w、位置 (x, y)、框高 h——再照数建框体、注册行数据、应用选中/滚动。四步各配一格图(下图 ①~④):
① 框宽 w = 最宽行 + 2 条边框(图①)
一行横向构成:边框1 | 留白2 | 文本 |(菜单行: 间距2 + ▶10)| 留白2 | 边框1。文本宽按字体实际步进量(lv_text_get_width,⚠️ 见本文 §8.2 第 1 条(lv_text_get_width 的 length=0 陷阱));同框各行宽度可以不同,框宽只由最宽的一行决定,窄行左对齐、右侧留空:
w = max_行( PAD_L + 文本_w + [GAP + ▶_w] + PAD_R ) + 2×BORDER
实测五类框宽(1× 原尺寸,px):
| 框 | 最宽行构成 | 框宽 |
|---|---|---|
| 根框(7 行,4 字 + ▶) | 2 + 48 + 2 + 10 + 2 | 66 |
| 4 字无箭头框 | 2 + 48 + 2 | 54 |
| 3 字无箭头框 | 2 + 36 + 2 | 46 |
| 2 字 + ▶ 框 | 2 + 24 + 2 + 10 + 2 | 42 |
| 空终点框(无行) | 仅留白 + 边框 | 6 |
② 子框 X:贴在父框右缘,共享 1px 边框列(图②)
x = 父.x + 父.w − 1
减掉的 1 = 父框右边框那列像素:子框的左边框与父框的右边框是同一列(边框重叠 1px),所以零间隙、不重画,逐级向右生长。
③ 子框 Y(默认):顶边对齐父框"选中行"行顶(图③)
y = 父.y + BORDER + 父sel × ROW_H
父框选中哪一行,子框顶边就落在那一行的行顶——父子垂直方向"肩并肩",选中行不动子框就不动。
④ 子框 Y(溢出时):整框上移,底边对齐选中行行底(图④)
y + h > 160 → y = 选中行行顶 + ROW_H − h
子框比"选中行以下到屏底"的空间高 → 整框上移,底边卡在选中行行底(不是贴屏底——贴屏底会让父子脱钩,选中行一移子框位置就乱跳)。上移后仍顶出屏顶(y<0)→ 钳到 0;再不够 → 框高截到可见区、行在框内滚动(§5.4 LV_SCROLLBAR_MODE_AUTO)。
框高 h = 按内容,不强顶屏底
h = 行数 × ROW_H + 2×BORDER
行数少框就矮(2 行 = 32 px)、行数多框就高;上限受屏高 160 约束(触发④的上移/截断)。
公式汇总(ui_menu_view_frame_build() 的实现即此五式):
w = max_行(PAD_L + 文本_w + [菜单: GAP + ▶_w] + PAD_R) + 2×BORDER (文本宽经 lv_text_get_width)
h = 行数 × ROW_H + 2×BORDER
子框 x = 父.x + 父.w − 1 (d>0,共享边框列)
子框 y = 父.y + BORDER + 父sel×ROW_H (d>0,顶对齐选中行行顶)
y + h > 160 → y = 选中行行顶 + ROW_H − h;y < 0 → y = 0(截断/框内滚动兜底)
5.4 对象模型
- 屏幕根容器(全屏 flat)→ 单一持久全屏容器
s_cont(各层框均建其内;LV_OBJ_FLAG_IGNORE_LAYOUT+ 绝对定位,透明,永不删;级联框须同屏叠加) - 根框体
s_frame_box[0]持久(位置 (0,44)、宽自适应);子框体进入时建 / 返回时毁(lv_obj_create(s_cont)+ flat + 边框样式 + 自绘回调,IGNORE_LAYOUT绝对定位) - 框体:自动滚动条(
LV_SCROLLBAR_MODE_AUTO);行自绘(无子对象),行内容超框高才框内滚动(内容高经LV_EVENT_GET_SELF_SIZE虚报) - 行 = 数据(节点下标注册
s_row_node[d][i],无对象);框体LV_EVENT_DRAW_MAIN自绘:选中行 = 整行白带(lv_draw_rect),每行 =lv_draw_label文本(左留白 2)+ 箭头(紧跟文本、间距 2;叶子无箭头),静态字符串(Flash 表、不拷贝);按滚动偏移枚举可见行、重绘区外的行跳过(culling) - 选中行 = 整行反色(白带、黑字),箭头随反色变黑(§4.4);非选中行黑底白字(白箭头)
- 根框装饰(浅 V + 状态行):容器
DRAW_MAIN自绘(3 段线lv_draw_line+ 1 行文本lv_draw_label,无对象;两腿 3px、水平线 2px) - 空终点框:叶子 ENTER 后建 w=6、h=2 的占位框(无行),仅 ESC 可返回
5.5 公开 API
| 接口 | 作用 | 调用方 |
|---|---|---|
int ui_menu_view_init(void) |
样式 + 持久容器(装饰/状态行自绘回调)+ 根框框体(含自绘回调)(一次性) | 逻辑层 ui_menu_init |
lv_obj_t *ui_menu_view_container(void) |
取持久容器(焦点/键唯一入口;逻辑层在其上绑输入组/键回调) | 逻辑层 |
void ui_menu_view_frame_build(uint8_t depth, uint8_t node, uint8_t sel, uint16_t scroll) |
建该深度框:现算几何 + 注册行数据 + 应用选中/滚动(d=0 仅(重)注册行) | 逻辑层(进 / 初始化) |
void ui_menu_view_frame_destroy(uint8_t depth) |
毁该深度框(框体连自绘回调整体删除、行数据清空;容器保留;根框 d=0 不可毁) | 逻辑层(退) |
void ui_menu_view_frame_refresh(uint8_t depth, uint8_t sel) |
刷选中(选中数据 + 跟随滚动 + 重绘框体,不重建框) | 逻辑层(移选后) |
void ui_menu_view_set_scroll(uint8_t depth, uint16_t scroll) |
应用滚动偏移(行数据重注册后按此还原) | 逻辑层 |
uint16_t ui_menu_view_get_scroll(uint8_t depth) |
读当前滚动偏移(供逻辑层回读持久化) | 逻辑层 |
void ui_menu_view_focus(void) |
焦点置持久容器(唯一键入口;框深度由 s_depth 决定) |
逻辑层(进/退) |
公共常量:
MENU_PAGE_MAX 4(深度上限 = 根框 + 3 级子框)、MENU_ITEM_MAX 16(单框最大条目数)、MENU_SCREEN_W/H 160(与ui_port.h/ 模拟器一致)。
5.6 RAM 字段(显示层)
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
s_cont |
lv_obj_t* |
4 B | 单一全屏持久容器(焦点/键唯一入口;各层框建其内);透明常驻、永不删(防焦点对象悬空) |
s_row_node[4][16] |
uint8_t |
64 B | 行数据:节点下标(名称/箭头绘制时现查,无对象);仅屏上框注册 |
s_style_frame/scrollbar |
lv_style_t×2(动态)+ flat 常量样式(Flash) |
~200 B | 样式(一次性初始化,常驻;flat 为常量样式零池占用,见 §9.5) |
s_frame_box[4] |
lv_obj_t*×4 |
16 B | 框体(d=0 持久;d>0 进入时建、返回时毁) |
s_frame[4] |
menu_frame_t(built/sel/item_cnt/x/y/w/h) |
48 B | 每框几何缓存:子框对齐要用父框刚算出的 x/w/选中行,故按层留一份;随时可由 s_path/s_sel 重算,不属于导航状态 |
只给"屏上的框"注册行数据(每框 ≤16 行);返回时清掉被关闭最右框的行数据 → 行自绘无对象,LVGL 池只占屏上框体(2~3 框),关闭即释放。
六、逻辑层:ui_menu(纯状态机)
一句话职责:持有唯一 17 B 导航状态(路径/选中/深度/滚动)+ 键语义(移/进/退),单向驱动显示层建/毁/刷新级联框。 绝不做:持 LVGL 对象句柄(唯一例外:输入接线 2 调
lv_group_add_obj/lv_obj_add_event_cb);渲染。
6.1 职责边界
- 持有 17 B 导航状态(唯一持有者,§6.2)
- 解释按键:移(↑↓)/ 进(→/ENTER)/ 退(←/ESC)(§6.4)
- 查询数据层(
children/valid)判键行为(§3.4) - 单向驱动显示层:
frame_build/destroy/refresh/focus+ 滚动位回读持久化
6.2 17 B 导航状态(唯一"必须持久"的状态)
| 字段 | 类型 | 大小 | 用途 |
|---|---|---|---|
s_path[4] |
uint8_t×4 |
4 B | 路径:s_path[d]=第 d 层"展示的节点下标"(s_path[0]=0 主界面);即"从根到当前(最右)框"的节点链 |
s_sel[4] |
uint8_t×4 |
4 B | 各层选中下标:返回时还原该层高亮(选中不跳) |
s_depth |
uint8_t |
1 B | 当前深度(0=根框)= 焦点所在(最右)框 |
s_scroll[4] |
uint16_t×4 |
8 B | 每框滚动偏移(px):该框销毁再进入、行重建时按此还原位置 |
| 合计 | 17 B | 其余(条目列表/名字/菜单叶子/框几何)全部现算,不落 RAM(见 §7.3) |
6.3 导航规则:数据如何驱动"移 / 进 / 退"
一句话:导航从不遍历树,它只反复向
g_menu_tab问一个问题—— "谁的 parent 等于当前节点?" 答案(按表序)就是当前页要显示的条目。 进 = 把选中的子项记为新的"当前节点"再问一遍;退 = 忘掉它、还原上一个"当前节点"。 表是扁平的(无嵌套指针),"树"只是"反复按 parent 过滤"营造出来的错觉。
当前页 = 对表的一次过滤(页面按需构建——进入时建、返回时毁,内存 = 当前导航路径,见 §7.2):
显示"节点 N 的页面" ⇔ 取 g_menu_tab 中所有 parent == N 的行(按表序 = 显示序)
| 你所在的页 | 当前节点 N | 执行的查询 | 页面上出现的条目 |
|---|---|---|---|
| 主界面(根页) | 0(主界面) | parent == 0 |
装置信息、实时数据▶、参数定值▶、三遥设置▶、装置维护▶、通讯参数▶、记录查询▶(7 项) |
| 参数定值页 | 6(参数定值) | parent == 6 |
整定▶、查看▶(2 项) |
| 整定页 | 7(整定) | parent == 7 |
参数、定值、控制字、软压板(4 项,全叶子) |
行尾有没有 ▶ 也由同一张表判:
parent == 该节点非空 → 菜单(有 ▶);为空 → 叶子(无 ▶)。
移(↑ / ↓):只改当前页的选中下标 s_sel[d](首尾环绕),不碰表。
进(ENTER / →):选中条目 = 子节点 M(kids[s_sel[d]])。向表问一句"M 还有孩子吗?"(parent == M 非空?)
- 有 → M 是菜单:压一层,
s_path[d+1]=M,再按"过滤"显示 M 的孩子。 - 没有 → M 是叶子:进终点(空终点框占位,本阶段不展开内容页),不再下钻。
退(ESC / ←):弹一层——销毁最右框、回到 depth-1 的父框;父框选中项保持为"刚才那个 M 在父框里的下标"(= 按下 ENTER 前的位置,不跳)。
完整走查(# = g_menu_tab 下标):主界面 → 参数定值 → 整定 → 参数,再一级级退回
【根页 node=0】 parent==0 → 7 项;选中 [2]=参数定值(#6)
ENTER ─▶ 问"6 有孩子吗"→ parent==6 非空(整定/查看) → 压层
【参数定值页 node=6】 parent==6 → [整定#7, 查看#12];选中 [0]=整定
ENTER ─▶ 问"7 有孩子吗"→ parent==7 非空(参数/定值/控制字/软压板) → 压层
【整定页 node=7】 parent==7 → [参数#8, 定值#9, 控制字#10, 软压板#11];选中 [0]=参数
ENTER ─▶ 问"8 有孩子吗"→ parent==8 为空 → 叶子 → 进终点(空框)
【参数终点 node=8】(占位,无条目,到此为止)
ESC ─▶ 弹层 → 回【整定页】,选中仍 [0]=参数
ESC ─▶ 弹层 → 回【参数定值页】,选中仍 [0]=整定
ESC ─▶ 弹层 → 回【根页】,选中仍 [2]=参数定值
导航状态 = 一条"当前节点"链(= 从根到当前框的路径)
s_path[0]=0 ─▶ s_path[1]=6 ─▶ s_path[2]=7 ─▶ s_path[3]=8
根页 参数定值页 整定页 参数终点
depth=0 depth=1 depth=2 depth=3
- 链上每个 node 都是上一个的孩子(parent 关系)→ 这条链天然就是路径,无需额外记录"我从哪来"。
- 进 = 链尾追加(选中的子节点接上去);退 = 链尾弹掉。
- 内存只占"当前这条链"(
s_path[0..s_depth])+ 屏上各框各一套行数据(屏外框行清空),退回即释放(§7.2/§7.5)。
对应到代码(逻辑层决定、显示层执行;三层见 §一):
| 逻辑 | 函数 | 读 / 写的数据 |
|---|---|---|
| 建框(显示某节点子项) | 显示层 ui_menu_view_frame_build(d, node, sel, scroll)(由逻辑层调用) |
读 s_path[d]+g_menu_tab 现算条目;算 x/顶边/框宽、注册 s_row_node[d][*]、恢复 s_scroll[d] |
| 进(压层 / 终点) | 逻辑层 menu_open(child) |
写 s_depth+1、s_path[d+1]=M、s_sel[d+1](同节点还原 / 换节点清零);再 frame_build + focus |
| 退(弹层) | 逻辑层 menu_back() |
ui_menu_view_frame_destroy(s_depth);s_depth-1、焦点回父框(父框不动、无需重建,s_sel/s_scroll 原样保留) |
| 移选 | 逻辑层 menu_key_handle(d, key) → menu_refresh_layer(d) |
写 s_sel[d](首尾环绕);frame_refresh(高亮 + 跟随滚动);滚动位回读入 s_scroll[d] |
6.4 键语义与分发
键位语义:
| 键 | 行为 |
|---|---|
↑ / ↓ |
当前(最右)框内上/下移动选中(首尾环绕);到边缘且该框可滚则滚动该框 |
→ 或 ENTER |
选中菜单节点 → 其右侧弹出子框;选中叶子节点 → 进终点(空框) |
← 或 ESC |
返回:关最右框(毁框体+行数据)、焦点回父框、选中态保持(不跳) |
| 焦点 / 框头 | 焦点 = 最右(最深)框;无逐框页头/返回按钮(框即紧凑列表,返回靠 ←);仅根框带主界面装饰 |
进入/返回示例(路径,→/← = 进/退动作方向):
主界面 → 参数定值 → 整定 → 参数
主界面一级菜单 二级菜单三级菜单
返回:主界面 ← 参数定值 ← 整定 ← 参数
分发(menu_key_handle(d, key),d = 焦点所在层):
cnt = ui_menu_tab_children(s_path[d], …) ← 先问数据层:本框几个条目
UP/DOWN : cnt>0 → s_sel[d] 首尾环绕 → menu_refresh_layer(高亮+跟随滚动+滚动位回读)
ENTER/RIGHT: cnt>0 且 d+1<4 → menu_open(kids[s_sel[d]]) (叶子也压层 → 空终点框)
ESC/LEFT : d>0 → menu_back() (根框 d=0 不响应;空框只响应返回)
6.5 公开 API
int ui_menu_init(void)—— 唯一公开入口(uiTask 调用);五步时序见 §1.5,表自检见 §3.5。- 内部(
static):menu_open/menu_back/menu_key_handle/menu_key_cb/menu_refresh_layer(见 §6.3/§6.4)。
七、层间配合细节
7.1 按键全序列(逐键分层动作)
通用管线见 §1.4;本表按键列出逻辑层改什么状态、显示层做什么。
| 键 | 逻辑层(17 B 状态) | 显示层(画面) |
|---|---|---|
| 移 ↑/↓ | s_sel[d] = (s_sel[d] ∓ 1 + cnt) % cnt(首尾环绕);cnt=0(空框)不变 |
frame_refresh(d, s_sel[d]):改选中数据 + 跟随滚动 + 重绘框体(不重建);随后逻辑层 get_scroll(d) 回读入 s_scroll[d] |
| 进 →/ENTER | s_depth+1;s_path[d+1]=kids[s_sel[d]];s_sel[d+1](同节点还原原选中/滚动,换节点清零) |
frame_build(d+1, …):现算几何(贴父框右缘、顶对齐/底对齐上移)→ 注册行数据 → 应用选中/滚动;focus()(焦点恒在持久容器);叶子 → 空终点框(w=6, h=2) |
| 退 ←/ESC | s_depth−1(根框 d=0 不响应) |
frame_destroy(d):毁框体(含自绘回调,容器保留,池内存即回落);focus()(焦点恒在持久容器);父框不动、无需重建,s_sel/s_scroll 原样保留 |
7.2 框生命周期(进建退毁)
- 进时建(
frame_build):现算几何(§5.3;父框几何取父层s_frame缓存)→ 注册行数据(行 = 子项,现算不落地)→ 应用选中高亮 + 滚动位。 - 退时毁(
frame_destroy):框体(含自绘回调)整体销毁、容器保留(永不删,防焦点对象悬空);LVGL 池内存随即回落。 - 根框持久(
ui_menu_view_init建一次,永不毁);子框随进/退建/毁。 - 焦点:持久容器为唯一焦点对象(初始化时入输入组并获焦,永不删、焦点不迁移);框深度由逻辑层
s_depth决定,无焦点争抢。 - 空终点框:叶子 ENTER 后建 w=6、h=2 占位框(无行),仅 ESC 可返回。
7.3 存 vs 算(关键区分)
核心原则:能推导的就不存——某页的条目列表可由
g_menu_tab的parent==节点随时重算; 故只持久化"不可推导"的导航历史(路径 + 各层选中 + 各框滚动),页面内容进出时按需重建。
| 数据 | 存 / 算 | 依据 |
|---|---|---|
| 路径(在哪些层) | 存 s_path(逻辑层) |
用户导航历史,不可推导 |
| 各层选中 | 存 s_sel(逻辑层) |
用户选择,不可推导 |
| 每框滚动位 | 存 s_scroll(逻辑层) |
用户 ↑/↓ 滚动,不可推导 |
| 某框条目列表 | 算 | ui_menu_tab_children(s_path[d]) = 过滤 parent==s_path[d] |
| 条目是菜单/叶子(有无 ▶) | 算 | ui_menu_tab_has_children(node) = 存不存在 parent==node |
| 条目显示名 | 算 | g_menu_tab[node].name |
| 每框 x / 顶边 / 框宽 | 算 | 右贴靠布局 + 顶对齐/底对齐上移(§四),现算(按层缓存在 s_frame[]) |
结论:持久导航状态 =
s_path+s_sel+s_depth+s_scroll(共 17 B,全在逻辑层); 框的条目/名字/菜单叶子/位置(框宽·x·顶边) 100% 现算,进/退时重建。
7.4 导航操作 × 字段读写
| 操作 | 读 | 写 |
|---|---|---|
| 建框(进 / 初始化) | s_path[d]、g_menu_tab(现算条目)、s_sel[d]、s_scroll[d] |
算该框 x/顶边/框宽(§四);注册 s_row_node[d][*];恢复滚动位 |
| 移(↑/↓) | s_sel[d]、s_scroll[d]、s_row_node[d][*] |
s_sel[d](首尾环绕);到边缘则改 s_scroll[d](滚);刷新高亮 |
| 进(ENTER/→,菜单) | s_sel[d]→子节点 M、ui_menu_tab_has_children(M)=真 |
s_depth+1、s_path[d+1]=M、s_sel[d+1]=0;新框贴父框右缘生成(既有框不动、不重排) |
| 进(ENTER/→,叶子) | ui_menu_tab_has_children(M)=假 |
s_depth+1、s_path[d+1]=M(终点框) |
| 退(ESC/←) | s_depth |
s_depth-1、销毁最右框行、焦点回父框(父框不动、无需重建,s_sel[d-1]/s_scroll[d-1] 原样保留) |
7.5 内存预算
Flash(const,数据层):g_menu_tab 336 B + 名字 ~400 B ≈ ~0.7 KB
RAM(静态/常驻):
| 组 | 层 | 大小 |
|---|---|---|
导航历史 s_path+s_sel+s_depth |
逻辑层 | 9 B |
每框滚动 s_scroll[4] |
逻辑层 | 8 B |
容器 s_cont |
显示层 | 4 B |
行数据 s_row_node[d][i] [4][16] |
显示层 | 64 B |
框体句柄 s_frame_box[4] |
显示层 | 16 B |
框几何缓存 s_frame[4](menu_frame_t) |
显示层 | 48 B |
样式 s_style_frame/scrollbar(动态)+ flat 常量样式(Flash,零池占用 §9.5) |
显示层 | ~200 B |
| 小计 | ~0.35 KB(逻辑层 17 B + 显示层 ~0.33 KB) |
LVGL 池(动态,LV_MEM 16 KB 预算内):行自绘无对象,池只占屏上框体(23 个 2 KB**,仍远小于 16 KB(池已于 2026-10-15 由 40 KB 缩到 16 KB,见 lv_obj)+ 根框装饰/状态行,
典型 ≈ **1docs/内存管理/RAM使用分析与优化方案.md §4.1.1;真机开机实测 menu build done: used=3192)。
八、验证与实施陷阱
8.1 分级验证
| 环节 | 方法 |
|---|---|
| 模拟器无头 | lvgl_sim.exe --screenshot shot.bin / --keys <seq> shot.bin;I1 原始像素(160×160 = 3200 B)转 PNG(PIL)目检 |
| 对照参考图 | 7 状态(根页 + 6 个一级子框)逐一对照参考图 01~07;反色行/框几何用像素统计核验(偏差见 §8.2 注 5) |
| 编译 | /keil-build(ARMCC,0 错 0 警,强制) |
| 真机 | /serial-boot-test(Required 含 UI port init: OK;UI menu init: OK;屏幕内容仍需人工目检) |
8.2 陷阱与保真度说明
- 本 LVGL v9.3 的
lv_text_get_width(txt, length, font, letter_space)length=0 时返回 0(不是"整串");必须传strlen——否则框宽计算全 0(根框退化 8 px)。 - 无头模式不得调
lv_timer_handler()/lv_refr_now():I1 +LV_DISPLAY_RENDER_MODE_FULL全屏刷新会进渲染死循环(截图因此走lv_snapshot);--keys注键是同步处理,无需驱动帧。 - 选中行保留 ▶(随反色变黑):参考图 02/04 原图即"选中行箭头仍在、仅随反色变黑",勿实现成"选中隐箭头"(§4.4)。
- 字体缺字形 = 方框:菜单树新增/改名的汉字必须同步加入字符集
ui_cn_chars.txt(当前 224 唯一字,2026-10-29 实测)并重生成字体(重跑extract_cn.py+make_font.py),否则新字显示为方框(流程见UI_中文字体生成与配置.md)。 - 参考图保真度:参考图为手工 mockup,4 个样本(子框顶位置)不满足单一公式;实现采用自洽规则(顶对齐/底对齐上移),实测与参考图偏差:顶位置 2/4/6/1 px(三遥/装置维护/通讯参数/记录查询),框宽 2/8/0/2 px(4字+箭头/4字/3字/2字+箭头)。两端极例(三遥、记录查询)精确吻合;统一规则优于逐屏特调,偏差范围内不影响识读与操作。
- 装饰"浅 V"两腿线宽必须 3(水平线 2):1bpp 下细斜线边缘被阈值吃掉,腿不粗于水平线即消失;且依赖
LV_DRAW_SW_COMPLEX=1(见UI_设计架构.md§1.4 约束 7)。
九、内容页(ui_content_page)
一句话职责:菜单叶子节点进入后的独立全屏页面——装置信息(node 1)、交流量(node 3)、直流量(node 4)、用户密钥密码页(受保护叶子,表单/操作页)。与级联框同屏互斥,按需建毁。 文件:
Drivers/BSP/UI/ui_content_page.c/h(MCU / PC 模拟器双端共用,注册见UI_设计架构.md§1.2/§1.4 约束 5)。
9.1 对象树与生命周期
菜单屏(持久) 内容页屏幕(按需建毁)
screen_menu screen_page = lv_obj_create(NULL)
└─ cont 持久容器(焦点/键入口) └─ s_cont 全屏容器(flat + CLICK_FOCUSABLE,唯一键入口)
├─ 根框体 / 子框体… (行全部自绘,无子对象——与级联框同一"行=数据"策略)
| 动作 | 序列 |
|---|---|
| 开页(菜单 ENTER 叶子 node 1/3/4) | lv_obj_create(NULL) 新建屏 → 建全屏容器(flat 常量样式 + 键回调 + 自绘回调)→ 入默认输入组并获焦(先于切屏,按键立即由本模块接管)→ lv_screen_load 切屏。菜单屏对象保留不销毁 |
| 关页(ESC/←) | 焦点先回菜单持久容器 → 切回菜单屏(原样还原)→ 删容器、再删屏。LVGL 池内存随即回落 |
- 导航状态不压层:菜单
s_depth/s_sel/s_scroll原样保留,返回即还原;内容页只记自身选中行s_sel(每次进入从首行开始,不跨次保留) - 单页互斥:同一时刻至多一个内容页打开(
s_open哨兵防御)
9.2 页面定义表(Flash 常量,几何实测自参考图 10/20/21)
| 参数 | 装置信息 | 交流量 | 直流量 |
|---|---|---|---|
selectable |
0(静态键值行) | 1(数据列表) | 1(数据列表) |
| 行数 / 每页 | 6 / 不分页 | 24 / 8(3 页) | 3 / 单页 |
| 首行槽顶 y / 行距 | 40 / 15 | 24 / 14 | 24 / 14 |
| 选中白带高 | 无 | 13(x=2..156) | 13(x=2..156) |
| 左列 x / 值列 x | 标签 6 / 86 | 名 7 / 101 | 名 7 / 101 |
| 副标题 | "馈线自动化终端" y=25 | — | — |
| 页脚(y=143,x=6) | 固定"第 10 页,共 1 页"(按参考图原样) | 动态"第 X 页,共 3 页" | 动态"第 1 页,共 1 页" |
- 标题 y=2(居中,浅 V 装饰区内;装饰几何与菜单根框一致:两腿 3px / 水平线 2px)
- 用户密钥页(表单/操作页,无行槽):title_y=44、"浅 V"/页脚关(hide_deco/hide_footer)、key_intercept=1;自绘 3 个 14×14 数字框(间距 6px、行顶 y=78、首框左 x=53)+ 反显光标,详见
内容页架构.md§4.1 - 列表页窗口 = 选中行所在页(首可见行 =
(sel/每页数)×每页数),行槽位置固定、数据随页切换 - 数据表为 Flash
const cp_row_t[](名+值字面量,不 strdup);交流量第 2/3 页当前为占位数据
9.3 键语义
| 键 | 行为 |
|---|---|
| ↑ / ↓ | 列表页移选(首尾环绕、跨页连续),窗口随页切换后重绘;信息页不响应 |
| ESC | 关页返回菜单(导航状态原样还原;恒不可拦截) |
| ← | key_intercept=1 时先交页面 key_cb(true = 已处理不关页,如密码页移光标);未置标志或未处理 → 照旧关页返回 |
9.4 公开 API
bool ui_content_page_open(content_page_id_t id); /* 开页:建屏+切屏+焦点置页面容器;ID 无效/已有页打开 → false */
void ui_content_page_close(void); /* 关页:焦点回菜单容器 + 切回菜单屏 + 销毁页面 */
void ui_content_page_invalidate(void); /* 整页重绘(表单/操作页自身状态变化后调用) */
bool ui_content_page_is_open(void); /* 查询是否有内容页打开 */
- 节点→页映射在逻辑层
ui_menu.c(menu_content_page_for(node)):node 1 →CONTENT_PAGE_DEVICE_INFO、3 →AC、4 →DC;其余叶子无内容页(进空白终点框)。需密码叶子(行尾#P,如 node 8 参数;密码检查先于内容页判断,带内容页的需密码叶子同样先经密码页)先 arm + 开CONTENT_PAGE_PASSWORD,确认一致后回调(携授予权限) 先关页再续做进入动作(有内容页→开内容页携权限,页面经ui_content_page_is_editable()判只读/可编辑);无密码叶子直接开(默认只读)
9.5 内存预算(模拟器 x64 实测,16 KB 池)
| 时刻 | LVGL 池 used | 说明 |
|---|---|---|
| 菜单基线(根框建完) | 5368 B | 同 §1.5;2026-10-11 裁剪 LVGL 控件类后基线 5440→5368,本行旧值 4784 是更早的快照 |
| 内容页打开峰值 | +776~1192 B(增量口径) | 增量为实测值、与基线无关:绝对水位随基线漂移,5560~5976 B 是老基线 4784 时代的快照,按 5368 基线的绝对峰值待重测(回归脚本只钉基线与往返两个数) |
| 关页后回落 | 5448 B | 一次性残留 +80 B(见下),非泄漏 |
+80 B 一次性残留分解(均为 LVGL 核心行为,与内容页模块无关;MCU 32 位下更小):
- +8 B:
disp->screens数组高水位——开页时第 5 个屏使 TLSF 块 32→40 B,关页缩回后块不归还(分配器行为) - +72 B:
lv_screen_load()内部对菜单屏执行lv_obj_set_pos(act_scr,0,0)→ 局部 X/Y 样式各分配一个零初始化lv_style_t+属性数组;每屏一次性,随菜单屏销毁才释放
常驻样式零池占用:flat 样式用常量样式(LV_STYLE_CONST_INIT + lv_style_const_prop_t[],属性存 Flash)。动态 lv_style_t 首次 set_prop 会从池分配 values_and_props 且永不释放(x64 实测 88 B/个)——常驻样式禁用动态初始化(见 UI_设计架构.md §5.6)。