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

37 KiB
Raw Blame History

内容页架构

用途:说明内容页体系在代码中实际如何工作(框架机制 / 页面插件 / 数据流 / 键与绘制行为 / 内存), 并给出新增页面的标准步骤。全文与当前代码一一对应;页面像素几何(行槽/选中带/页脚实测值) 见 菜单页面设计.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 钩子)")]
        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):

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, 与 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)

/* 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 实际代码节选):

/* 头文件包含区 */
#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-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)在注键后同步完成交接保持终态一致;五门禁全绿(像素/文本与改前一致,池水位不变)