Files
STM32F4-Base/docs/UI设计/菜单页面设计.md
T
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/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 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

56 KiB
Raw Blame History

菜单页面设计

用途:定义 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——再照数建框体、注册行数据、应用选中/滚动。四步各配一格图(下图 ①~④):

级联框几何计算四步:① 行剖面定框宽 ② X 贴父框右缘 ③ Y 顶对齐选中行 ④ Y 溢出上移

① 框宽 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 个 lv_obj)+ 根框装饰/状态行, 典型 ≈ **12 KB**,仍远小于 16 KB(池已于 2026-10-15 由 40 KB 缩到 16 KB,见 docs/内存管理/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 陷阱与保真度说明

  1. 本 LVGL v9.3 的 lv_text_get_width(txt, length, font, letter_space) length=0 时返回 0(不是"整串");必须传 strlen——否则框宽计算全 0(根框退化 8 px)。
  2. 无头模式不得调 lv_timer_handler() / lv_refr_now():I1 + LV_DISPLAY_RENDER_MODE_FULL 全屏刷新会进渲染死循环(截图因此走 lv_snapshot);--keys 注键是同步处理,无需驱动帧。
  3. 选中行保留 ▶(随反色变黑):参考图 02/04 原图即"选中行箭头仍在、仅随反色变黑",勿实现成"选中隐箭头"(§4.4)。
  4. 字体缺字形 = 方框:菜单树新增/改名的汉字必须同步加入字符集 ui_cn_chars.txt(当前 224 唯一字,2026-10-29 实测)并重生成字体(重跑 extract_cn.py + make_font.py),否则新字显示为方框(流程见 UI_中文字体生成与配置.md)。
  5. 参考图保真度:参考图为手工 mockup,4 个样本(子框顶位置)不满足单一公式;实现采用自洽规则(顶对齐/底对齐上移),实测与参考图偏差:顶位置 2/4/6/1 px(三遥/装置维护/通讯参数/记录查询),框宽 2/8/0/2 px(4字+箭头/4字/3字/2字+箭头)。两端极例(三遥、记录查询)精确吻合;统一规则优于逐屏特调,偏差范围内不影响识读与操作。
  6. 装饰"浅 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)。