Files
STM32F4-Base/docs/UI设计/菜单页面设计.md
T
wandering1 a7f526569a perf(mem): R-9 落地——LVGL 池 40→16 KB,SRAM1 74.9%→56.1%
取值依据是实测水位,不是预估:菜单根框 5,368 B、开页-关页往返
5,448 B、最重页面峰值 6,568 B(占 16 KB 的 40%)、真机开机
used=3,192 B。注意 16,384 B 建池后 lv_mem_monitor 报的 total 是
15,020 B(TLSF 控制块与对齐元数据 ≈1,364 B),核算余量用后者。

两端 lv_conf.h 同步改值(lv-conf-sync 生效值比对 163 项 PASS),
Lib/lv_conf.h 的 UTF-8 BOM 已按原样补回(Edit 会静默剥掉)。

验证:
- @build 0 错 0 警;ZI 154,456 → 129,880,精确 −24,576 B
- SRAM1 98,144/74.9% → 73,568/56.1%(余 57,504 B);CCM 不变 87.9%
- 模拟器菜单回归 PASS(8 状态像素 + 池基线 5,368 B 未变);11 页
  黄金参考逐字节 10/11 一致,往返池水位 5,448 B 分毫未变
- 唯一差异 netcfg_save 保存态:用 40 KB 旧池做 A/B 复测同样失败,
  确认与本改动无关(属 506eccc 的 cfg_file 行为变化),未刷黄金
  参考掩盖,留给该改动属主处置
- @test PASS:fw build Oct 15 2026 07:31:34,无 [ERR]/Fault,
  开机 622 ms 到 net init: OK,menu build done: used=3192
- 本地 CI 全绿(L0 + L0.5,15 runner / 955 断言)
- 台账受管区与内存快照区已 --write 回填,mem-budget / mem-numbers
  由红转绿

连带结论:R-2 后半待迁体量 54,146 → 29,570 B,瓶颈由 SRAM1 换到
CCM(只剩 7,904 B);解锁链第 ① 步完成,下一步只差缩堆 56→32 KB
(B5 是反向扩容,缩回前须重跑 stack_report.py + 上板 min_ever)。

文档同步 20 处:本报告按修复即销项删除 R-9 行,并改写 §0.2/§0.3/
§0.4/§5.1/§5.2/§5.3/§6.4 与变更记录、去处表;RAM使用分析 §2.6.2
新增实测水位表、阶段 1.1 标已执行;台账 §3 解锁链、登记簿 N-27、
任务规划 README W3 行与链路图;UI 四篇、产品需求、内存内容分配
策略、运行与自检记录、项目部署环境要求、测试开发标准流程、
simulator/README、ui-verify skill 常量。顺带订正三处与脚本不符的
旧值:池水位常量 4784/4864 → 5368/5448、FreeRTOS 堆 48 → 56 KB。
2026-10-15 09:56:15 +08:00

910 lines
69 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 菜单页面设计
> **用途**:定义 DTU 主菜单的**内容(42 节点层级树)、视觉规范、三层代码架构**(数据层 / 显示层 / 逻辑层),以及叶子节点进入后的**内容页模块**(§九)。
> **范围**:菜单层级(节点/父子/层级/进出导航)+ 三个已实现的内容页(装置信息/交流量/直流量,§九);其余叶子的内容页后续按同模式扩展。
> **来源**:用户提供的主菜单截图 7 屏(根页 + 实时数据/参数定值/三遥设置/装置维护/通讯参数/记录查询各一级子页 + 参数定值·整定二级)。
> **导航模型**:树形**逐级进入**——`ENTER`/`→` 进入子页,`ESC`/`←` 返回上一级;最多 **4 级(L0~L3)**。
> **阅读指南**:
>
> | 你想… | 读 |
> |---|---|
> | 搞懂**哪段代码干什么、三层怎么配合** | **§一 架构总览**(职责速览 / 调用矩阵 / 一次按键的旅程 / 初始化时序 / 层间不变量),再按需深入 §三 / §五 / §六 |
> | 深入某一层 | §三 数据层 / §五 显示层 / §六 逻辑层(各章自包含:职责边界 + 字段 + 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 三层职责速览
```mermaid
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[42] 父子索引表 + 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 按键协作:一次按键的旅程
```mermaid
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()`,三层按此顺序配合:
```mermaid
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 打印)——`@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` 内存监控 |
---
## 二、菜单内容:42 节点层级树
### 2.1 层级总览
主菜单为 4 级树:
| 层级 | 含义 | 例子 |
|---|---|---|
| **L0 根** | 主界面,7 个一级条目 | 主界面 |
| **L1 一级** | 根页的 7 个条目 | 装置信息、实时数据、参数定值、三遥设置、装置维护、通讯参数、记录查询 |
| **L2 二级** | 一级子菜单的子项 | 交流量、整定、遥测死区、时钟设置、通讯设置、SOE记录 … |
| **L3 三级** | 仅"参数定值 → 整定/查看"下的子项 | 参数、定值、控制字、软压板 |
节点分两类(决定导航行为):
| 类型 | 标识 | 行为 |
|---|---|---|
| **菜单节点** | 行尾 **▶** 箭头 | `ENTER` 进入 → 展开下一级子页 |
| **叶子节点** | 无箭头 | `ENTER` 进入 → 有内容页(node 1/3/4,§九)则切内容屏;否则进空白终点框 |
> 根页默认选中**第 1 项"装置信息"**(整行反色:白带、黑字)。
### 2.2 完整层级树
```text
主界面 (L0)
├─ 装置信息 [叶子]
├─ 实时数据 ▶ [菜单]
│ ├─ 交流量 [叶子]
│ ├─ 直流量 [叶子]
│ └─ 遥信量 [叶子]
├─ 参数定值 ▶ [菜单]
│ ├─ 整定 ▶ [菜单]
│ │ ├─ 参数 [叶子]
│ │ ├─ 定值 [叶子]
│ │ ├─ 控制字 [叶子]
│ │ └─ 软压板 [叶子]
│ └─ 查看 ▶ [菜单]
│ ├─ 参数 [叶子]
│ ├─ 定值 [叶子]
│ ├─ 控制字 [叶子]
│ └─ 软压板 [叶子]
├─ 三遥设置 ▶ [菜单]
│ ├─ 遥测死区 [叶子]
│ ├─ 遥测系数 [叶子]
│ ├─ 遥信类型 [叶子]
│ ├─ 遥信防抖 [叶子]
│ └─ 双点遥信 [叶子]
├─ 装置维护 ▶ [菜单]
│ ├─ 时钟设置 [叶子]
│ ├─ 强制复归 [叶子]
│ ├─ 手动录波 [叶子]
│ └─ 清除记录 [叶子]
├─ 通讯参数 ▶ [菜单]
│ ├─ 通讯设置 [叶子]
│ ├─ 网口设置 [叶子]
│ └─ SNTP设置 [叶子]
└─ 记录查询 ▶ [菜单]
├─ SOE记录 [叶子]
├─ 事故记录 [叶子]
├─ 操作记录 [叶子]
├─ 保护告警 [叶子]
├─ 保护启动 [叶子]
├─ 遥控记录 [叶子]
├─ 自检记录 [叶子]
├─ 运行记录 [叶子]
└─ 运行报告 [叶子]
```
**统计**:菜单节点 **9** 个(含根),叶子节点 **33** 个,共 **42** 个节点;最大深度 **4 级**。
### 2.3 节点定义表(逻辑层级定义)
> 扁平表,与 §3.3 生成物 `g_menu_tab[]` 一一对应:`#` = 数组下标,`父节点` = 父菜单的 `#`,`子项数` = 直接子项个数(叶子为 0)。
| # | 名称 | 层级 | 类型 | 父节点 | 子项数 |
|---|---|---|---|---|---|
| 0 | 主界面 | L0 | 菜单(根) | — | 7 |
| 1 | 装置信息 | L1 | 叶子 | 0 | 0 |
| 2 | 实时数据 | L1 | 菜单 | 0 | 3 |
| 3 | 交流量 | L2 | 叶子 | 2 | 0 |
| 4 | 直流量 | L2 | 叶子 | 2 | 0 |
| 5 | 遥信量 | L2 | 叶子 | 2 | 0 |
| 6 | 参数定值 | L1 | 菜单 | 0 | 2 |
| 7 | 整定 | L2 | 菜单 | 6 | 4 |
| 8 | 参数 | L3 | 叶子 | 7 | 0 |
| 9 | 定值 | L3 | 叶子 | 7 | 0 |
| 10 | 控制字 | L3 | 叶子 | 7 | 0 |
| 11 | 软压板 | L3 | 叶子 | 7 | 0 |
| 12 | 查看 | L2 | 菜单 | 6 | 4 |
| 13 | 参数 | L3 | 叶子 | 12 | 0 |
| 14 | 定值 | L3 | 叶子 | 12 | 0 |
| 15 | 控制字 | L3 | 叶子 | 12 | 0 |
| 16 | 软压板 | L3 | 叶子 | 12 | 0 |
| 17 | 三遥设置 | L1 | 菜单 | 0 | 5 |
| 18 | 遥测死区 | L2 | 叶子 | 17 | 0 |
| 19 | 遥测系数 | L2 | 叶子 | 17 | 0 |
| 20 | 遥信类型 | L2 | 叶子 | 17 | 0 |
| 21 | 遥信防抖 | L2 | 叶子 | 17 | 0 |
| 22 | 双点遥信 | L2 | 叶子 | 17 | 0 |
| 23 | 装置维护 | L1 | 菜单 | 0 | 4 |
| 24 | 时钟设置 | L2 | 叶子 | 23 | 0 |
| 25 | 强制复归 | L2 | 叶子 | 23 | 0 |
| 26 | 手动录波 | L2 | 叶子 | 23 | 0 |
| 27 | 清除记录 | L2 | 叶子 | 23 | 0 |
| 28 | 通讯参数 | L1 | 菜单 | 0 | 3 |
| 29 | 通讯设置 | L2 | 叶子 | 28 | 0 |
| 30 | 网口设置 | L2 | 叶子 | 28 | 0 |
| 31 | SNTP设置 | L2 | 叶子 | 28 | 0 |
| 32 | 记录查询 | L1 | 菜单 | 0 | 9 |
| 33 | SOE记录 | L2 | 叶子 | 32 | 0 |
| 34 | 事故记录 | L2 | 叶子 | 32 | 0 |
| 35 | 操作记录 | L2 | 叶子 | 32 | 0 |
| 36 | 保护告警 | L2 | 叶子 | 32 | 0 |
| 37 | 保护启动 | L2 | 叶子 | 32 | 0 |
| 38 | 遥控记录 | L2 | 叶子 | 32 | 0 |
| 39 | 自检记录 | L2 | 叶子 | 32 | 0 |
| 40 | 运行记录 | L2 | 叶子 | 32 | 0 |
| 41 | 运行报告 | L2 | 叶子 | 32 | 0 |
---
## 三、数据层:ui_menu_tab(生成物,只读)
> **一句话职责**:提供"有什么条目"——一张扁平父子索引表(42 节点,`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 数据结构定义
```c
#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 完整 42 节点表(按 §2.3 节点定义表落地)
> 产物 `ui_menu_tab.c`(§3.6 生成):`const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT]`,内容与下表一致。
```c
const ui_menu_node_t g_menu_tab[UI_MENU_TAB_COUNT] = {
/* parent name 下标 */
{ MENU_ROOT, "主界面" }, /* 0 L0 菜单(根) */
{ 0, "装置信息" }, /* 1 L1 叶子 */
{ 0, "实时数据" }, /* 2 L1 菜单 */
{ 2, "交流量" }, /* 3 L2 叶子 */
{ 2, "直流量" }, /* 4 L2 叶子 */
{ 2, "遥信量" }, /* 5 L2 叶子 */
{ 0, "参数定值" }, /* 6 L1 菜单 */
{ 6, "整定" }, /* 7 L2 菜单 */
{ 7, "参数" }, /* 8 L3 叶子 */
{ 7, "定值" }, /* 9 L3 叶子 */
{ 7, "控制字" }, /* 10 L3 叶子 */
{ 7, "软压板" }, /* 11 L3 叶子 */
{ 6, "查看" }, /* 12 L2 菜单 */
{ 12, "参数" }, /* 13 L3 叶子 */
{ 12, "定值" }, /* 14 L3 叶子 */
{ 12, "控制字" }, /* 15 L3 叶子 */
{ 12, "软压板" }, /* 16 L3 叶子 */
{ 0, "三遥设置" }, /* 17 L1 菜单 */
{ 17, "遥测死区" }, /* 18 L2 叶子 */
{ 17, "遥测系数" }, /* 19 L2 叶子 */
{ 17, "遥信类型" }, /* 20 L2 叶子 */
{ 17, "遥信防抖" }, /* 21 L2 叶子 */
{ 17, "双点遥信" }, /* 22 L2 叶子 */
{ 0, "装置维护" }, /* 23 L1 菜单 */
{ 23, "时钟设置" }, /* 24 L2 叶子 */
{ 23, "强制复归" }, /* 25 L2 叶子 */
{ 23, "手动录波" }, /* 26 L2 叶子 */
{ 23, "清除记录" }, /* 27 L2 叶子 */
{ 0, "通讯参数" }, /* 28 L1 菜单 */
{ 28, "通讯设置" }, /* 29 L2 叶子 */
{ 28, "网口设置" }, /* 30 L2 叶子 */
{ 28, "SNTP设置" }, /* 31 L2 叶子 */
{ 0, "记录查询" }, /* 32 L1 菜单 */
{ 32, "SOE记录" }, /* 33 L2 叶子 */
{ 32, "事故记录" }, /* 34 L2 叶子 */
{ 32, "操作记录" }, /* 35 L2 叶子 */
{ 32, "保护告警" }, /* 36 L2 叶子 */
{ 32, "保护启动" }, /* 37 L2 叶子 */
{ 32, "遥控记录" }, /* 38 L2 叶子 */
{ 32, "自检记录" }, /* 39 L2 叶子 */
{ 32, "运行记录" }, /* 40 L2 叶子 */
{ 32, "运行报告" }, /* 41 L2 叶子 */
};
```
> `UI_MENU_TAB_COUNT`(=42)与 `MENU_ROOT` 在 `ui_menu_tab.h` 定义。
### 3.4 查询 API(逻辑层 / 显示层共用)
4 个查询 API 统一模块前缀 `ui_menu_tab_*`(与逻辑层 `menu_*` 函数名区分),参数 `uint8_t`(菜单上限 255 项),随表生成在 `ui_menu_tab.c`:
```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):
```c
/* 显示层 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]` 完整 42 节点表 |
重新生成:
```
python tools/menu_gen/gen_menu.py
```
> 产物 `ui_menu_tab.c/.h` 含 42 节点(9 菜单 + 33 叶子,最深 L3);`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 改菜单流程
```text
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`(生成) | 42 节点扁平父子表 | ≈ 42×8 B ≈ 336 B |
| 名字字面量 | 同上 | 42 个 `const char[]` | ≈ 400 B |
| `UI_MENU_TAB_COUNT` / `MENU_ROOT` | `ui_menu_tab.h` | 节点数 42 / 根哨兵 0xFF(8 位,上限 255 项) | 宏 |
> 逻辑层/显示层**只读**它(§3.4 的 API 都是对它的过滤/查询),从不修改。合计 ≈ **0.7 KB Flash**。
---
## 四、显示规范:横向级联子框模型
> **本章是显示层(§五)要实现的"图纸"**:讲"页"(每级的子项列表)在**屏上怎么摆**——每级一个**框**,贴靠在其**父框右侧**——一棵**横向的树**(往右生长)。
> 以下规则由参考图 02~07(`docs/UI设计/mockups/`)逐像素实测得出(设备 px,误差 ≤3px);参考图为**手工 mockup**,与实现的偏差见 §8.2 注 5。
### 4.1 总览
![横向级联子框总览(主界面 → 参数定值 → 整定)](../images/级联框总览.svg)
- 每级一个**框**,框 = 该级条目列表(紧凑列表,**无逐框页头/返回按钮**,返回靠 `←`);
- **无滑动动画**(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 溢出上移](../images/级联框几何计算.svg)
**① 框宽 w = 最宽行 + 2 条边框**(图①)
一行横向构成:`边框1 | 留白2 | 文本 |(菜单行: 间距2 + ▶10)| 留白2 | 边框1`。文本宽按字体实际步进量(`lv_text_get_width`,⚠️ 见 §8.2 陷阱 1);同框各行宽度可以不同,**框宽只由最宽的一行决定**,窄行左对齐、右侧留空:
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()` 的实现即此五式):
```text
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):
```text
显示"节点 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` 下标):主界面 → 参数定值 → 整定 → 参数,再一级级退回
```text
【根页 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]=参数定值
```
**导航状态 = 一条"当前节点"链**(= 从根到当前框的路径)
```text
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` | 返回:**关最右框**(毁框体+行数据)、焦点回父框、选中态保持(不跳) |
| 焦点 / 框头 | 焦点 = 最右(最深)框;**无逐框页头/返回按钮**(框即紧凑列表,返回靠 `←`);仅根框带主界面装饰 |
**进入/返回示例**(路径,`→`/`←` = 进/退动作方向):
```
主界面 → 参数定值 → 整定 → 参数
L0 L1 L2 L3
返回:主界面 ← 参数定值 ← 整定 ← 参数
```
**分发**(`menu_key_handle(d, key)`,`d` = 焦点所在层):
```text
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 预算内)**:行自绘无对象,池只占**屏上框体**(2~3 个 `lv_obj`)+ 根框装饰/状态行,
典型 ≈ **1~2 KB**,仍远小于 16 KB(池已于 2026-10-15 由 40 KB 缩到 16 KB,见 `docs/内存管理/RAM使用分析与优化方案.md` §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) |
| 编译 | `@build`(ARMCC,0 错 0 警,强制) |
| 真机 | `@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`(当前 122 唯一字)并重生成字体(重跑 `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 对象树与生命周期
```text
菜单屏(持久) 内容页屏幕(按需建毁)
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
```c
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 | 说明 |
|---|---|---|
| 菜单基线(根框建完) | 4784 B | 同 §1.5 |
| 内容页打开峰值 | 5560~5976 B | +776~1192 B:屏对象+容器+事件列表+局部样式+screens 数组扩容(x64 指针 8B,MCU 32 位下更小) |
| 关页后回落 | **4864 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)。
---
## 十、变更记录
| 日期 | 变更 |
|---|---|
| 2026-09-03 | 初始版本:依据用户 7 屏主菜单截图定义完整 4 级逻辑层级(42 节点)+ 导航逻辑 + 与现有 `g_menu_tab` 差异对照 |
| 2026-09-03 | 按范围调整:移除"内容页类型"相关内容,聚焦菜单层级(节点/父子/层级/导航);新增 `子项数` 列;标注"查看"子项为推测待确认 |
| 2026-09-03 | **查看(#12)子项经用户确认**与"整定"同构(参数/定值/控制字/软压板),由推测转为正式定义,移除全部 ⚠ 标记与待确认节 |
| 2026-09-03 | 新增 §六"菜单树数据结构":选型扁平"父子索引表"(邻接表/关系表模型,对比指针树/首子-兄弟/堆式),给出 `ui_menu_node_t` + 完整 42 节点表 + 导航 API(`menu_get_children`/`menu_has_children`/`menu_get_name`)+ 启动自检 + 声明式生成器 + 与现有 `g_menu_tab` 迁移映射 |
| 2026-09-03 | **声明式生成器落地**(§6.6 由"可选"转"已实现"):新增 `ui_menu_tree.txt`(缩进即层级源)+ `tools/menu_gen/gen_menu.py`(同 `make_font.py` 模式),生成 `Drivers/BSP/UI/ui_menu_tab.c/.h`(42 节点:9 菜单 + 33 叶子,最深 L3);已跑通并校验 parent 下标/连通性/子项数全部正确 |
| 2026-09-04 | §四 新增"**导航走查**":用真实 `g_menu_tab` 下标逐步演示"移/进/退"如何由 `parent==当前节点` 一次过滤驱动(当前页=过滤、进=压页/终点、退=弹页、导航态=当前节点链),并修正根页模型为"主界面 node 0 的子项"(§6.2/6.7 同步,去掉"虚拟根页"表述) |
| 2026-09-04 | 新增 §七"导航运行时数据结构(完整清单)":静态层(`g_menu_tab` 只读)+ 运行时层(导航历史 `s_path`+`s_sel`+`s_depth`=13 B、持久容器 `s_cont[4]`、单套活跃行 `s_row*[]`、页头/装饰、样式);"存 vs 算"区分、操作×字段读写表、内存预算、与现有 `s_pages[4]` 收敛对照 |
| 2026-09-04 | 改名 `menu_is_menu` → `menu_has_children`(原名"menu 是不是 menu"含糊;新名=该节点有无子项);§四 路径/代码映射由旧 `s_pages[]` 统一为 §七 的 `s_path[]`/`s_depth`/`s_sel[]`/`s_row*[]` 模型,消除 §四/§七 不一致 |
| 2026-09-04 | 改名 `menu_children` → `menu_get_children`、`menu_name` → `menu_get_name`(裸名词改"动词+对象",API 统一 `has_`/`get_` 风格);§6.4 建页片段改用 `s_path`/`s_depth`/`s_cont` 现算模型(条目不落地) |
| 2026-09-04 | §6.4 补"签名约定"说明:`node`/`out` 取 `uint16_t` 因与 `parent`(须装 `0xFFFF` 根哨兵)同域、**非容量所需**(42 下标 8 位够);`max`=出参缓冲容量(防越界 + 对齐单页行数,今日 9≤16 不触发截断) |
| 2026-09-04 | **索引位宽 16→8 位**:`MENU_ROOT` 由 `0xFFFF` 降为 `0xFF`,`parent`/`node`/`out`/`s_path[]` 均改 `uint8_t`(菜单上限 255 项,远够);路径历史 13→9 B、省 ~20 B RAM,Flash 不变(结构体因指针仍 8 B/项);双护栏=生成器 `节点数>255 报错` + 头文件 C99 断言 `menu_tab_count_check`。生成器已改并重生成 `ui_menu_tab.c/.h`,MSVC /W4 编译 + 导航测试(根 7 / 整定 4 / 查看 4 / 记录查询 9)通过;同步 §四/§6.2/§6.4/§6.5/§七。取代上一条"签名约定(uint16_t)"决定 |
| 2026-09-04 | 修正 §四"进入/返回示例":返回行节点顺序原与进入行相反(参数在左)致 `←` 被读成"前进"方向;改为与进入行**同序**(主界面 L0 在左…参数 L3 在右)+ `←`,并补"怎么读"注(`→`/`←` = 进/退动作方向;返回 = 从最右 参数(L3) 沿 `←` 一级级退回最左 主界面(L0)) |
| 2026-09-04 | **子页面显示模型定稿(横向级联子框)**:§四 新增 ⑧"显示模型"——横向树,每级一框在上一级**右侧**、顶边对齐上级"选中行"行顶;水平**右锚定**(最深框钉右缘、父框超宽滚出左、行按屏上/外建毁)+ 框宽文字自适应 + **无动画**;垂直每框独立(顶边=上级选中行行顶、框高按内容、**仅底边>160 才上移**贴屏底、比整屏高则 `↑`/`↓` 滚);焦点=最右框(`↑`/`↓` 移选/滚、`→`/`ENTER` 进、`←`/`ESC` 关最右框)。§七 同步:行对象按层 `s_row[d][i]` 仅屏上框建、去掉逐框页头(`s_header`/`s_back_btn`/`s_title`)、新增每框滚动 `s_scroll[]`(8 B)、"存 vs 算"增 滚动=存/位置=算、持久态 9→17 B、内存预算(静态 ~0.75→~1.3 KB、LVGL 池=屏上 2~3 框)、对照表更新 |
| 2026-09-04 | **§四⑧ 按参考图 02~07 像素实测校准**(设备 px,误差 ≤3px):层级树/命名/箭头/选中态全部与图一致 ✓;显示模型两处修正——水平由"右锚定(钉屏幕右缘)"改为"**子框左缘=父框右缘**(共享边框列、零间隙)+ 宽自适应"(实测各子框均贴父框右缘 x≈67/106,最深框止于 x≈113~127 而非屏右缘);垂直溢出由"上移使底边贴屏底"改为"**上移使子框底边对齐父选中行行底**"(实测三遥 top=26/装置维护 top=54/通讯参数 top=82/记录查询 top=12 均吻合,且可叠到屏顶状态行区、顶部不钳制)。本菜单最深路径 ≈155<160 不会溢出;>160 行为与"根页框宽 99 vs 68"列为待确认。§7.2/§7.3/§7.4 "右锚定"表述同步 |
| 2026-09-04 | **实现落地,本文档与代码同步**:三层拆分实现(`ui_menu_tab` 生成物 + 新增 `ui_menu_view` 显示层、`ui_menu` 瘦身为纯状态机,§8.1);§四⑦/§6.4/§6.5/§6.6/§6.7 函数与 API 名同步为实际 `ui_menu_tab_*`/`ui_menu_view_*`/`menu_open\|menu_back\|menu_key_handle`;§五"待实现"→"已实现";§七 修正为实际字段(容器从不切 HIDDEN、`s_style_frame`、新增框几何缓存 `s_frame[]`,§7.2 G);§四⑧ 实测值区分"参考图/实现"两套(实现:根框 66、子框 42/46/54/6,顶 28/58/88/13,偏差入 §8.8 注 5),>160 溢出"待确认"落定为实现行为"屏缘裁切";新增 **§八 实现架构与关键实施细节**(布局常量/几何公式/对象模型/键路径/初始化时序/分级验证/6 条陷阱与保真度说明);验证:模拟器 7 状态对照参考图 01~07 + `@build` 0 错 0 警 |
| 2026-09-07 | **全文结构重排(理清三层架构与协作)**:旧版三层描述分散在 §六(数据结构)/§七(运行时数据)/§八(实现架构) 三处、§四 又混装导航逻辑与显示模型,读者难以看清各层职责与层间配合;重排为十章——**一 架构总览**(1.1 为什么拆三层 / 1.2 职责速览+依赖图 / 1.3 调用矩阵 / 1.4 一次按键的旅程 / 1.5 初始化时序 / 1.6 层间不变量)→ **二 菜单内容** → **三 数据层**(含 3.7 改菜单流程)→ **四 显示规范**(自 §四⑧ 抽出,含 4.5 参考图偏差)→ **五 显示层**(5.1 职责边界 / 5.5 公开 API 8 接口表)→ **六 逻辑层**(6.2 17 B 状态 / 6.3 导航规则含完整走查 / 6.4 键语义与分发)→ **七 层间配合细节**(7.1 逐键分层动作 / 7.2 框生命周期 / 7.3 存vs算 / 7.4 读写矩阵 / 7.5 内存预算带层归属)→ **八 验证与陷阱** → **九 迁移历史**(结构/运行时/内容三表收拢)→ **十 变更记录**;**修正归属错误**:`s_scroll[]` 实为**逻辑层** `ui_menu.c` 字段(17 B 导航状态之一,见 ui_menu.c),旧 §7.2 误划给显示层,新文 §6.2/§7.5 已归正;全部交叉引用同步到新章号;技术内容零删减 |
| 2026-09-07 | §1.2/§1.4/§1.5 三幅架构图由 ASCII 转 Mermaid(1 张 flowchart + 2 张 sequenceDiagram,初始化时序含表自检 alt/else 分支);键源措辞统一为“远端键”(与 `UI_设计架构.md`“远端客户端”一致:MCU 端按键经 TCP 7003 远端客户端 → ui_net_sink → keypad);职责表/调用矩阵/“两个要点”文字保留 |
| 2026-09-07 | 正文现状化:删除正文过渡性历史叙述(“设计阶段拟名…/实现采用…/见变更记录”等,旧文见 git 历史),各节改写为现状描述;移除 §九“迁移历史”整章(旧→新对照保留于本变更记录),变更记录重编号为 §九;§8 去日期注记,陷阱 3/4 改写为现状约束 |
| 2026-09-07 | §4.1 总览图由 ASCII 框线图升级为 SVG 精确几何图(新增 `docs/images/级联框总览.svg`,按 §5.2 常量 / §5.3 公式逐 px 绘制,含根框装饰 / 状态行 / 选中行反色 / 共享边框列标注 + ①~⑦ 位置标注);ASCII 版删除(旧图见 git 历史) |
| 2026-09-07 | 删除 §4.5“参考图与实现偏差”表(偏差数据已在 §8.2 注 5“参考图保真度”完整记载);正文两处引用(§四 引言、§8.1 验证表)改指 §8.2 注 5 |
| 2026-09-07 | §5.1 职责边界表去掉“不做”列、改为列表(负向约束已由章首“绝不做”行与 §1.6 层间不变量表承载,无信息损失) |
| 2026-09-07 | §6.1 职责边界表同样去掉“不做”列、改为列表(负向约束由章首“绝不做”行、§1.3 调用矩阵与 §1.6 层间不变量表承载,无信息损失) |
| 2026-09-08 | §5.3 几何计算重写为图文并茂:新增 `docs/images/级联框几何计算.svg` 四格图(① 行剖面定框宽 ② X 共享边框列 ③ Y 顶对齐 ④ Y 溢出上移,含虚线幽灵框 + 超屏红区示意),正文改为四步叙述 + 实测框宽表 + 公式汇总(五式内容不变) |
| 2026-09-08 | `menu_frame_t` 瘦身:`built` bool→`uint8_t`(与系统存储类型一致);`x/y/w/h` `int32_t`→`int16_t`(值域 0..160 / ≤242,16 位够;y 需有符号——钳制前可为负);`s_frame[4]` 96 B→48 B,省 48 B RAM(§5.4 对象模型表 / §7.5 内存预算表同步) |
| 2026-09-08 | 行 label 指针合并:删 `s_row_text` / `s_row_arrow` 两数组(省 512 B);文本/箭头颜色改在行容器 `s_row` 上设置、子 label 继承(LVGL `text_color` 为可继承属性,`lv_style.c` 属性标志表证实);§5.4 对象模型表 / §7.5 内存预算表同步(768 B→256 B) |
| 2026-09-08 | **行对象 → 自绘重写(方案1,省 LVGL 池"每行对象机制税")**:行不再是 LVGL 对象——行数据改 `s_row_node[4][16]`(`uint8_t` 节点下标,256 B→64 B),框体 `LV_EVENT_DRAW_MAIN` 自绘(选中行白带 `lv_draw_rect` + 每行 `lv_draw_label` 文本/箭头,按滚动偏移枚举可见行、重绘区外行 culling);框内滚动改 `LV_EVENT_GET_SELF_SIZE` 虚报内容高 + 手动跟随滚动(`view_scroll_to_sel`,替代 `lv_obj_scroll_to_view`);删 `s_style_selected`、`view_add_row` 与 flex 堆行;`frame_build/destroy/refresh` 改行数据注册/清空/重绘。§5.4/§5.6/§6.2/§7.1~7.5 同步。验证:`@build` 0 错 0 警 + 代码规范 PASS + 模拟器 8 状态像素回归(白带/边框/根框不变性 + 3 组往返态逐像素一致)全 PASS |
| 2026-09-08 | **4 层容器→单一持久容器 + 根框装饰/状态行自绘(RAM 收敛)**:焦点/键唯一入口 = 持久容器(框深度由逻辑层 `s_depth` 决定,键回调去掉 `user_data=depth`,`container`/`focus` 接口去参);根框装饰(浅 V)与状态行改容器 `DRAW_MAIN` 自绘(3 段 `lv_draw_line` + 1 行 `lv_draw_label`,删 3 `lv_line` + 1 label + 3 容器对象);静态显示层 ~0.5→~0.4 KB。验证:`@build` 0 错 0 警 + 代码规范 PASS + 模拟器 8 状态像素回归全 PASS(装饰/状态行像素级一致);LVGL 池实测 根框 7168→4872 B、最深四级级联 8488→6128 B(省 ~2.3 KB) |
| 2026-09-09 | **新增 §九"内容页(ui_content_page)"**:装置信息/交流量/直流量三叶子页落地(独立屏按需建毁 + 行自绘,几何实测自参考图 10/20/21);节点→页映射入逻辑层 `menu_content_page_for`(node 1/3/4),§2.1 叶子行为同步;flat 样式改常量样式(`LV_STYLE_CONST_INIT` 零池分配,动态常驻样式 x64 实测 88 B/个永久占用);关页后一次性残留 +80 B 分解入 §9.5(screens 数组高水位 +8、`lv_screen_load` 菜单屏局部 X/Y 样式 +72,均 LVGL 核心行为非泄漏)。验证:`@build` 0 错 0 警 + 代码规范 PASS + 模拟器三页对照参考图几何一致 + 菜单 8 状态像素回归全 PASS |
| 2026-09-09 | 用户密钥密码页落地(首个表单/操作页,§九 / `内容页架构.md` §4.1):框架描述符新增 title_y/hide_deco/hide_footer/key_intercept + 新 API `ui_content_page_invalidate`;受保护叶子路由(arm→开密码页,确认一致后回调**先关页再续做进入动作**);§9.2/§9.3/§9.4 同步。验证:回归扩至 7 页 + 往返水位全 PASS、五门禁全绿 |
| 2026-09-09 | 菜单显示层 flat 样式同法转常量样式(§5.6/§7.5/§9.5):LVGL 池基线 4872→4784 B、最深四级级联 6128→6040 B、内容页峰值 5648~6064→5560~5976 B(x64 省 88 B);`LV_STYLE_CONST_INIT` 生成外部链接符号,内容页同名者改名 `cp_style_flat`。验证:`@build` 0 错 0 警 + 代码规范 PASS + 菜单 8 状态像素回归全 PASS + 三页几何不变 |
| 2026-09-09 | 权限模型(§3.7/§3.8/§9.4 同步):菜单表行尾注记 ` #P`(需密码);`g_menu_tab` pwd 列改 0/1 布尔(API `ui_menu_tab_has_pwd`,表项仍 8 B/项);两个编译期密码(查看 000 / 修改 123)+ `ui_perm_t` 权限枚举在 `ui_pwd_def.h`(用户定案:密码仅编译期写入,不落 Flash、无运行期设置页)——密码页输查看密码授只读权限、输修改密码授修改权限(只有修改权限进入后才能改参数);逻辑层密码检查提前到内容页判断之前(带内容页的需密码叶子同样先经密码页,修复路由缺口);无密码叶子直接进、默认只读。验证:五门禁全绿(菜单 8 态 + 内容页 7 页像素一致、池 4784/4864 B 不变、Keil 0 错 0 警、代码规范 PASS) |