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

482 lines
37 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.
# 内容页架构
> **用途**:说明内容页体系在代码中**实际如何工作**(框架机制 / 页面插件 / 数据流 / 键与绘制行为 / 内存),
> 并给出新增页面的标准步骤。全文与当前代码一一对应;页面像素几何(行槽/选中带/页脚实测值)
> 见 `菜单页面设计.md` §九。
> **配套文档**:`UI_设计架构.md`(§1.2 文件地图 / §4.7 内容页 / §5.6 常量样式)、`菜单页面设计.md`(§九 内容页)、
> `UI_界面制作流程.md`(参考图→代码管线)、`UI_自动化测试流程.md`(回归门禁与黄金参考)。
---
## 一、什么是内容页
菜单**叶子节点** ENTER 后进入的**独立全屏页面**:新建一块 LVGL 屏幕,全部内容(装饰 + 标题 + 行 + 页脚)
由框架一次性自绘(**无子对象**),ESC 返回菜单屏。当前已接 5 页:
| 页面 | 菜单节点 | 形态 | 数据 | 描述符对象 |
|---|---|---|---|---|
| 装置信息 | node 1 | 静态信息页(6 行 + 副标题,不分页) | Flash 表 | `ui_page_device_info_desc` |
| 交流量 | node 3 | 列表页(24 项,8 项/页,共 3 页) | Flash 表(第 1 页实测,2/3 页占位) | `ui_page_ac_desc` |
| 直流量 | node 4 | 列表页(3 项,单页) | Flash 表 | `ui_page_dc_desc` |
| 网口设置 | node 30(通讯参数子项) | 列表页(5 行 1 页,动态值)+ 可编辑(`edit_capable`,↑↓调字节/←→移光标/ENTER 确认;保存行 ENTER → 投递保存请求,属主任务 defaultTask 写盘 + 立即应用(免重启),行值随交接状态 ENTER/WAIT/OK/ERR) | `net_cfg` RAM 副本(defaultTask 上电自 Flash 加载;netTask 回写实际 MAC 影子) | `ui_page_net_cfg_desc` |
| 用户密钥 | 需密码叶子(如 node 8 参数) | 表单/操作页(draw_cb + key_cb;无行、无"浅 V"、无页脚) | arm 装载:完成回调(携授予权限);页面比对两个编译期密码 | `ui_page_password_desc` |
设计核心是 **"框架 + 页面插件"**:框架 `ui_content_page` 只持**机制**(屏/容器生命周期、焦点、
按键分发、通用绘制),每页的**数据/几何/钩子**由独立 `ui_page_xxx` 模块经描述符 `content_page_def_t`
提供;两者只以描述符相遇——框架不持任何页面数据,页面模块不碰任何 LVGL 对象。
### 1.1 系统总览
```mermaid
flowchart TD
K(["按键 / LVGL 刷新<br/>(均在 uiTask 上下文,10ms 周期)"]) --> M["逻辑层 ui_menu.c<br/>导航状态机(17 B)+ 键语义<br/>menu_content_page_for:叶子节点 → 页 ID"]
subgraph FW["框架 ui_content_page.c/h —— 只有机制,零页面数据"]
S[("描述符表 s_defs[]<br/>页 ID → const content_page_def_t *<br/>(几何 + 数据源 + 4 钩子)")]
L1["屏/容器生命周期<br/>按需建毁 · 焦点 · 切屏"]
L2["按键分发<br/>内置 ↑↓/ESC + key_cb 钩子"]
L3["通用绘制(LV_EVENT_DRAW_MAIN)<br/>浅 V → 标题/副标题 → 行 → 选中带 → 页脚 → draw_cb"]
end
M -- "ENTER:ui_content_page_open(id, perm)" --> S
S -- "描述符驱动" --> L1
S -- "描述符驱动" --> L2
S -- "描述符驱动" --> L3
L2 -- "ESC/←:close()" --> L1
subgraph PG["页面插件 —— 数据(Flash)+ 几何 + 钩子实现,零 LVGL 对象"]
P1["ui_page_device_info<br/>装置信息(静态信息页)"]
P2["ui_page_ac<br/>交流量(列表页)"]
P3["ui_page_dc<br/>直流量(列表页)"]
P4["ui_page_password<br/>用户密钥(表单/操作页)"]
PN["ui_page_xxx<br/>扩展点:每新增页一文件"]
end
P1 -- "const 描述符(Flash)" --> S
P2 -- "const 描述符(Flash)" --> S
P3 -- "const 描述符(Flash)" --> S
P4 -- "const 描述符(Flash)" --> S
PN -- "const 描述符(Flash)" --> S
L3 -- "ui_deco_draw" --> D["ui_deco(共享)<br/>顶部“浅 V”装饰"]
L3 -- "动态行:refresh / count_cb" --> PG
L2 -- "页面自有键:key_cb" --> PG
```
读图要点:
- **数据只向下一层流**:按键 → 逻辑层 → 框架;页面插件不主动调任何人,只被动提供描述符与钩子
- **框架 ↔ 页面只以描述符相遇**:框架不持页面数据,页面不碰 LVGL 对象
- 反向调用(`refresh/count_cb/draw_cb/key_cb`)是**框架回调页面**,方向与依赖方向相反
- 菜单根框与内容页共用同一"浅 V"装饰(`ui_deco` 模块,几何唯一定义处)
## 二、系统组成
| 文件 | 角色 | 一句话职责 |
|---|---|---|
| `ui_content_page.c/h` | **框架** | 屏/容器生命周期 + 按键分发 + 描述符驱动通用绘制 + 描述符表;公开 `content_page_id_t` / `content_page_def_t` / `cp_row_t` 与 4 个 API |
| `ui_page_device_info.c/h` | 页面插件 | 装置信息页:6 行 Flash 表 + 描述符对象 `ui_page_device_info_desc` |
| `ui_page_ac.c/h` | 页面插件 | 交流量页:24 行 Flash 表 + 描述符对象 `ui_page_ac_desc` |
| `ui_page_dc.c/h` | 页面插件 | 直流量页:3 行 Flash 表 + 描述符对象 `ui_page_dc_desc` |
| `ui_page_password.c/h` | 页面插件 | 用户密钥页:3 数字框自绘(draw_cb)+ ↑↓/←→/ENTER 键处理(key_cb)+ arm API + 描述符对象 `ui_page_password_desc` |
| `ui_page_net_cfg.c/h` | 页面插件 | 网口设置页:5 行(IP/掩码/网关/MAC + 保存)动态值 + 可编辑(draw_cb 反显活动字节 + key_cb 调字节/移光标/保存)+ `init_cb` 重置运行态 + 描述符对象 `ui_page_net_cfg_desc`(数据源 `net_cfg`) |
| `ui_deco.c/h` | 共享 | 顶部"浅 V"装饰(3 段线 + culling),菜单根框/内容页共用 |
| `ui_menu.c` | 逻辑层 | `menu_content_page_for` 叶子节点 → 页 ID 映射;ENTER 普通叶子调 `ui_content_page_open`;受保护叶子先 arm + 开密码页,确认一致后回调**先关页再续做进入动作**(`menu_password_ok`) |
| `ui_menu_view.c` | 逻辑层依赖 | `ui_content_page_close` 经 `ui_menu_view_container()` 把焦点切回菜单持久容器 |
依赖方向:`ui_menu(逻辑)→ ui_content_page(框架)→ ui_page_xxx(页面数据)`;
页面模块只依赖框架头文件(取 `content_page_def_t` / `cp_row_t` 类型),**不反向调用任何人**。
## 三、框架机制(ui_content_page.c)
### 3.1 公开 API
| API | 作用 | 关键行为(代码实际) |
|---|---|---|
| `bool ui_content_page_open(id)` | 开页 | ① 查 `s_defs[id]`:NULL → 打 `ERROR` 日志并返回 false(枚举/表漂移防护)② `lv_obj_create(NULL)` 建屏 + 全屏容器(flat 常量样式 + `IGNORE_LAYOUT` + `CLICK_FOCUSABLE`)③ 容器**先**入默认输入组并获焦 ⓸ `lv_screen_load` 切屏 |
| `void ui_content_page_close()` | 关页 | ① 焦点**先**回菜单持久容器(防输入组焦点悬空)② 切回菜单屏(菜单屏对象未动,原样还原)③ 再删容器+屏,池内存回落 |
| `void ui_content_page_invalidate()` | 整页重绘 | 失效页面容器;供表单/操作页自身状态变化后调用(无页打开时空操作) |
| `bool ui_content_page_is_open()` | 查询 | `s_open != CONTENT_PAGE_COUNT` |
框架持有的运行状态(全部 4 个变量,约 20 B):
```c
static content_page_id_t s_open; /* 当前打开的页(CONTENT_PAGE_COUNT = 未打开) */
static uint16_t s_sel; /* 列表页选中行(全局下标,跨页连续;每次进入清零) */
static lv_obj_t *s_scr; /* 页面屏幕(进入时建、返回时毁) */
static lv_obj_t *s_cont; /* 页面全屏容器(焦点/键入口 + 自绘载体) */
```
### 3.2 页面生命周期:一次 ENTER → 一次 ESC
```mermaid
sequenceDiagram
participant U as uiTask(按键 / 10ms 刷新)
participant M as 逻辑层 ui_menu.c
participant F as 框架 ui_content_page
participant P as 页面模块 ui_page_xxx
participant D as ui_deco(共享装饰)
Note over M,F: 菜单屏:焦点 = 菜单持久容器,按键归逻辑层
U->>M: ENTER(LV_EVENT_KEY → menu_key_cb)
M->>M: menu_content_page_for(node):叶子节点 → 页 ID
M->>F: ui_content_page_open(id)
F->>F: s_defs[id] 查描述符(NULL → 返回 false + 错误日志)
F->>F: 建屏 + 全屏容器(flat 常量样式)→ 入输入组获焦 → 切屏
Note over F: 内容页:焦点 = 页面容器,按键归框架键分发
F->>D: DRAW_MAIN:ui_deco_draw(浅 V)
F->>F: 标题/副标题(居中)
F->>P: refresh(row, name, value)(动态页逐行取数;同帧同步光栅化,栈缓冲安全)
F->>P: count_cb(动态行数)
F->>F: 行 + 选中白带 + 页脚页码
F->>P: draw_cb(表单/操作页:自定义内容)
U->>F: ESC/←(LV_EVENT_KEY → 框架键分发)
F->>F: close():焦点先回菜单持久容器 → 切回菜单屏 → 再销毁页面屏
Note over M: 菜单导航状态(s_depth/s_sel/s_scroll)原样保留,返回即还原
```
要点:
- **导航状态不压层**:进内容页不动菜单 `s_depth/s_sel/s_scroll`,ESC 返回即原样还原
- 菜单屏对象**保留不销毁**(`lv_screen_load` 来回切,屏上对象原样还原)
- 页面按需建毁:任一时刻至多一个"屏 + 容器"存在
- 焦点/键入口在切屏**之前**接线(先入组获焦,后 `lv_screen_load`),按键立即由框架接管
### 3.3 描述符表与页面注册
```c
/* ui_content_page.c(实际代码) */
static const content_page_def_t *const s_defs[CONTENT_PAGE_COUNT] = {
[CONTENT_PAGE_DEVICE_INFO] = &ui_page_device_info_desc,
[CONTENT_PAGE_AC] = &ui_page_ac_desc,
[CONTENT_PAGE_DC] = &ui_page_dc_desc,
[CONTENT_PAGE_PASSWORD] = &ui_page_password_desc,
[CONTENT_PAGE_NET_CFG] = &ui_page_net_cfg_desc,
};
```
- 每页一行:`页 ID → &描述符对象`;绘制/按键逻辑一律经 `s_defs[s_open]` 取当前页描述符
- **描述符是 extern const 对象**(头文件声明、页面模块 `.c` 定义),框架表直接存其**地址**
> ⚠️ 为什么不用"访问器函数"建表:ARMCC v5 禁止用函数名初始化对象指针数组(error #144,
> 拆分实施时实测命中)。extern const 对象模式与菜单数据层 `g_menu_tab[]` 一致,且天然落 Flash。
- **NULL 防护**:`open()` 时查表为 NULL(枚举加了值但表漏加行)→ 拒开 + `ERROR` 日志,不崩溃
### 3.4 按键分发(cp_key_cb)
```mermaid
flowchart TD
A(["按键(焦点容器 → LV_EVENT_KEY → cp_key_cb)"]) --> B{"ESC ?"}
B -- 是 --> C["ui_content_page_close()<br/>关页返回菜单(框架保证,恒不可拦截)"]
B -- 否 --> L{"← ?"}
L -- "key_intercept=1 且<br/>key_cb(←) 返回 true" --> E["页面已处理(表单/操作页自有键)<br/>框架跳过内置语义"]
L -- "未置标志 / 钩子未处理" --> C
L -- 否 --> D{"key_cb 钩子非空<br/>且返回 true ?"}
D -- 是 --> E
D -- 否 --> F{"↑ / ↓ ?"}
F -- 是 --> G{"selectable=1 且<br/>总行数 > 0 ?"}
G -- 是 --> H["移选(首尾环绕,跨页连续)<br/>选中行越界 → 重置首行(防动态行数缩小)<br/>lv_obj_invalidate 触发重绘"]
G -- 否 --> I["不响应(信息页 / 空表)"]
F -- 否 --> J["其余键值:忽略"]
```
行为细节(与代码一致):
- **ESC 恒返回**:最先判断,`key_cb` 无法拦截(框架保证页面可退出)
- **← 每页可拦截**:`key_intercept=1` 时 ← **先**交 `key_cb`(true = 页已处理不关页,如密码页光标移动);未置标志或钩子未处理则照旧关页
- **key_cb 先于内置 ↑↓**:返回 `true` = 页已处理,框架不再执行内置移选
- **↑↓ 移选**:`(sel + total ∓ 1) % total` 首尾环绕;`s_sel` 是全局下标,跨页连续
- **越界防护**:`s_sel >= total` 时先重置首行(为动态行数可能缩小而设;静态页恒不触发)
- 移选只改 `s_sel` + `lv_obj_invalidate(s_cont)` 触发重绘,**不重建任何对象**
### 3.5 通用绘制链(cp_draw_cb,LV_EVENT_DRAW_MAIN)
```mermaid
flowchart TD
A(["LV_EVENT_DRAW_MAIN → cp_draw_cb"]) --> B["① 浅 V 装饰:ui_deco_draw(共享模块,内置 culling;hide_deco=1 跳过)<br/>② 标题(居中,每页 title_y / 默认 CP_TITLE_Y=y2) ⓢ 副标题(居中,y=subtitle_y;仅非空页)"]
B --> C["④ 行区块<br/>列表页:窗口 = 选中行所在页(首可见行 = (sel/每页数)×每页数)<br/>信息页:全量;行槽位置固定,数据随页切换"]
C --> C1{"行数据来源"}
C1 -- "静态:rows 表非空" --> C2["直绘 Flash 表(text_static=1,不拷内存)"]
C1 -- "动态:refresh 非空" --> C3["refresh() 逐行取入栈缓冲(32 B×2,同帧同步光栅化)"]
C2 --> D
C3 --> D["逐行:culling 判交 → 选中行白带(x=2..156,高 band_h)<br/>→ 左/右两列 lv_draw_label(选中黑字,其余白字)"]
D --> E["⑤ 页脚“第 X 页,共 Y 页”(y=143,x=6;hide_footer=1 跳过)<br/>列表页:随选中行现算;信息页:fixed_cur/total 固定值"]
E --> F{"draw_cb 非空 ?"}
F -- 是 --> G["⑥ 页面自定义内容(表单/操作页,在内置区块之后)"]
F -- 否 --> H(["绘制完成"])
G --> H
```
行为细节(与代码一致):
- **行窗口**:`first = (s_sel / rows_per_page) * rows_per_page`,`cnt` 截断到 `rows_per_page`
(末页不足整页);信息页(`selectable=0`)全量绘制
- **culling**:每个区块(标题/副标题/行条带/页脚)先判"重绘区 ∩ 该区块 ≠ ∅"(`cp_areas_intersect`,
与 LVGL `lv_area_intersect` 等价的本地实现,避免依赖私有头),不交则整块跳过
- **选中带**:`band_h > 0` 且该行选中 → 整行白带 `x=2..156`(参考图实测),文字随之黑字
- **文本**:静态行 `text_static=1`(Flash 字符串不拷贝);动态行与页脚页码用栈缓冲
(`lv_draw_label` 在同一刷新周期内**同步**光栅化,指针仅需调用期间有效)
- **页脚**:`lv_snprintf` 现算(MSVC 对 `snprintf` 中文格式串误报 C4819,故用 LVGL 自带 `lv_snprintf`)
> ⚠️ **`lv_draw_label` 折行陷阱**:`lv_draw_label` 的换行宽度取 **`coords` 宽度**(未置 `LV_TEXT_FLAG_EXPAND` 时 `w = lv_area_get_width(coords)`)。在**窄区域**(光标盒 / 小方框)里传入**长于该区域的文本**时,LVGL 会按该宽度**折行并逐行向下绘制**,溢出到下方其它行与空白区(表现为"下方弹出一串字符")。规则:窄区域只传"恰好该区域宽度的文本"(如网口设置光标段只传 3 个字符),长文本请在整行宽条带内绘制。
- 容器样式为**常量样式**(`LV_STYLE_CONST_INIT`,零 LVGL 池分配),见 `UI_设计架构.md` §5.6
## 四、页面描述符(content_page_def_t)
```c
/* ui_content_page.h(实际代码) */
typedef struct {
const char *title; /* 标题(浅 V 区居中) */
const char *subtitle; /* 副标题;NULL = 无(仅信息页有) */
int32_t subtitle_y; /* 副标题放置 y(仅 subtitle 非空时有效) */
int32_t title_y; /* 标题放置 y;0 = 框架默认(CP_TITLE_Y) */
uint8_t hide_deco; /* 1 = 不绘"浅 V"装饰(密码页跟随实机) */
uint8_t hide_footer; /* 1 = 不绘页脚页码(密码页跟随实机:无页脚) */
uint8_t selectable; /* 1 = 列表页(↑↓移选+分页);0 = 信息页(静态行) */
uint8_t key_intercept; /* 1 = LV_KEY_LEFT 先交 key_cb(true = 页已处理不关页);ESC 恒不可拦截 */
int32_t top_y; /* 首行槽顶 y */
int32_t pitch; /* 行距(相邻行槽顶间距) */
int32_t band_h; /* 选中白带高(0 = 无选中带) */
int32_t text_dy; /* 文本放置相对行槽顶的垂直偏移 */
int32_t left_x; /* 左列 x(信息页=标签、列表页=名) */
int32_t right_x; /* 右列 x(值) */
uint8_t rows_per_page; /* 每页可见行数(0 = 不分页) */
uint16_t row_count; /* 总行数(静态页;动态页用 count_cb) */
const cp_row_t *rows; /* 静态数据表(Flash);NULL = 动态页 */
uint8_t fixed_cur_page; /* 信息页页脚"第 X 页"固定显示值 */
uint8_t fixed_total_pages; /* 信息页页脚"共 Y 页"固定显示值 */
/* —— 扩展钩子(全可选;NULL = 纯内置行为;均运行在 uiTask 上下文)—— */
void (*refresh)(uint16_t row, char *name, char *value); /* 动态行:逐行取数(名/值) */
uint16_t (*count_cb)(void); /* 动态行数(记录页) */
void (*draw_cb)(lv_layer_t *layer, const lv_area_t *clip); /* 表单/操作页:自定义内容 */
bool (*key_cb)(lv_key_t key); /* 页面自有键;true = 页已处理 */
} content_page_def_t;
```
字段一览(含当前 4 页实际值):
| 字段 | 类型 | 作用 | 装置信息 | 交流量 | 直流量 | 用户密钥 |
|---|---|---|---|---|---|---|
| `title` | `const char *` | 标题(浅 V 区居中) | "装置信息" | "交流量" | "直流量" | "用户密钥" |
| `subtitle` | `const char *` | 副标题;NULL = 无 | "馈线自动化终端" | NULL | NULL | NULL |
| `subtitle_y` | `int32_t` | 副标题放置 y | 25 | — | — | — |
| `title_y` | `int32_t` | 标题放置 y(0 = 框架默认) | 0 | 0 | 0 | 44 |
| `hide_deco` / `hide_footer` | `uint8_t` | "浅 V" / 页脚开关 | 0 / 0 | 0 / 0 | 0 / 0 | 1 / 1 |
| `selectable` | `uint8_t` | 1 = 列表页(↑↓+分页);0 = 信息页 | 0 | 1 | 1 | 0 |
| `key_intercept` | `uint8_t` | ← 拦截标志(先交 key_cb) | 0 | 0 | 0 | 1 |
| `top_y` | `int32_t` | 首行槽顶 y | 40 | 24 | 24 | —(无行) |
| `pitch` | `int32_t` | 行距 | 15 | 14 | 14 | —(无行) |
| `band_h` | `int32_t` | 选中白带高(0 = 无) | 0 | 13 | 13 | —(无行) |
| `text_dy` | `int32_t` | 文本垂直偏移 | 0 | 0 | 0 | —(无行) |
| `left_x` / `right_x` | `int32_t` | 左列(标签/名)/ 右列(值)x | 6 / 86 | 7 / 101 | 7 / 101 | —(无行) |
| `rows_per_page` | `uint8_t` | 每页行数(0 = 不分页) | 0 | 8 | 8 | 0 |
| `row_count` | `uint16_t` | 总行数(静态页) | 6 | 24 | 3 | 0 |
| `rows` | `const cp_row_t *` | 静态数据表(Flash) | 6 行 | 24 行 | 3 行 | NULL |
| `fixed_cur_page` / `fixed_total_pages` | `uint8_t` | 信息页页脚固定值 | 10 / 1 | — | — | — |
| `refresh` / `count_cb` / `draw_cb` / `key_cb` | 函数指针 | 扩展钩子 | 全 NULL | 全 NULL | 全 NULL | draw_cb + key_cb(余 NULL) |
扩展钩子(机制已实现;密码页用 `draw_cb` + `key_cb`,其余 3 页未用):
| 钩子 | 签名 | 框架调用时机(uiTask 上下文) | 用途 |
|---|---|---|---|
| `refresh` | `void (uint16_t row, char *name, char *value)` | 绘制行区块时,每行一次、`lv_draw_label` 之前 | 动态行:逐行取名/值(遥信量、记录页等) |
| `count_cb` | `uint16_t (void)` | 绘制(行窗口/页脚)与按键(移选取模)经 `cp_row_total()` 取行数 | 动态行数(记录查询类) |
| `draw_cb` | `void (lv_layer_t *layer, const lv_area_t *clip)` | 绘制链末尾(内置区块之后) | 表单/操作页自定义内容 |
| `key_cb` | `bool (lv_key_t key)` | 键分发时(ESC 之后、内置 ↑↓ 之前) | 页面自有键(ENTER 确认等);返回 true = 页已处理 |
### 4.1 表单/操作页首个应用:密码页(已落地)
实机(淮源电力 F30)已有"用户密钥"页:进入某些子叶面前须先输入密码(实机照片见
`mockups/密码页面实际机器的示例图片.jpg`)。它是本项目**第一个表单/操作页**,落地形态即
`draw_cb` + `key_cb` 组合。
**功能规则(已确认)**
- 密码共 **3 位**;子页(叶子)可配置"是否需要密码页"(行尾 ` #P`);密码页按所输密码**授权**:
查看密码 → 只读权限 / 修改密码 → 修改权限(只有修改权限进入后才能改参数);
密码值为编译期配置(`ui_pwd_def.h`,查看/修改各一个,改值 = 重新编译)
- 需密码叶子 ENTER:先开密码页;一致后按授予权限进入目标页(页面据此只读/可编辑);ESC 随时返回菜单
- 无密码叶子直接进,默认只读权限
- 键语义(全部由页面 `key_cb` 处理;`selectable=0`,框架内置 ↑↓ 移选不适用):
| 键 | 行为 |
|---|---|
| ↑ / ↓ | 调整光标位数字值(0–9,环绕:9↔0) |
| ← / → | 移动光标(反显框)左/右(环绕:首框↔末框) |
| ENTER | 确认:修改密码 → 关密码页并以修改权限打开目标页;查看密码 → 以只读权限打开;不一致 → 3 框清空回 `0`、光标回首框,重输(已确认) |
| ESC | 返回菜单(框架保证,不可拦截) |
**实机视觉(照片;✅ 已确认按此实现)**
- 标题"用户密钥"居中、位于屏中上部(约屏高 1/4 处);**无**"浅 V"装饰、**无**页脚
- 3 个方框单行居中(约屏高 1/2 处),均**预填 `0`**:首框**反显(黑底白字)= 光标位**,
其余 2 框白底黑字、细边框
**框架映射(描述符配置)**
| 字段 | 值 |
|---|---|
| `selectable` | 0(无内置移选) |
| `rows` / `row_count` | NULL / 0 |
| `draw_cb` | 非空:绘制标题 + 3 个数字框(光标框反显) |
| `key_cb` | 非空:↑↓ / ←→ / ENTER 按上表 |
| `title` | "用户密钥"(y≈45 中上、"浅 V"关 —— 跟随实机;需下方 ⚠️ 扩展 2) |
**✅ 框架扩展(已落地,见 §3.4/§3.5)**
1. **← 键拦截**:描述符 `key_intercept`——置位时 `LV_KEY_LEFT` **先**交 `key_cb`(true = 页已处理不关页),
未置标志或钩子未处理则照旧关页;`LV_KEY_ESC` **不受**拦截("页面恒可退出"的框架保证不放松)。
其余 3 页不置标志,行为完全不变
2. **每页标题 y / "浅 V" / 页脚开关**:描述符 `title_y`(0 = 默认 CP_TITLE_Y)/ `hide_deco` / `hide_footer`;
密码页取 title_y=44、双开关置位,跟随实机照片
**设计确认进度**
| # | 事项 | 状态 |
|---|---|---|
| 1 | 密码不一致行为 | ✅ 已确认:清空重输(3 框回 `0`、光标回首框) |
| 2 | 密码值的存放与设置 | ✅ 已确认(2026-09-09 修订):仅编译期配置(`ui_pwd_def.h`,查看/修改各一个值,按所输密码授权),不做运行期设置页、不落 Flash(用户定案:密码只编译期写入) |
| 3 | 视觉风格 | ✅ 已确认:跟随实机照片(无"浅 V"、标题中上)→ 需 ⚠️ 扩展 2 |
| 4 | ← 键冲突解法 | ✅ 已确认:方案 A 描述符拦截开关(`key_intercept`);`LV_KEY_LEFT` 先交页面钩子,ESC 恒不可拦截,现有 3 页不置开关、零影响 |
| 5 | 边界环绕行为 | ✅ 已确认:都环绕(数字 9↔0,光标首框↔末框) |
**落地要点(与代码一致)**
- **arm API**:`ui_page_password_arm(on_ok)` 由逻辑层在开页前装载完成回调;每次进入重置初始态
(全 `0`、光标首框,与实机一致)。页面模块不感知菜单,比对 `ui_pwd_def.h` 两个编译期密码
(先查修改:两值同设时授高权),一致时回调以授予的权限(`ui_perm_t`)为实参
- **权限注入**:开页 `ui_content_page_open(id, perm)` 携授予权限;描述符 `edit_capable` 声明页面
支持编辑模式;页面钩子经 `ui_content_page_is_editable()`(= edit_capable 且修改权限)决定
只读/可编辑行为
- **菜单路由**:需密码叶子 ENTER → `arm(menu_password_ok)` + 开 `CONTENT_PAGE_PASSWORD`;
密码检查先于内容页判断(带内容页的需密码叶子同样先经密码页);
确认一致时回调(携权限)**先关密码页回菜单屏、再沿未变导航状态续做"进入"动作**(有内容页→
开内容页携权限,否则压层进子框);无密码叶子直接开(默认只读)。
先关页是必须的:不关则后续动作落在被盖住的菜单屏上(焦点已移走,按键全错位);
事件内删对象由 LVGL `e->deleted` 机制保证安全(与 ESC 路径同模式)
- **几何**:标题 y=44;3 个 14×14 数字框、间距 6px、行顶 y=78、首框左 x=53(整组居中);光标框反显
- **回归**:`check_content_pages.py` 含密码页 4 状态(初始 / 输 1 位 / 正确确认进菜单 / 不一致清空),
pwk 终态经像素级几何断言验证为"4 框菜单 + sel=[2,0,0] + 终点空框"
## 五、页面模块(ui_page_xxx)
每页一个 `.c/h` 文件对,只含**数据(Flash)+ 描述符对象**,零 LVGL 对象、零机制代码。
以装置信息页为例(`ui_page_device_info.c` 实际代码节选):
```c
/* 头文件包含区 */
#include "ui_page_device_info.h" /* 内含 ui_content_page.h,并声明描述符对象 */
/* 静态变量定义区 */
static const cp_row_t s_rows[] = {
{"终端类型:", "F30"},
{"终端型号:", "F30"},
{"软件版本:", "SV0.010"},
/* … 共 6 行(参考图 10) */
{"程序日期:", "2024.08.27"},
};
/* 页面描述符(外部链接:框架描述符表 s_defs[] 直接引用) */
const content_page_def_t ui_page_device_info_desc = {
.title = "装置信息",
.subtitle = "馈线自动化终端",
.subtitle_y = 25,
.selectable = 0u,
.top_y = 40,
.pitch = 15,
.band_h = 0,
.text_dy = 0,
.left_x = 6,
.right_x = 86,
.rows_per_page = 0u,
.row_count = 6,
.rows = s_rows,
.fixed_cur_page = 10u,
.fixed_total_pages = 1u,
};
```
```c
/* ui_page_device_info.h(对外只有两个声明) */
#include "ui_content_page.h"
extern const content_page_def_t ui_page_device_info_desc; /* 描述符(Flash const) */
```
- 初始化器按**声明顺序**写指定域(ARMCC v5 = C99 要求顺序一致);省略的钩子字段零初始化为 NULL
- 行表 `s_rows[]` 是模块私有(`static`),只有描述符对象是外部链接
- 动态页的差异:`rows = NULL`,改在描述符里给 `refresh`(逐行取数),行数变化再给 `count_cb`
- 文件编码:`Drivers/BSP/UI/` 下为 **UTF-8 带 BOM**(中文字面量依赖 BOM 让 MSVC 认 UTF-8,
见 AGENTS.md 编码铁律)
## 六、执行上下文与内存
**执行上下文**
- 框架全部回调(绘制 `cp_draw_cb`、键 `cp_key_cb`、4 个页面钩子)运行在 **uiTask 上下文**
(LVGL 刷新与按键都走 `ui_task_func` 主循环,10 ms 周期)
- `refresh` 取到的动态文本在同一刷新周期内经 `lv_draw_label` **同步**光栅化 → 32 B 栈缓冲即安全
- 跨任务数据(ADC/网络/RTC/存储)沿用既有约定:生产者任务写快照(原子/队列),
UI 任务在钩子里读快照;框架与页面模块**不加锁**
**内存(实测,16 KB LVGL 池)**
| 项 | 值 | 说明 |
|---|---|---|
| 框架常驻 RAM | ~20 B | `s_open/s_sel/s_scr/s_cont` 4 变量 |
| 开页峰值 | +776 B | 屏 + 容器 + 事件列表 + 常量样式引用 |
| 关页回落 | 基线 +80 B 一次性残留 | screens 数组高水位 +8、`lv_screen_load` 菜单屏局部 X/Y 样式 +72(LVGL 核心行为,非泄漏;分解见 `菜单页面设计.md` §9.5) |
| 池水位门禁 | 5368 B / 5448 B | 菜单基线 / 开页-关页往返(回归脚本常量,2026-10-11 随 LVGL 控件裁剪由 4784/4864 上移;**与池总大小无关**,16 KB 池下实测值不变) |
| 描述符/行表/标题 | Flash const | 不进 LVGL 池;每页增量 = 描述符 ~40 B + 行表 + 代码 |
| 动态行栈缓冲 | 2×32 B | 仅绘制期存在 |
## 七、新增页面(标准步骤)
| # | 动作 | 改哪里 |
|---|---|---|
| 1 | `content_page_id_t` 加一个枚举值 | `ui_content_page.h` |
| 2 | 新建 `ui_page_xxx.c/h`:行表(或回调)+ `extern const content_page_def_t ui_page_xxx_desc`(头文件声明 + `.c` 定义) | `Drivers/BSP/UI/` |
| 3 | 描述符表加一行 `[新 ID] = &ui_page_xxx_desc` | `ui_content_page.c` `s_defs[]` |
| 4 | 叶子节点 → 页 ID 映射加一个 case | `ui_menu.c` `menu_content_page_for` |
| 5 | 双端注册(显式文件列表,无通配) | `MDK-ARM/*.uvprojx`(Drivers/BSP/UI 组)+ `simulator/CMakeLists.txt`(`UI_SOURCES`) |
| 6 | 回归验证 | `ui-verify` 五门禁;新页在 `check_content_pages.py` 登记黄金参考;含新汉字先重生成字体 |
按页面形态选描述符配置:
| 页面形态 | 配置要点 |
|---|---|
| 静态信息页 | `selectable=0`、`rows`=Flash 表、`fixed_cur_page/fixed_total_pages` 定页脚 |
| 静态列表页 | `selectable=1`、`rows_per_page` 分页、`rows`=Flash 表(钩子全 NULL) |
| 动态数据页 | `rows=NULL` + `refresh`(逐行取数);行数随数据变再加 `count_cb` |
| 表单/操作页 | `selectable=0`、`rows=NULL`、`draw_cb` 自绘内容 + `key_cb` 自有键(ESC 返回仍由框架保证;首个实例=密码页,见 §4.1) |
## 八、验证与回归
- **门禁**(`ui-verify` skill / `UI_自动化测试流程.md`):
0) 模拟器构建 0 警告 → 1) 菜单 8 状态像素回归 + 池基线 → 2) 内容页文本断言 + 11 页黄金参考逐字节比对
(网口设置 4 态 + 密码页 4 态等)+ 开页-关页往返水位 → 3) Keil 构建 0 错 0 警 → 4) 代码规范检查
- **门禁常量**(脚本头部):`MENU_POOL_BASELINE = 4784 B`、`POOL_AFTER_ROUNDTRIP = 4864 B`
- **失败处置**:像素不一致 = 代码 bug(修代码,**禁止**刷参考掩盖);**有意**改视觉 →
先肉眼确认新效果正确,再 `check_content_pages.py --update` 刷参考(`ref/` 为本地生成物、不入库)
- 新增页面后同步在 `check_content_pages.py` 增加该页的按键序列、文本断言与参考图
## 九、变更记录
| 日期 | 变更 |
|---|---|
| 2026-09-09 | 架构定稿:"框架 + 页面插件"模型,新增页面六步流程 |
| 2026-09-09 | 总览图由 ASCII 框线升级为 Mermaid(结构 flowchart + 生命周期 sequenceDiagram) |
| 2026-09-09 | 拆分执行:`ui_content_page` 提纯为框架(描述符表 + NULL 防护 + 描述符驱动绘制/按键、4 扩展钩子);三页拆出 `ui_page_device_info` / `ui_page_ac` / `ui_page_dc`(extern const 描述符对象);双端注册;五门禁全绿(像素一致、池 4784/4864 B 不变);描述符定稿 extern const 对象模式(ARMCC #144:函数名不能入对象指针表) |
| 2026-09-09 | 按实际代码重写(本版):章节重组为"总览 → 组成 → 框架机制 → 描述符 → 页面模块 → 上下文与内存 → 新增页面 → 验证";去掉规划性页面清单与拆分前执行清单;图扩充为 4 张 Mermaid(系统总览 / 页面生命周期 / 按键分发 / 绘制链);全部内容逐项核对当前代码 |
| 2026-09-09 | 密码页(首个表单/操作页,待实现)设计落档(§4.1):3 位密码、每页专属密码、每页"是否需要密码页"配置、↑↓ 调值 / ←→ 移光标 / ENTER 确认进入;实机视觉参考 `mockups/密码页面实际机器的示例图片.jpg`;同时发现框架冲突:`cp_key_cb` 对 `LV_KEY_LEFT` 先于钩子拦截(ui_content_page.c:345),落地前需描述符拦截标志扩展(ESC 恒不可拦截);4 项待确认(错误行为 / 密码存放 / 环绕行为 / 视觉风格) |
| 2026-09-09 | 密码页设计确认 3 项(§4.1):不一致 = 清空重输;每页专属密码 = 配置页可设 + 存 Flash 配置区;视觉 = 跟随实机照片(由此确定绘制链扩展需求:每页标题 y / "浅 V" 开关);← 键拦截方案与环绕行为仍待确认 |
| 2026-09-09 | 密码页 ← 键冲突解法确认(§4.1):方案 A 描述符拦截开关(`key_intercept`:`LV_KEY_LEFT` 先交页面钩子、ESC 恒不可拦截、现有 3 页零影响);框架扩展清单定稿(拦截标志 + 每页标题 y / "浅 V" 开关),仅剩环绕行为待确认 |
| 2026-09-09 | 密码页设计定稿(§4.1):边界行为 = 都环绕(数字 9↔0、光标首框↔末框);5 项设计全部确认、无遗留待确认——具备实施条件(框架扩展 + 新页面模块 + 每页保护标志/密码) |
| 2026-09-09 | 密码页落地(§4.1):框架描述符新增 title_y/hide_deco/hide_footer/key_intercept + 新 API `ui_content_page_invalidate`;新模块 `ui_page_password`(3 数字框反显光标 + arm API,draw_cb+key_cb);菜单受保护叶子路由(arm→开密码页,确认一致后 `menu_password_ok` **先关页再续做进入动作**);字体 97→102 汉字(198 字形);双端注册;回归扩至 7 页 + 往返水位,五门禁全绿 |
| 2026-09-09 | 权限模型(替代"每页专属密码",§4.1):表行尾注记 ` #P`(需密码,无类别);`ui_pwd_def.h` 两个编译期密码(查看 000 / 修改 123)+ 新 `ui_perm_t` 权限枚举——密码页输查看密码授只读权限、输修改密码授修改权限(只有修改权限进入后才能改参数);生成器同步改造并重新生成(pwd 列 = 0/1 布尔,`ui_menu_tab_has_pwd`);框架 `ui_content_page_open(id, perm)` 携授予权限 + 描述符 `edit_capable` + 新 API `ui_content_page_is_editable`(页面钩子据以判只读/可编辑);路由密码检查提到内容页判断之前(修复带内容页需密码叶子绕过密码的路由缺口);无密码叶子直接进、默认只读。§4.1 功能规则/待确认表/落地要点同步。验证:五门禁全绿(菜单 8 态 + 7 页像素一致、池 4784/4864 B 不变、Keil 0 错 0 警、代码规范 PASS) |
| 2026-09-10 | 网口设置内容页:新增 `Drivers/BSP/NETCFG/`(Flash 持久化 magic 'NCFG'+CRC-16/MODBUS,工厂默认 192.168.1.100/24、gw .1、MAC 全零=芯片内置)+ 页面插件 `ui_page_net_cfg`(5 行动态值 + 可编辑:↑↓调字节 0..255 环绕 / ←→移光标 / ENTER 确认,保存行 ENTER → `net_cfg_save` 下次上电生效);框架新增描述符钩子 `init_cb`(开页重置运行态)+ API `ui_content_page_selected_row`;`net_init` 增 `mac` 参数、netTask 改为等 `SYS_EVT_STORAGE_READY` → `net_cfg_load` → `net_init(ip,mask,gw,mac)` → 回写实际 MAC 影子;模拟器 main 调 `net_cfg_load` 使参考图显示工厂默认值;字体修复:lv_font_conv 丢弃低码位符号(U+FF0C 全角逗号 < 0x4E2D 范围起点 → 页脚逗号长期空白)改由 `merge_extra_symbols()` 补回 + 原本缺字(存/密/户/本/用/钥)→ 111 字 / 208 字形;回归扩至 11 页(网口设置 4 态:可编辑/只读/编辑光标反显/保存 OK),五门禁全绿 |
| 2026-09-10 | 修复网口设置页编辑态渲染 bug:光标段原传整串 `value + start`,而 `lv_draw_label` 按 `coords`(光标盒,仅段宽)折行 → 逐行向盒下方绘制、溢出到其它行与空白区("下方弹出一串字符")。改为只传**段文本**(IP/掩码/网关 3 字符、MAC 2 字符);§3.5 补"`lv_draw_label` 折行陷阱"说明 |
| 2026-09-10 | 网口设置页 MAC 值改常识格式 **`A4:94:B6:12:34:56`**(冒号分隔,17 字符 = 102px);布局调整 名 x=6→4、值 x=60→54(MAC ink 止于 155,仍在白带 2..157 内);编辑光标段偏移 MAC 2i→3i |
| 2026-09-14 | 运行记录/自检记录动态列表页(`ui_page_runlog` / `ui_page_selftest`):描述符新增 `count_cb` + `refresh`(开页/翻页时重读条数与内容,每条 2 行 = 时间戳/描述),数据源为 `App/log/sys_log` 引擎的 `/log/run.log`、`/log/selftest.log`;**框架不变量 selectable ⇒ band_h>0**——band_h=0 时选中行恒黑字无白带 → 黑底不可见(首版两页均踩中,改 band_h=13 对齐 AC/DC 约定);新坑:本 LVGL 版本 `lv_text_get_width` length≠0 时无 NUL 检查,传 -1 越串尾死循环,必须传 strlen(密码页错误提示首版触发,已修并全库排查) |
| 2026-09-15 | 网口设置页保存行改**异步交接**(P1-3 跨任务竞态修复,配置引擎属主 = defaultTask):保存行 ENTER → `net_cfg_save_request()`(置"待保存"标志 + `SYS_EVT_CFG_SAVE` 唤醒属主任务,UI 不再直接触碰 cfg_file 引擎);写盘与"立即应用"由 defaultTask 的 `net_cfg_save_run()` 执行(成功 → OK 并触发 `SYS_EVT_NET_RECONFIG` 免重启重建;失败 → ERR 不触发应用);行值改随交接状态 `net_cfg_save_state()` 显示 ENTER/WAIT/OK/ERR(原为页面局部 `s_saved` 同步结果,删除);无头自检(`--keys`)在注键后同步完成交接保持终态一致;五门禁全绿(像素/文本与改前一致,池水位不变) |