三数实测真值(每项两个独立来源互证): - 张数 60:simulator/tests/ref/ 入库 shot_*.bin 实数 60 = check_content_pages.py 的 PAGES 条目数 60(id 逐一对齐)。 - 已绑 28 叶 / 未绑 5 叶:ui_page_registry.h 的 UI_PAGE_MAP_COUNT 28 = ui_menu_tree.txt 剔掉文件头注释示例行后的 #page= 实数(不剔是 29)。 - 字体 218 唯一字 / 313 字形:ui_cn_chars.txt 唯一码点 218(ASCII 区 0 个,ASCII 0x20~0x7E 由生成器子集参数另计、不算进字数); ui_font_cn_12.c 的 .bitmap_index 条目 314 条含 notdef,剔掉 notdef → 313 = 218 + ASCII 95。 口径更正(数字一律不动):当前态计数单位由「页」改报「张」——张数 = 页面个数 + 分页页多拍的第 2 屏(runreport_p2 / sparam_p2 两张),两个数 不许互相冒充。同日先前那行「53 → 59 页」是页面个数口径、与判据本体 不同源,原文冻结不回改,只在各文件变更记录末行追加当日更正。 历史一律冻结:变更记录里的 27 / 40 / 47 / 53→60 / 59 与字库 122/217、 179/274、185/280、183/278 都是当日快照,只追加、不回改。 夹带说明:.pre-commit-config.yaml 一并入本笔——pre-commit 要求配置本身 已 staged 才允许提交;该文件的改动是 ui-golden 显示名(60 页→60 张)与 一条钩子计数口径注释(同日 51 → 52 → 51 净零,属另一类,已在此点名)。
38 KiB
内容页架构
用途:说明内容页体系在代码中实际如何工作(框架机制 / 页面插件 / 数据流 / 键与绘制行为 / 内存), 并给出新增页面的标准步骤。全文与当前代码一一对应;页面像素几何(行槽/选中带/页脚实测值) 见
菜单页面设计.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 系统总览
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 钩子)")]
一级菜单["屏/容器生命周期<br/>按需建毁 · 焦点 · 切屏"]
二级菜单["按键分发<br/>内置 ↑↓/ESC + key_cb 钩子"]
三级菜单["通用绘制(LV_EVENT_DRAW_MAIN)<br/>浅 V → 标题/副标题 → 行 → 选中带 → 页脚 → draw_cb"]
end
M -- "ENTER:ui_content_page_open(id, perm)" --> S
S -- "描述符驱动" --> 一级菜单
S -- "描述符驱动" --> 二级菜单
S -- "描述符驱动" --> 三级菜单
二级菜单 -- "ESC/←:close()" --> 一级菜单
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
三级菜单 -- "ui_deco_draw" --> D["ui_deco(共享)<br/>顶部“浅 V”装饰"]
三级菜单 -- "动态行:refresh / count_cb" --> PG
二级菜单 -- "页面自有键: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):
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
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 描述符表与页面注册
/* 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)
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)
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, 与 LVGLlv_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)
/* 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)
- ← 键拦截:描述符
key_intercept——置位时LV_KEY_LEFT先交key_cb(true = 页已处理不关页), 未置标志或钩子未处理则照旧关页;LV_KEY_ESC不受拦截("页面恒可退出"的框架保证不放松)。 其余 3 页不置标志,行为完全不变 - 每页标题 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; 密码检查先于内容页判断(带内容页的需密码叶子同样先经密码页); 确认一致时回调(携权限)先关密码页回菜单屏、再沿未变导航状态续做"进入"动作(有内容页→ 开内容页携权限,否则压层进子框);无密码叶子直接开(默认只读)。 先关页是必须的:不关则后续动作落在被盖住的菜单屏上(焦点已移走,按键全错位); 事件内删对象由 LVGLe->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 实际代码节选):
/* 头文件包含区 */
#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,
};
/* 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-verifyskill /UI_自动化测试流程.md): 0) 模拟器构建 0 警告 → 1) 菜单 8 状态像素回归 + 池基线 → 2) 内容页文本断言 + 60 张黄金参考逐字节比对 (网口设置 4 态 + 密码页 4 态等)+ 开页-关页往返水位 → 3) Keil 构建 0 错 0 警 → 4) 代码规范检查 - 门禁常量(脚本头部):
MENU_POOL_BASELINE = 5368 B、POOL_AFTER_ROUNDTRIP = 5448 B - 失败处置:像素不一致 = 代码 bug(修代码,禁止刷参考掩盖);有意改视觉 →
先肉眼确认新效果正确,再
check_content_pages.py --update刷参考(ref/*.bin已入库,刷了会进 diff,必须一并提交并说明) - 新增页面后同步在
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)在注键后同步完成交接保持终态一致;五门禁全绿(像素/文本与改前一致,池水位不变) |
| 2026-10-22 | §八 失败处置口径更新:ref/*.bin 黄金参考已入库,刷参考会进 git diff、必须随改动一并提交并说明理由(详见 UI_自动化测试流程.md §四) |
| 2026-10-27 | 内容页黄金参考 40 → 47 页(遥测死区与系数 2 页落地,simulator/tests/ref/ 实测 47 张:7 张新增、存量 40 张逐字节零漂移);本文件的当前态计数同步改 47,历史行 27 → 40 原样保留 |
| 2026-10-27 | 内容页黄金参考 47 → 53 页(通讯设置页落地并绑 #page=comm;判据 = simulator/tests/ref/ 入库 shot_*.bin 实数 53 张,git diff --name-status 31ca4a1f HEAD -- simulator/tests/ref 实测只有 6 个 A、零 M ⇒ 存量 47 张逐字节零漂移);本文件的当前态计数已同步 |
| 2026-10-26 | 验证与回归段的页数口径修正为 27 页黄金参考(simulator/tests/ref/ 入库 27 个 shot_*.bin,含整定 9 态);上方变更记录里的「11 页」是当时事实不动 |
| 2026-10-26 | 门禁常量修正为 5368 B / 5448 B(= 两个回归脚本头部现值);本文件正文表早已是 5368/5448,只这一处残留 2026-10-11 裁剪 LVGL 控件类之前的 4784/4864 |
| 2026-10-26 | 验证与回归段的黄金参考页数 27 → 40 页(UI 三页落地后 simulator/tests/ref/ 实数 40 张);上方变更记录里的 11 / 27 是各当时下限,保留 |