# UI 重构报告(P0 / P1 / P2 / P3-Toolbar) 日期:2026-10-28 分支:`UI_Refactor`(从 `dev` 切出;`dev` 又由 `main@df715a0` 切出) 所有提交**只在本地仓库**,`main` / `dev` 未受影响。计划见 `UI_Refactor_Plan.md`,台账见 `UI_Refactor_Progress.md`。 --- ## 1. 本次交付了什么 | 阶段 | 交付物 | 状态 | | --- | --- | --- | | **P0** | 入库离屏回归网 `Tools/uicheck`(62 项断言,`build.ps1` + `main.cpp`) | ✅ | | **P1** | UI 基础层 `Components/Shared/{Inc,Src}/UiLayout.*` + `UiWidget.*`(约 1 440 行含注释) | ✅ | | **P2** | `PlotView` 实现按关注点重分为**七个分片**;`PlotViewPaint.cpp` 9 080 → **4 913** 行(**−46%**) | ✅ | | **P3** | **Toolbar 已迁移到控件树**(新增 `UiButtonWidget`,公开 API 未变、逐像素 0 差异) | ✅(1/5 组件) | 五个提交: ``` 84a349f UI 重构 P3:Toolbar 迁移到控件树(新增 UiButtonWidget,逐像素 0 差异 + 功能断言) eb11e63 UI 重构:补充进度台账、阶段报告与共享记忆同步 ec3f93f UI 重构 P2 续:把布局与命中几何从绘制分片拆出(PlotViewPanels.cpp,逐像素 0 差异) b532046 UI 重构 P2:PlotViewPaint.cpp 按关注点分片(机械搬移,逐像素 0 差异) 8bdf48c UI 重构 P0+P1:新增布局/控件基础层与离屏回归网(对产品像素零影响) ``` --- ## 2. 关键数字 | 指标 | 重构前 | 重构后 | | --- | --- | --- | | `PlotViewPaint.cpp` | 9 080 行 | **4 913 行(−46%)** | | 最大源文件 | 9 080(`PlotViewPaint.cpp`) | 4 913(`PlotViewPaint.cpp`) | | `PlotView` 实现文件数 | 4 | **7**(职责单一) | | 自动化 UI 探针 | **0** | **62 项断言**(入库、可复跑) | | 已迁移到控件树的组件 | 0 | **Toolbar**(1 个,含 8 场景像素 A/B + 3 条功能断言) | | 产物 | 873 984 B | **881 664 B**(+7.7 KB,< 900 KB 阈值) | | 产品像素差异 | — | **0**(分片三场景 + 迁移八场景哈希逐一相同) | --- ## 3. 新增的 UI 基础层(P1) ### 3.1 `UiLayout`:布局原语 + 布局缓存 | 能力 | 说明 | | --- | --- | | 矩形原语 | `UiRectIntersect` / `UiRectUnion` / `UiRectDeflate` / `UiRectHasArea` | | **设备像素内侧对齐** | `UiRectSnapInward`(上界 `floor`、下界 `ceil`)—— 离屏位图贴图源矩形必须用它,否则补丁会多算一行盖到相邻网格(历史 1px 接缝 BUG) | | 对齐 | `UiAlignInRect`(BEGIN/CENTER/END/**STRETCH**、超尺寸夹取、偏移后置) | | Stack | `UiStackExtent` / `UiStackRects`(固定尺寸 + `kUiSizeAuto` 均分剩余、**绝不产生负尺寸**) | | Flow | `UiFlowRects`(换行 + `overflow` 下标,供图例 `+N` 使用) | | Abs | `UiAbsRect`(绘图区叠加物的主力布局) | | 缓存 | `UiLayoutCache`:`std::deque` 保证引用稳定、**定向失效**、按"最久未命中"淘汰 | > 设计取舍:**不实现 Grid**(绘图区不是表格,用不上);**Card 只是皮肤**不是布局; > 真正需要的是 **Abs(主力)/ Anchor / Stack / Flow / Measure-driven**。 ### 3.2 `UiWidget`:控件树基类 ```mermaid flowchart TD H[UiHost(P5)
共享 DIB · 脏区并集 · 一次回送 · 动画 tick] H --> R[UiWidget 根] R --> A[普通控件:直接画进共享缓冲] R --> B[高成本叶子:自持位图,完成即 BitBlt] A --> S[SurfaceView{dc, rect, stride, bits, version}] B --> S ``` 已实现的契约: | 契约 | 内容 | | --- | --- | | 两段式布局 | `Measure`(自下而上、带缓存、失效只沿祖先链)/ `Arrange`(自上而下、回调 `OnArrange` 摆子节点) | | 脏区下探绘制 | `dirty` 为空 = 全画;**与子树矩形无交集 ⇒ 整棵子树 return**;有效裁剪 = 祖先裁剪切 ∩ 自身 ∩ 脏区 | | 裁剪契约 | 进入时收紧 GDI+ 裁剪并暴露 `ctx.clip`;**GDI 绘制不受其约束** ⇒ 派生类必须把 `ctx.clip` 传给 `UiTextClipped` 一类辅助函数 | | 事件派发 | capture(拖拽期直投)→ modal(只向模态子树)→ **只沿命中链**(最深命中者 → 祖先兜底);键盘沿焦点链;`{consumed, redraw, dirty}` | | 交互仲裁 | `CaptureMouse` / `ReleaseMouse`(仅持有者可释放)/ `SetModal` / `SetHitTransparent` / `RequestFocus` | | 光标链 | `QUERY_CURSOR` 由最上层认领者返回,未认领交给宿主 | | 动画 | `AdvanceAnimation(nowMs)` 逐节点下发同一时间戳并汇总"是否仍有动画" | | 缓存键 | `ContentKey()`(派生类提供)+ `BaseContentKey()`(矩形/可见性),父层只聚合 | **三条禁区(写进 `agent_mem.md` §11.2 第 235–240 条)** 1. 波形数据→像素管线(网格/曲线/采样点)**不做成控件节点**; 2. **不是每个控件自持位图**(内存按面积线性增长:128 网格已 ≈250 MB 量级); 3. 禁止"父脏 ⇒ 子树全脏"(会把"只重绘一个网格/面板"打回整块重绘)。 --- ## 4. 分片重构(P2) ### 4.1 七个分片 | 文件 | 行数 | 职责 | | --- | --- | --- | | `PlotViewPaint.cpp` | 4 913 | 绘制(`Paint*` / `Render*` / 输入框绘制) | | `PlotViewInteraction.cpp` | 3 430 | 命中、鼠标/键盘、光标形状、面板层序 | | `PlotViewFeatures.cpp` | 3 266 | 光标读数、提示、缩放、自适应、派生曲线、边沿统计 | | `PlotViewRaster.cpp` | 1 876 | 光晕层 / 场景缓存 / 装饰层 / 宽基底图层与预算 | | `PlotViewPanels.cpp` | 1 359 | 面板布局、图例徽标几何、自由标记取值与探测 | | `PlotView.cpp` | 889 | 视图核心(构造、布局、坐标映射、网格结构) | | `PlotViewCacheKey.cpp` | 474 | 内容指纹与缓存键 | ### 4.2 为什么能"零风险"搬移 - `PlotViewInternal.hpp` 里 `pvdetail` 的工具函数**全部 `inline`** ⇒ 跨 TU 安全的; - 所有搬移的都是**成员函数定义**,声明仍在 `PlotView.hpp`; - 因此**不需要改任何 include,也不需要改 `Makefile`**(按 basename 自动收集 `.cpp`)。 ### 4.3 验证方式(重点) **不是"能编译就算过"**,而是**逐像素 0 差异**: | 场景 | 内容 | | --- | --- | | A | 默认全览(4 条信号) | | B | 深放大 0.90–1.10 + 自由标记(含固定点、透明度 0.8)+ 均值滤波(点数 2 / 迭代 1)+ 弱峰调整(60% / 2 次)+ 时间光标 | | C | 在 B 基础上再平移一帧(触发宽基底图层滚动/补条路径) | 渲染后备缓冲区后取 FNV 哈希: ``` 迁移前 A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 迁移后 A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 ← 三场景完全一致 ``` 哈希与 `Tools/uicheck` 都在每次搬移后重跑;`make release` / `make debug` 零警告。 --- ## 5. 过程中修掉的自身缺陷 | # | 缺陷 | 修法 | | --- | --- | --- | | 1 | `UiWidget` 头文件漏声明 `AdvanceAnimationSelf` / `AnimationActiveSelf`(`.cpp` 已引用) | 补 protected 虚函数 | | 2 | 最初的派发是"遍历同层兄弟、未消费就继续" ⇒ **一次点击可能被视觉上位于下方的控件也处理** | 改为"只沿命中链派发 + 祖先兜底" | | 3 | 探针用**绝对次数**断言事件派发次数(被前面用例的计数污染) | 全部改成**增量断言** | | 4 | 分片后回退做 A/B 时,`Build//` 残留 `PlotViewRaster.o` / `PlotViewCacheKey.o`,链接探针因重复符号失败 | 记录为复现步骤的硬要求:先删陈旧 `.o` | 另外顺手清理:`agent_mem.md` 侧新增 §11.2 第 235–240 条 + §10 变更日志行; `documents/refactor_report_2026-10-16.md`(过时报告)在 `dev` 分支删除,结论已并入 `agent_mem.md`。 --- ## 6. P3:Toolbar 迁移到控件树(真实组件落地) 这是第一个**真正换成控件树**的现有组件(不是新增代码,而是替换旧实现),公开 API 一字未改。 | 旧实现 | 新实现 | | --- | --- | | `Toolbar::Button` 同时存配置、矩形、悬停、按下 | `Button` 只存**静态配置 + 控件指针**;几何与外观状态归控件 | | `m_hoverIndex` / `m_pressedIndex` 手工维护 | 由 `UiWidget` 的**悬停链**与 capture 维护 | | `Paint` 自己遍历按钮逐项绘制 | 根控件(底色 + 底部分隔线)+ 按钮子节点,一次 `root->Paint(ctx)` | | 四个鼠标函数各自扫按钮矩形 | 统一翻译 `UiEvent` → `root->Dispatch()`;动作经 `onClick` 登记、抬起后执行 | | 死字段 `Button::hover`(从未赋值) | 删除,改用基类 `Hovered()` | 新增 `UiButtonWidget`(`Components/Shared`):主按钮/普通按钮/可切换/禁用四套配色与旧实现逐像素一致; 按下即 `CaptureMouse()`,"在自身范围内松开才算点击",指针离开时 `CancelPress()`。 **框架约定调整**:`UiWidget::Paint` 的裁剪由"严格等于自身矩形"改为**"自身矩形外扩 `kUiClipBleed`(1px)"** —— 边框以矩形边界为中心绘制,严格裁剪会切掉外半侧、控件变细(与旧代码不再像素一致)。 **验证(两类,缺一不可)** 1. **像素**:8 个场景(全览 / 深放大+自由标记+滤波+弱峰+光标 / 平移 / 悬停按钮 / 按下 / 松开 / 悬停主按钮 / 移出工具栏)哈希迁移前后**逐一相同**。 2. **功能**(证明"点击真的翻译成动作",而不是"看起来没坏"): * `locate-cursor-button PASS (1291,73)` —— 用控件自身矩形定位「光标」按钮; * `click-cursor-changes-mode PASS (1 -> 2)` —— 点击后光标模式 单→双; * `release-outside-does-not-fire PASS (2 -> 2)` —— 按下后移出再松开,不触发动作。 --- ## 7. 明确未做的事(避免误解) | 未做 | 原因 | | --- | --- | | 迁移 TabBar / PopupMenu / ColorPicker / SignalPanel 行 | 其余 chrome 组件:需逐个迁移 + 逐组件 A/B(Toolbar 已示范套路) | | `UiHost` + 共享 32bpp DIB + `SurfaceView` | 属 P5;需同时把 56 处 `InvalidateRect` 收敛成脏区并集,必须与迁移同批验证 | | 引入多线程光栅 | 本项目实测 **1.03x**(GDI+ 内部串行),且有"后台改写视界覆盖用户平移"的历史事故 | | 每控件独立位图 | 内存按面积线性增长(128 网格 ≈250 MB) | | 删除 1 295 行硬核"为什么"注释 | 它们是防回归的核心资产,行数不是问题 | --- ## 8. 下一步建议(按收益/风险排序) 1. **P3 继:TabBar**(标签 + 「+」+ 重命名内联输入框)→ 再 `PopupMenu` / `ColorPickerPopup` 条目行。 判据同 Toolbar:**8+ 场景像素哈希一致 + 功能断言**(点击/悬停/取消、重命名提交与取消)。 2. **P4:浮动面板迁移**:`EdgePanelLayoutFor`(651 行)→ 真控件(Stack + Abs + Measure-driven), 同时消掉面板里 10 多个平行数组(`rowRefs/rowChecks/rowPoints/rowOrder/rowLevelTracks/...`)。 ⚠️ 每个面板单独一轮:吸顶/滚动/滚动条/滑块复位/行悬停压暗/开关联动是 6 轮补丁叠出的隐式约定。 3. **P5:共享 DIB + 脏区并集**(`CreateCompatibleBitmap` → top-down 32bpp `CreateDIBSection`, `SurfaceView` 带 `version` 作废机制)。 4. 每阶段一个可独立回滚的提交;`main` / `dev` 只接受"`uicheck` 全绿 + 像素 A/B 通过"的提交。 --- ## 9. 一句话结论 > 本阶段**首次把一个现有组件(Toolbar)真的换成了控件树**,并且同时以**像素哈希一致**与**功能断言**证明 > 行为未变;同时新增了布局/控件基础层、入库回归网,并把 9 080 行的"万能绘制文件"降到 4 913 行(−46%)。 > 后续每个组件迁移都能复用同一套路(`uicheck` + 像素 A/B + 功能断言), > 从而把"AI 写得快"转换成"你敢合多少"。