e4d70f9fcb
证据源 = 主树 ci-artifacts/archify/architecture-dtu-overview-20261030-095132/ evidence.md §11。该目录不入库,故把每条的判据与实测源抄进正文,别让证据只活在 一台机器上。处置口径:能改的改成与代码同源,核实后确认文档没问题的明确写「不改」。 判停点与就绪屏障(条 1、条 3) - AGENTS 原写 `protect_task.c:98` 直接停走判据:`:98` 是 `s_prev_cal_lock` 变量, 判停点是 `App/task/protect_task.c:268 if (adc_cal_prot_blocked() != 0u)`, 判定本体在标定层 `adc_cal_prot_blocked()`、protectTask 只是消费者 → 改引用, 同步 `ui_task.c:240` 页面注释与 `docs/任务规划/数据可信.md` 的同一处行号。 - `SYS_EVT_FTP_READY` 有生产者(`Src/freertos.c:490`,注释自陈预留)无消费者; `SYS_EVT_TIMESYNC_READY` 全仓无生产者也无消费者(只有定义与测试桩)。 AGENTS、`sys_events.h` 位注释、屏障配图(生成器 + 产物)三处从「四个就绪位 并列」改成「bit0/bit1 在用 + bit2/bit3 预留」,并指向 `docs/架构设计/项目启动顺序管理.md` §3.2 的逐位对照表。ftpTask 实际等的是 `NET|STORAGE`(`Src/freertos.c:477-479`,WaitAll|NoClear,100 ms 分片喂狗)。 /log 不是「三本」(条 5) - `App/log/sys_log_mcu.c` 的 `s_paths[]` = run / selftest / accid / alarm / start / oper / yk 七本 64B 定长文本 + `soe.log`(64B 定长二进制)+ `wdev.log` (变长取证行,`App/fs/vol_lock` 侧投,不走定长格式)。 → AGENTS 两处、总览 §2.1 与 §4 的「三本」改成「七本 + SOE(+ wdev 取证行)」。 主机单测 44 套 → 47 套(条 2,两把机器尺互证) - `scripts/check_test_coverage.py --list` 打印 47 行;`ls -d test/host/*/` = 48 个 一级目录,唯一没有 `run_unit_test.py` 的是 `common/` ⇒ 47 套。 - 差额三名 = 屏板链路套件:`sb_proto`、`sb_link`(2026-10-29 入库)、 `sb_led_map`(2026-10-30 入库)。专属钩子仍是 22 根 ⇒ 无专属钩子的从 22 套变 25 套。 - 同步面:AGENTS、`docs/门禁说明/README.md`、`host-unit-tests.md` §6 台账(名单补 三名)、`docs/代码规范/pre-commit使用说明.md`、`主机单测覆盖率口径.md`(含耗时句)、 `scripts/README.md`、总览目录树、`.pre-commit-config.yaml` 三处注释、 `test/host/sys_log_mcu/sys_log_mcu_test.c` 一处注释。 - 带日期戳的历史测量值按纪律冻结不动:28 套、41 套 ~105 s、40 套/18 套那三处。 本仓 LVGL 版本(条 6) - vendored 版本 = `Lib/lvgl/lv_version.h:9-11` 的 9.3.0;总览两处「LVGL v9.2」与 `gen_arch_svg.py` 图内文字改 v9.3 并重跑产物(脚本与产物同一笔,`generated-fresh` 逐字节核)。 - **刻意不动**:`docs/内存管理/RAM使用分析与优化方案.md` 两处「LVGL v9.2 文档」+ GitHub `release/v9.2` 分支 URL ——那是对外游某分支文档的引用,不是本仓版本声明。 触发口径与栈值(条 4、7、9、10) - ADC 映射 §1「ADC1 为软件触发」是旧口径:`Src/adc.c:53` 实为 `ADC_EXTERNALTRIGCONV_T3_TRGO` → 改成 TIM3 TRGO,并补一句「TIM3 与 TPAFE 的 CONVST 之间没有硬件耦合,仍不构成共同采样触发」,缺陷结论本身不变。 - 同文档 adcTask 栈 512 → 640 words(`Src/freertos.c:159 .stack_size = 640 * 4`)。 - `docs/UI设计/UI_设计架构.md` 的 uiTask 栈「16384 words」是单位错: 实为 4096 words = 16384 B(`Src/freertos.c:218`),顺手补上行号。 - `App/app/app_main.c:197` 注释「DE = PD0」陈旧:`:198` 实传 `ST_DIR4_*` (V0.1 实板 PA15,V0.0 才是 PD0)→ 注释改指 V0.1 网表报告的引脚迁移表。 - 两处已退役编号 `N-18`(ADC 映射、V0.1 网表报告)换成登记簿条目名 「双转换器无共同采样触发」。 核实后确认「文档没问题」的三条(条 8、12、13) - 黄金参考「菜单 8 状态 + 内容页 66 张」:`simulator/tests/ref/` 实测 66 个 `shot_*.bin`,与 `ui-golden` 钩子显示名一致 → 不改。 - `App/task/rs485_task.c` 存在但 `Src/freertos.c` 不创建它(grep 无 rs485): AGENTS 与总览都已写明「尚未接线 / 暂缓」,图侧画成「实现待接线」→ 不改。 - 104 端口 `:2404` 是工厂默认、可经 `/config.ini` `[comm]` 改 → 总览索引表补 「默认」二字;「uiTask 10 ms 节拍」核实 = `ui_tokens.h:65 UI_TASK_TICK_MS 10` ✓。 并行勘查会话的同族发现(它扫「文档声明数 vs 真值」,逐处独立复验后采纳 3 处) - `App/protocol/iec104/iec104_link.h` 头注释写「端口:TCP 2404(§1.5 B0 定义)」, 同文件下方已写「服务端口真源已迁 [comm] 段」= 自相矛盾 → 补「工厂默认」并指回 [comm] 与「下次重 bind 即生效」。 - `scripts/gen_task_table.py` 的 iec104Task 描述「:2404 监听」→「工厂默认 :2404 监听」,`--write` 重生成 README 任务表(AGENTS 表无描述列,实测未被改写)。 - `App/log/README.md` 五行写入点行号全漂:事故 181→188、告警 184→191、启动 176→183(`App/task/protect_task.c`);操作 `iec104_pts.c` 574/602→582/610、 `default_task.c` 242→266、`sys_log_mcu.c` 646→682;遥控 573/576/582/586/596/ 598/609 → 581/584/590/594/604/606/617(宿主 `sim_syslog.c:245` 原值正确、未动)。 - **撤销一条**:我早先记的「`docs/算法/继电保护功能复现规划.md:225` 声称记录本已 有消费者」与原文不符——:225 实为规划表里「动作/SOE 记录 | 复用 `sys_log`」一行, 属能力复用口径、不是当前态断言 → 不改。教训:**改引用前逐处读现文,别照自己 上一轮的笔记盲替**(这条比改中的每一处都值钱)。 - 同一批勘查还报了约 12 处「`2404` 缺『工厂默认』限定词」(AGENTS、`App/protocol/ README.md`、`Src/freertos.c` 注释、5 篇 docs、`test/host` 工具,含一张配图生成器 要连产物同批重跑)与 `测试开发标准流程.md`「像素层 60 内容页」、 `.pre-commit-config.yaml` uinav 注释「快检档 18 张黄金参考」(后者写时为真、 描述的却是钩子现有行为):**本笔刻意不做**,另开任务空间处理——那批要同时动 AGENTS.md 与 `.pre-commit-config.yaml` 两处单写者面,与并行会话撞车的代价更高。 - 端口生效语义(用户裁定 2026-10-30):产品基线写「公共地址与端口可配…改后需重启 生效」,代码 `App/task/iec104_serv.c` 是「ca 改后要重启;port 下次重 bind 才生效」 ——裁定**按代码语义要改**处理(改了端口应立刻重 bind),属固件行为变更,另开任务 且需真机验证;`.serialtest/config.json` 写死 `i104: listening on 2404` 必检锚点 是同题(现场改端口会让串口回归假失败),一并留给那条任务。 自查(任务树内) - `check_doc_links.py --sections-strict`:12 个改动 .md 逐个 rc=0; `check_mermaid.py` 改动 .md 全 rc=0;`check_generated_fresh.py` PASS (25 个生成器 / 21 一致 / 4 存量基线);`check_nested_comment.py --all` PASS (449 文件,新发 0、存量 2);`check_bom_drift.py --all` 99 个名单文件无漂移。 - 编译:`.dsh/skills/keil-build` 退出码 0(0 错误 0 警告), Program Size Code=246720 RO=29836 RW=2052 ZI=149708 —— 与本树凭证同码, 证明这批改的确实只有注释与文档。 - 字节体检:23 个文件无 CR / U+FFFD / 行尾空格 / 缺末行换行;`ui_task.c` 被编辑后 丢的 3 字节 BOM 已补回(bom-guard 复验通过);5 处「三连空行」逐文件与 HEAD 比对确认是存量(Python 生成器的 PEP8 双空行与个别 C 文件原有空行)。 - 一次性工具 `ci-artifacts/fix_docs2.py`:断言式规则表,按文件读一次、在演化文本上 逐条替换、一次写盘。第一版写成「每条规则各自从启动时快照起算」,同一文件后一条 把前一条整体覆盖,只有每文件最后一条活下来——靠「改完再全仓复扫旧形态」当场抓到, 这条判据本身已值得写进流程。 - 待办:`git add` 后需重跑 `ram_baseline.py --update`(体积凭证按 git 索引内容生成, 未 staged 的文件不在凭证里,`check_size_gate.py --local` 现在因此报红)。 - 未 commit、未 push(AGENTS.md 边界:提交属 Ask first)。
24 KiB
24 KiB
UI 设计架构
用途:说明 UI 系统如何实际工作(渲染/输入/菜单架构)与设计考量(为什么这样做)。 配套文档:
LVGL模拟器部署与使用说明.md(模拟器部署/使用)、UI_中文字体生成与配置.md(字体生成)。
一、UI 系统速览
1.1 总体方案
MCU 无物理屏幕:LVGL 做 160×160 1bpp 单色离屏渲染,帧缓冲(3200 B)经 RemoDispBus 协议(TCP 端口 7003)按需推给远端客户端(DTU-RemoteLCD)显示;远端按键走同一通道回传。"屏幕"实际是 PC 端客户端。
1.2 文件地图
| 文件 | 端 | 职责 |
|---|---|---|
Drivers/BSP/UI/ui_port.c/h |
MCU | LVGL 离屏显示端口(I1 + flush 到内存帧缓冲)+ 远端 keypad 输入 |
Drivers/BSP/UI/ui_menu.c/h |
共用 | 菜单导航逻辑层:持有导航状态(路径/选中/深度/滚动,共 17 B)+ 键语义,单向驱动显示层;不持 LVGL 对象 |
Drivers/BSP/UI/ui_menu_view.c/h |
共用 | 菜单显示层:横向级联框渲染(右贴靠/自适应宽/顶底对齐),持有全部 LVGL 对象与样式、无导航状态 |
Drivers/BSP/UI/ui_menu_tab.c/h |
共用 | 菜单数据层:g_menu_tab[] 父子索引表 + 查询 API(生成物,勿手改) |
Drivers/BSP/UI/ui_content_page.c/h |
共用 | 内容页框架(机制 + 描述符表);装置信息/交流量/直流量/用户密钥四页由 ui_page_xxx 模块接入(独立屏按需建毁,自绘行,零常驻池占用);多页面"框架+页面插件"架构见 内容页架构.md |
Drivers/BSP/UI/ui_net_sink.c/h |
共用 | RemoDispBus 服务端(收键 + 回送帧缓冲),MCU/PC 双端同一份;平台后端 net_socket + ui_net_sink_backend(见 RemoDispBus 协议文档) |
Drivers/BSP/UI/ui_font_cn_12.c |
共用 | 中文 1bpp 子集字体(lv_font_conv 生成物) |
App/task/ui_task.c/h |
MCU | UI 任务:等 NET_READY → 初始化 → 10ms 刷新循环 |
Lib/lv_conf.h |
MCU | LVGL 配置(1bpp / 16KB 内存池 / 默认字体);⚠ 必须与 simulator/lv_conf.h 同一特性子集,见 §1.4 第 10 条 |
simulator/(main.c / sim/ / lv_conf.h / CMakeLists.txt) |
PC | SDL2 模拟器(替代 ui_port,共用全部 UI 源码);支持 --screenshot / --keys <seq> 无头截图验证 |
tools/font_gen/(extract_cn.py / make_font.py)+ ui_cn_chars.txt |
工具 | 字符集提取 / 字体生成 |
tools/menu_gen/gen_menu.py + ui_menu_tree.txt |
工具 | 菜单声明源 → 生成 ui_menu_tab.c/h |
1.3 关键参数
| 项 | 值 | 位置 |
|---|---|---|
| 屏幕 | 160×160,1bpp(LV_COLOR_FORMAT_I1) |
ui_port.h |
| 帧缓冲 | 3200 B(g_ui_fbuf,flush 回调整帧 memcpy) |
ui_port.c |
| 绘制缓冲 | 3200 + palette 8 B(+32 B 余量):I1 索引色缓冲布局为 [palette 8B][pixel data],见 1.4 第 8 条 |
ui_port.c |
| LVGL 内存池 | 16 KB(LV_MEM_SIZE,2026-10-15 由 40 KB 收缩;实测峰值 6,568 B = 池的 40%) |
Lib/lv_conf.h |
| 刷新周期 | 10 ms(ui_port_step(10) + osDelay(10)) |
ui_task.c |
| 推流端口 | 7003(TCP,与 DTU-RemoteLCD 客户端对齐) | ui_net_sink.h |
| 默认字体 | ui_font_cn_12(12px 1bpp,224 汉字/符号 + ASCII 0x20–0x7E,共 319 字形) |
Lib/lv_conf.h |
| uiTask 栈 | 4096 words = 16384 B(BelowNormal) | Src/freertos.c:218(.stack_size = 4096 * 4) |
1.4 硬约束(改 UI 必须遵守)
- RAM 紧张:新增页面/控件/缓冲前必须核算 LVGL 池(16KB,实测峰值 6,568 B)与任务栈余量(见
docs/内存管理/RAM预算台账.md) - 新增汉字必须重新生成字体(
tools/font_gen/两步),否则显示空白 - 文本一律用静态字符串(
lv_label_set_text_static),不 strdup - UI 相关文件为 UTF-8 带 BOM(
Drivers/BSP/UI/*、ui_task.c、Lib/lv_conf.h),其余源码无 BOM;禁止 PowerShell 整文件重写(编码铁律,见 AGENTS.md) - 模拟器与 MCU 共用 UI 源码(
ui_menu*.c/ui_font_cn_12.c),禁止在simulator/下复制副本;新增文件须同时注册进 Keil.uvprojx与模拟器 CMakeLists(双端注册) - 验证顺序:模拟器预览 →
/keil-build(0 错 0 警)→/serial-boot-test(Required 加UI port init: OK;UI menu init: OK) LV_DRAW_SW_COMPLEX必须为 1(两端均显式声明):主界面顶部"开口浅 V"装饰含斜线,lv_line的斜段仅在复杂渲染器下绘制(draw_line_skew),关闭则只画水平线、两腿消失。且LV_DRAW_SW_CIRCLE_CACHE_SIZE须非零(默认 4),否则零长数组编译报错(C2466)- I1 绘制缓冲必须含 palette 头(2026-09-21 实证,别再踩):LVGL 9.3 索引色格式的 draw buffer 布局是
[palette 8B][pixel data stride×h]——lv_draw_buf_create()按stride×h + LV_COLOR_INDEXED_PALETTE_SIZE(cf)×4分配,lv_draw.c:497 → lv_draw_buf_goto_xy()取绘制指针时先+= palette,lv_refr.c get_max_row()也从data_size里扣掉 palette。 但lv_display_set_buffers()/lv_draw_buf_init()的容量校验只看stride×h(不含 palette) → 只给 3200 B 会被静默接受,后果有两条: ① 末 8 个像素字节写到缓冲之外(曾把紧邻.bss的s_frame_box[0]指针打脏 → 野指针 HardFault;金丝雀实测越界 n=8); ② flush 回调收到的首地址含 palette,照原样整帧拷贝会让推流画面整体错位 8 字节。 故ui_port.c按stride×h + palette供给缓冲,上报尺寸含 palette,flush 时color_p + UI_DRAW_BUF_PALETTE取像素。 - MCU 端必须显式应用 dark mono 主题 + 初始化 I1 调色板(2026-09-21 实证,远端"纯白屏"根因):
lv_display_create()内部会按lv_theme_mono_init(disp, **false**)(白底黑字)初始化单例主题,而lv_display_set_theme()只在屏幕为空时把主题重应用到screens[0](底层)——活动屏幕保持 LVGL 类默认浅灰F5F5F5; UI 内容恒用lv_color_white()绘制(ui_standby/ui_menu_view/ui_deco/ui_content_page)→ 白底白字完全不可见, 远端整帧 99% 为亮(客户端显示"纯白")。修法(ui_port.c):lv_theme_mono_init(disp, true, ...)+lv_display_set_theme()+ 对活动屏幕再lv_theme_apply()(实测bg_before=F5F5F5→bg_after=000000)。 另外 I1 缓冲头 8 B 调色板 LVGL 不替调用方初始化(上电全 0 = 两项透明黑),须显式写[0]=黑 [1]=白; 渲染器本身按亮度阈值置位(lv_draw_sw_blend_to_i1.c:亮 → 位=1),与客户端"位=1 即亮"的约定一致。 - 两份
lv_conf.h必须是同一特性子集(2026-10-11 定;裁剪 LVGL 死代码时暴露的漂移):Lib/lv_conf.h(MCU)与simulator/lv_conf.h(PC)是两份独立文件,此前已漂移——模拟器早已 I1-only,MCU 端却让 11 个LV_DRAW_SW_SUPPORT_*、5 个未用控件类、LV_USE_THEME_SIMPLE、LV_USE_FLEX/GRID全靠lv_conf_internal默认值隐式生效,把用不到的代码整份链进镜像 = 实测 64,576 B 死代码(旧镜像的 23.5%)。现两端对齐为同一子集:- 绘制核只留
LV_DRAW_SW_SUPPORT_I1(显示是 1bpp;字形 A8 位图是作为 mask 传入、混合按目的格式分派,故不需要 A8/L8/AL88); - 控件全关(
label/button/buttonmatrix/bar/textarea/image/keyboard)——全仓(Drivers/BSP/UI、App、simulator)零控件构造函数,只用lv_obj_create+lv_theme_mono_init; LV_USE_THEME_SIMPLE 0:它是把上述控件类拉进镜像的锚点(lv_display_create()会强链lv_theme_simple_init);⚠ 9.3 没有LV_USE_THEME_BASIC,旧配置里那条是无效项,已删;LV_USE_FLEX 0/LV_USE_GRID 0:全仓无lv_obj_set_flex_*/lv_obj_set_grid_*调用。 ⚠ 裁剪后 ARMCC 报#177-D(上游 9.3.0 在lv_draw_sw_blend_to_i1.c漏了lv_color16_from_u16的定义处守卫)→ 本仓已按使用点补#if LV_DRAW_SW_SUPPORT_RGB565_SWAPPED(原型 + 定义两处),升级 LVGL 时若上游已修可去掉。 ⚠ 将来引入控件型页面(原型库)时两端同步逐项打开并复跑ui-verify。量化与验证记录见../内存管理/RAM使用分析与优化方案.md§2.6.4。 ✅ 已入门禁(2026-10-11):scripts/check_lv_conf_sync.py= pre-commit 钩子lv-conf-sync。比对的是生效值(文件未定义则取lv_conf_internal.h兜底默认),并抓"写了 LVGL 不认的宏名"与同文件重复定义。首次跑即揪出两端共 26 个 9.3 不存在的选项(LVGL 8 / 9.4 模板残留,与LV_USE_THEME_BASIC同类)、3 处同文件重复定义、10 项 MCU 端靠默认值隐式生效的开关(含默认 =1 的LV_USE_OBSERVER/LV_USE_DRAW_PXP/LV_USE_IMAGEBUTTON)——已全部清理/显式固化。 ✅ 门禁触发范围(2026-10-11):lv-conf-sync的files:同时匹配Lib/lv_conf.h、simulator/lv_conf.h与脚本自身 → 两端任一改动都会跑。此前顶层exclude整目录挡掉simulator/,模拟器侧改动拦不到;已删该条并把simulator/下自研代码整体纳入 pre-commit(cppcheck 当天即揪出sim_syslog.c一处真实空指针解引用),详见../代码规范/pre-commit使用说明.md§5.5。
- 绘制核只留
二、渲染链路(画面怎么到"屏幕")
2.1 MCU 端(产品实际路径)
LVGL 场景(160×160,LV_COLOR_FORMAT_I1 单色)
→ SW 软件渲染器(仅启用 I1,裁掉其余色深)
→ flush 回调:整帧 memcpy 到内存帧缓冲 g_ui_fbuf(3200 B)
→ RemoDispBus 协议(TCP 7003):远端客户端发 LCDMEM 请求才回送整帧
→ DTU-RemoteLCD 客户端(PC)解码显示
- MCU 无物理屏幕:"屏幕"是 PC 端客户端,帧缓冲只是网络传输的载体
- 按需拉取:不主动推帧,客户端要才给(菜单是低频交互,够用且省带宽)
- LVGL 视角:
ui_port.c就是一个普通 display(I1 格式 + flush 回调),不知道帧最终走网络
2.2 模拟器端(PC 开发路径)
同一份 LVGL 场景(共用 ui_menu.c / ui_font_cn_12.c)
→ SW 渲染器(I1)
→ SDL 窗口 blit(LV_SDL_RENDER_MODE_PARTIAL,1bpp 强制要求)
→ 屏幕(160×160,高 DPI 下可能放大显示,属正常)
- 两端场景层完全相同(同一份 UI 源码),只有"渲染结果去向"不同:MCU 进帧缓冲走网络,模拟器进 SDL 窗口
- 模拟器必须初始化 mono 主题(
lv_theme_mono_init):1bpp 下无主题 = 黑底黑字,全黑屏
三、输入链路(按键怎么到菜单)
3.1 统一模型(两端相同)
按键 → keypad 输入设备(LV_INDEV_TYPE_KEYPAD)
→ 默认输入组(lv_group)
→ 组内焦点对象 = 当前可见页面容器
→ 容器的 LV_EVENT_KEY 回调(menu_key_cb,user_data = 该层深度)
- keypad 输入设备经
lv_group_send_data把键码发给焦点对象(模拟器--keys无头验证走同一路径) - LVGL v9 的输入组不自动注册默认键回调(方向键移动焦点需焦点对象自己实现) → 4 个深度容器都在组内,但同一时刻仅最右框容器获焦,按键一律由逻辑层统一处理,无焦点争抢
- 按下时输入设备捕获焦点对象(indev_obj_act),释放时向其回发 RELEASED/CLICKED → 焦点对象(容器)必须存活,这是"容器持久不删"的原因之一(见 §5.2)
3.2 MCU 端
远端客户端按键 → TCP 7003 → ui_net_sink 解析
→ ui_port_queue_key(UI_KEY_* → LV_KEY_* 映射)
→ keypad 输入设备 read_cb 上报 → 默认输入组
3.3 模拟器端
SDL 键盘 → LVGL SDL 驱动内部 5ms 定时器(sdl_event_handler)
→ 键码映射(↑↓←→/Enter/Esc → LV_KEY_*)
→ keypad 输入设备 → 默认输入组
- ⚠️ 主循环不得再调
SDL_PollEvent:会先消费事件队列,按键全部失效(2026-09-02 实际踩坑,见 §5.7)
3.4 键位语义
| 键 | 行为 |
|---|---|
| ↑ / ↓ | 移动选中(首尾环绕);选中行反色高亮 + 自动滚动到可见区 |
| → / Enter | 选中条目右侧弹出子框(叶子节点建空白终点框) |
| ← / Esc | 关最右框(其行销毁,焦点回父框,选中态不变) |
四、菜单架构(核心)
显示模型细节(右贴靠/自适应宽/顶底对齐的像素规则与实测值)见
菜单页面设计.md§四⑧;本节只讲代码架构。
4.1 三层拆分(高内聚低耦合)
| 层 | 文件 | 职责 | 持 LVGL 对象? |
|---|---|---|---|
| 数据层 | ui_menu_tab.c/h(生成物) |
g_menu_tab[] 父子索引表 + 查询 API(children/has_children/name/valid) |
否 |
| 显示层 | ui_menu_view.c/h |
级联框渲染:样式 / 持久容器 / 框体(行与根框装饰、状态行均自绘、无对象);几何现算按层缓存 | 是(全部) |
| 逻辑层 | ui_menu.c/h |
导航状态(s_path/s_sel/s_depth/s_scroll,共 17 B)+ 键语义;单向驱动显示层建/毁/刷新 |
否 |
- 依赖方向:逻辑 → 显示,两者都用数据查询 API;显示层无导航状态、逻辑层无 LVGL 对象
- 改菜单只编辑
ui_menu_tree.txt后重跑python tools/menu_gen/gen_menu.py(再生成 ui_menu_tab.c/h)
4.2 数据:父子索引表
g_menu_tab[]:43 节点,每项 = 名称 + parent 下标(MENU_ROOT=0xFF 为根);树序即表序- "页" = 对表的一次过滤:节点 N 的页条目 = 全部
parent == N的行(按表序) - 菜单 vs 叶子由"是否有子项"自动推导:
has_children(N)非空 → 菜单行(带 ▶),为空 → 叶子(无箭头,ENTER 进空白终点框)
4.3 对象树
screen
└─ menu(铺满屏幕,flat)
└─ cont 全屏持久容器(唯一焦点/键入口;各层框体均建其内,永不删)
├─ 根框体(持久)+ 根框装饰"浅 V" + 状态行(容器自绘,无对象)
└─ 子框体进入时建、返回时毁
- 持久容器永不删(唯一焦点/键入口,见 §5.2);子对象全部绝对定位(级联框须同屏叠加,层间不用 flex 堆叠)
- 框体 =
lv_obj(透明底 + 1px 白边框 + 自动滚动条),行在框体内自绘(无子对象)
4.4 条目行与选中态
- 行 = 数据(节点下标
s_row_node[d][i],无对象)+ 框体LV_EVENT_DRAW_MAIN自绘:选中行整行白带 + 每行lv_draw_label文本(左留白 2px)/箭头(紧跟文本、间距 2px;叶子无箭头) - 行无对象、不入输入组:纯展示,按键一律由容器 → 逻辑层处理
- 选中行 = 整行反色(白带、黑字),箭头随反色变黑(参考图 02/04 实测:选中行保留 ▶);非选中行黑底白字
4.5 框生命周期(按需建毁)
ENTER/→ 进子框:
现算几何(x = 父框右缘 − 1 共享边框列;y = 父"选中行"行顶,超屏底则上移使本框底对齐该行行底)
→ cont 建框体 + 注册行数据(框深度由逻辑层 s_depth 决定,焦点恒在持久容器)
←/Esc 返回:
销毁最右框(框体连自绘回调整体删除,容器保留)→ 焦点恒在持久容器;父层选中/滚动位原样保留
- 容器持久、框动态:行自绘无对象,池内存 = 屏上框体(实测根框 5,368 B、最深四级级联 6,040 B、最重页面峰值 6,568 B,远低于 16KB 池)
- 同节点再次进入:还原该框上次选中与滚动位(
s_sel/s_scroll);换节点则清零 - 叶子节点(无子项):ENTER 建空白终点框(w=6、h=2 占位),仅 ESC 可返回
- 深度保护:depth+1 ≥ 4 不再下钻
4.6 焦点与按键处理
- 焦点对象 = 最右(最深)框的容器;进/退时
lv_group_focus_obj显式转移 - 每层容器挂
menu_key_cb(user_data = depth),统一分发到逻辑层menu_key_handle:↑↓ 移选(首尾环绕)+ 跟随滚动、→/ENTER 进、←/ESC 退 - 选中变化只改选中数据 + 跟随滚动 + 重绘框体,不重建;滚动位回读持久化到
s_scroll[d]
4.7 内容页(ui_content_page)
菜单叶子节点进入后的独立全屏页面(装置信息/交流量/直流量/用户密钥密码页),与级联框同屏互斥。设计细节见
菜单页面设计.md§九、内容页架构.md。
- 对象树:
screen(新) → cont 全屏容器(flat + CLICK_FOCUSABLE,键入口);行全部自绘(无子对象),与级联框同一"行=数据"策略 - 生命周期 = 按需建毁:ENTER 叶子(node 1/3/4)→
lv_obj_create(NULL)新建独立屏 + 容器 → 入默认输入组获焦 →lv_screen_load切屏;ESC → 焦点先回菜单持久容器、切回菜单屏,再删容器+屏。菜单屏对象保留不销毁(返回即原样还原) - 导航状态不压层:菜单
s_depth/s_sel/s_scroll原样保留,内容页只记自身选中行s_sel(每次进入从首行开始) - 池占用实测(模拟器 x64,16KB 池):开页峰值 +776 B(屏+容器+事件列表+局部样式),关页后回落;一次性残留 +80 B(screens 数组高水位 +8、
lv_screen_load给菜单屏设 X/Y 局部样式 +72,均为 LVGL 核心行为、非本模块泄漏) - flat 样式用常量样式(
LV_STYLE_CONST_INIT):属性存静态数组、零池分配;动态lv_style_t首次 set_prop 会从池分配values_and_props且永不释放(x64 实测 88 B/个,MCU 同量级),常驻样式一律用常量样式 - 多页面架构(已定稿并拆分,2026-09-09):待实现 30 个叶子页;框架(本模块)+ 每页一文件(
ui_page_xxx,描述符为 extern const 对象)+ 4 个扩展钩子(refresh/count_cb/draw_cb/key_cb),见内容页架构.md;架构第一步(共享"浅 V"装饰ui_deco)与拆分本身均于 2026-09-09 落地,纯重构回归全绿(像素一致、池 4784/4864 B 不变);首个表单/操作页(用户密钥密码页:draw_cb+key_cb + 描述符 title_y/hide_deco/hide_footer/key_intercept)同日落地
五、设计决策与考量(为什么这样做)
5.1 弃用 lv_menu 控件
lv_menu已被 LVGL 官方废弃;官方建议用基元控件构建菜单:lv_obj构建页面 + 返回按钮切换可见页面(参考官方示例lv_example_menu_navigation)- 基元控件方案的好处:无废弃风险、样式完全自主可控、与 mono 主题/1bpp 单色兼容性好
- 配套动作:
LV_USE_MENU两端配置置 0(MCULib/lv_conf.h+ 模拟器simulator/lv_conf.h),省代码空间
5.2 页面容器持久不删
- keypad 输入设备在按下时捕获焦点对象,释放时向其回发 RELEASED/CLICKED
- 若在按键处理器内删除焦点对象(如"返回时销毁当前页"),释放事件将落在已释放内存上 → use-after-free
- 容器永不删除,回发事件始终落在存活对象上,天然安全;要释放的只是容器内的框体(行自绘、无对象)
5.3 框按需建/销
- LVGL 池仅 16KB(
LV_MEM_SIZE,2026-10-15 由 40KB 收缩),全量预建 43 个节点的所有页面必然爆内存 - 只建"当前路径"上的框:行自绘无对象,池内存 = 屏上框体(实测根框 used=5,368 B、最深四级级联 used=6,040 B、网口设置保存态峰值 used=6,568 B = 池的 40%)
- 返回即
lv_obj_delete释放,内存随时回落到根框水位
5.4 持久容器是唯一焦点对象
- v9 输入组不自动处理方向键(无默认键回调),框深度无需由焦点对象携带
- 持久容器为组内唯一焦点对象(初始化时入输入组并获焦,永不删、焦点永不迁移);容器集中处理按键:逻辑单点、行为可预测、无多对象焦点争抢
- 框深度由逻辑层
s_depth决定(4 容器时代的user_data=depth已冗余,去掉) - 框体(含自绘行)不入组:纯展示,不参与焦点/点击,避免与容器抢键
5.5 条目行自绘而非对象化
- 每行对象有"机制税":
lv_obj_t结构体 + 样式向量 + 子 label(文本/箭头)状态,约 2 KB/行;屏上 2~3 框 × 多行会逼近 16 KB 池 → 行降级为数据(节点下标s_row_node[4][16],1 B/行) - 框体
LV_EVENT_DRAW_MAIN自绘:选中行整行白带(lv_draw_rect),每行文本/箭头经lv_draw_label(与 label 同字体光栅化路径,像素级一致) - 行不需要对象语义(焦点/点击/按压反馈):输入由容器统一处理,行是纯展示
- 自绘同时根除了旧对象模型的两类问题:样式继承"白底白字"坑(2026-09-02 实际踩坑)、行对象池内存膨胀
5.6 flat 样式剥离主题 card
- mono 主题给所有基元对象附加 card 样式(背景/边框/圆角/内边距/文字色)
- 160×160 小屏 1bpp 菜单不需要卡片观感(边框圆角浪费像素)
- flat 样式(透明背景/无边框/无圆角/零内边距)后加优先,逐项覆盖 card
- 常驻 flat 样式必须用常量样式(
LV_STYLE_CONST_INIT+lv_style_const_prop_t[]):动态lv_style_t的values_and_props是池分配且永不释放(x64 实测 88 B/个),RAM 紧张下每个常驻动态样式都是永久泄漏;常量样式属性存 Flash、零池占用
5.7 SDL 事件只由 LVGL 驱动轮询
- LVGL SDL 驱动内部有 5ms 定时器(
lv_sdl_window.c的sdl_event_handler)统一SDL_PollEvent并分发 - 主循环若再
SDL_PollEvent,会先消费事件队列 → 驱动定时器轮询时队列已空 → 键盘全部失效 - 关窗退出由驱动
LV_SDL_DIRECT_EXIT=1自动exit(0),主循环无需处理 SDL_QUIT
5.8 推流往返的心跳边界(F3,2026-10-25 大窗判读)
- 现象:netTask 监听重建风暴期,
ui_net_sink_poll()内「单个可等待往返都回来了、 但一轮里串了好几个」,把 uiTask 两次心跳之间的间隔顶过 800ms——看门狗按位连击 判据(docs/子系统设计/看门狗设计说明.md§5.3)抓到 bit4 单任务 8 连击窗 (2026-10-25 台架实测,uisn=154ms 仍在段内样本,:7003churn 与探针重建并发叠加)。 - 处置(本票 = 心跳面):
ui_net_sink_set_beat_cb()回调注入——sink 在每次 accept / recv_nb / send / close / 重建三件套的返回处调用钩子,uiTask 注册task_wd_beat(TASK_WD_UI)。效果 = 心跳间隔从「整轮往返之和」收敛到「单个往返」; 只要每次往返都会回来,UI 就不该被记停摆。往返本身不改(有界化/请求序号 =docs/任务规划/README.md的跨上下文收口条目,批 3;§11.6 否决过的 「给 net_* 裸加超时」不重演)。 - 分层:Drivers/BSP 不得 include App 头(layer-deps),故心跳经回调注入而非直调;
钩子内只做置位类动作、不阻塞(契约写在
Drivers/BSP/UI/ui_net_sink.h)。 - 零行为变化面:钩子未注册(模拟器 / 黄金回归 / start 之前)= 与 F3 之前逐字一致;
协议、帧格式、门控、兜底节拍全部不动。回归钉 =
test/host/ui_sink/(17 断言: 未注册全协议路径 / 门控零 RPC 零心跳 / 每往返恰好一次心跳 / 重建与 EOF 的 close 护栏不变)。 - 残留暴露(刻意不归本票):单个往返自身仍无上界(osMessageQueuePut(osWaitForever)
- xTaskNotifyWait(portMAX_DELAY)),netTask 若被单消息拖 >800ms,UI 位连击照成立—— 那是「判据抓对了人」,处置在 net 侧,不在心跳侧。