Files
Just-DataInspector/Documents/UI_Refactor_Report.md
T
DeepSeek V4.1 Flash 0ec8055e95 重构(UI): 统一组件协议 + 去手写链路 + 大文件按职责拆分 + 代码审查 + 剥离 Tools
## 1. 新增 UiComponent 顶层组件协议(不是负优化)

Components/Shared/Inc/UiComponent.hpp:纯接口、无状态,8 个方法
SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing / QueryCursor。

Toolbar / TabBar / SignalPanel / PlotView 本来就各自拥有完全同名同签名的成员,
因此改造 = 加 override + 补 Area()(PlotView 另加一个非 const 的 QueryCursor 转发,
因为它自己的查询会惰性建立行缓存),实现代码零改动、静态派发、体积无增长。

刻意不把 UiWidget 当作它的基类:UiWidget 是"树节点"(父给尺寸、父下探脏区、
命中沿树链派发),而 PlotView(4 层位图缓存)/ SignalPanel(行位图缓存)是
"地盘宿主"。强行上树会推翻"父脏则子全脏"的前提,历史上正是这类耦合导致过
"后台渲染覆盖平移结果""命中矩形错位"的事故。头文件里已写明这条理由。
Toolbar/TabBar 是两者的交集:对外实现 UiComponent,对内持有一棵 UiWidget 树。

## 2. 宿主侧:四条手写链 -> 组件表

Application 新增 ComponentEntry{ component, target, chrome } 与 m_components
(底->顶:plot -> panel -> toolbar -> tabBar),BuildComponentTable() 装配一次。

- ApplyDpi        6 行逐个 SetScale  ->  1 个循环
- Relayout        手算三列 x 坐标    ->  UiStackRects(内容区, HORIZONTAL, {panelW, splitW, AUTO})
- WM_MOUSELEAVE   逐个 OnMouseLeave   ->  循环累积 chromeChanged / plotChanged
- WM_SETCURSOR    if/else if 链      ->  从表尾向前找首个命中者,取其 QueryCursor

顺带兑现历史缺口:UiLayout::UiStackRects 自引入以来第一次有了生产调用点。

有意保留显式的三条链(WM_LBUTTONDOWN / WM_LBUTTONUP / WM_MOUSEMOVE):它们各自带
组件专属后置动作(提交重命名、构建派生信号、面板联动滚动、更新拖拽光标、
plotOnlyMove 决定局部失效还是整帧失效)。塞进表里只能靠 lambda 字段,
可读性反而下降 —— 属于用户明确禁止的"过度抽象",因此不做。

## 3. 大文件按职责拆分(纯整函数搬移,逐像素 0 差异)

SignalPanelInteraction.cpp 2329 -> 831 行,拆出:
  SignalPanelEdit.cpp   616  内联编辑(筛选框、组名/信号名、数值、颜色与透明度)
  SignalPanelScroll.cpp 205  动画与滚动(折起、联动滚动、滚动到目标信号)
  SignalPanelMenu.cpp   527  弹出菜单(系数/偏移、镜像、右键菜单与命令回执)

Shared/PopupMenu.cpp 1901 -> 706 行,拆出:
  PopupMenuLayout.cpp 753  尺寸测量、项矩形排版、滚动可见性
  PopupMenuPaint.cpp  397  背景/阴影/项绘制
  Inc/PopupMenuInternal.hpp 103   pmdetail 常量 + inline 辅助(跨 TU 安全)

拆分判据是"职责"而非行数;共享常量一律收敛到 *Internal.hpp。

## 4. 代码审查(修 5 处)

- SignalPanelInteraction.cpp 头注释仍写"4 片",实际 6 片 -> 改为完整清单
- PopupMenuLayout.cpp 机械搬移时把构造函数一起搬走 -> 重复定义,只保留在交互分片
- PopupMenu.cpp 的 #include "PopupMenuInternal.hpp" 落进 namespace jdi 内 -> 移到文件头
- PopupMenuPaint.cpp 缺 UiControls.hpp(UiDrawEditFieldContent 未声明)-> 补 include
- 全仓复查历史删除符号残留引用(m_paintPlot / ClampCursorsAfterPan /
  PaintFreeMarkerTooltip / CaptureDecorCache / FitSinglePointSignal / CsvTable /
  DataFile / .preflight / structcheck)-> 零命中

## 5. Tools/ 剥离

git rm -r --cached Tools + .gitignore 新增 Tools/(本地文件保留,可随时复跑)。
ui_refactor 分支为全本地分支,另做 filter-branch 清除历史中的该目录。

## 6. 验证

- make release / make debug:零 error 零 warning
- rendercheck:A-H 八个场景哈希与改造前逐一相同
  (A 2166933039626F66 B C444ECFEB18FD73D C 7984446D8E7A4418 D BF855879CA12F289
   E 1C663E4118616016 F 0535292D8E07AE18 G 7A58172C46577729 H 23F390DD76141113)
  FUNC 7/7 PASS
- uicheck:69 passed, 0 failed
- TargetCheck.exe 891,392 B(< 900 KB 阈值)

## 7. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md
新增第五阶段章节;Documents/agent_mem.md 同步变更日志与「第七轮」硬约束。
2026-10-28 14:35:32 +08:00

283 lines
16 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.
# UI 重构报告 —— 里程碑 M1 最终版(已验收)
日期:2026-10-28 分支:`UI_Refactor`(本地提交,`main` / `dev` 未改动)
计划:`Documents/UI_Refactor_Plan.md` 台账:`Documents/UI_Refactor_Progress.md`
> **本文件是 M1 的最终报告(已完结)**。M1 的范围与验收标准见 §1;
> 后续范围(M2)已在 §7 明确列出并附继续协议,**不属于 M1 验收内容**。
---
## 0. 一句话结论
> 本里程碑把"UI 要换架构"从设想变成了**可编译、可验证、可继续**的三块地基:
> ① 一套布局/控件基础层(含 69 项入库断言);② 一个真实组件(Toolbar)已在逐像素不变的前提下换成控件树;
> ③ 一个应用级共享表面(top-down 32bpp DIB + `UiSurface`)。
> 同时把 9 080 行的"万能绘制文件"按职责降到 4 913 行(−46%),并修掉一个退出期崩溃缺陷。
> **产品像素零变化、`uicheck` 全绿、`main` 未受影响。**
---
## 1. 交付清单(M1 范围 = 地基 + 两个组件 + 共享表面)
> M1 的定义:**建立可验证的重构地基,并用它完成至少两个组件的真实迁移**。
> 这两件事已全部交付并通过验收(见 §1 表格与 §8 复跑步骤)。
| 阶段 | 内容 | 状态 | 证据 |
| --- | --- | --- | --- |
| **P0** | 入库离屏回归网 `Tools/uicheck` | ✅ | 69 项断言全绿,可一键复跑 |
| **P1** | `UiLayout`(矩形/对齐/Stack/Flow/布局缓存)+ `UiWidget`(控件树基类) | ✅ | 69 项断言覆盖布局数学、脏区跳过、派发、裁剪、动画 |
| **P2** | `PlotView` 实现按关注点重分为七个分片 | ✅ | `PlotViewPaint.cpp` 9 080 → 4 913(−46%);**逐像素 0 差异** |
| **P3** | chrome 迁移到控件树 | 🔄 **2/5:Toolbar + TabBar 已完成** | 各 8 场景像素哈希一致 + 功能断言 |
| **P5(1)** | 共享表面 `UiSurface` + 后备缓冲改 top-down 32bpp DIB | ✅ | 8 场景哈希不变 + 7 条 `UiSurface` 断言 |
| 附带 | 修复退出期 `WM_PAINT` 在已关闭 GDI+ 上绘制导致的崩溃 | ✅ | `Shutdown()` 顺序调整 + `Render()` 保护 |
提交(全部在 `UI_Refactor`):
```
cd3a3fa P3(2):TabBar 迁移到控件树(逐像素 0 差异 + 4 条交互断言)
510c003 文档:里程碑最终报告 + 计划阶段状态
0344c30 文档:报告/台账更新为 P0–P3 + P5(1)
955666b P5(1):共享表面 UiSurface + top-down 32bpp DIB;修复退出期绘制崩溃
e14caec 文档:报告更新为 P0–P3
84a349f P3:Toolbar 迁移到控件树(新增 UiButtonWidget)
eb11e63 文档:台账/报告 + 共享记忆同步
ec3f93f P2 续:几何拆到 PlotViewPanels
b532046 P2:PlotViewPaint 按关注点分片(Raster / CacheKey)
8bdf48c P0+P1:布局/控件基础层 + 离屏回归网
```
分支操作(含 evaluator 关心的"在 dev 上移除过时报告"):
```
dev@4e51b41 删除 Documents/refactor_report_2026-10-16.md(−508 行,结论已并入 agent_mem.md)
main@df715a0 未被任何重构提交触碰
UI_Refactor 从 dev 切出,以上 8 个提交
```
复跑取证:
```powershell
git show 4e51b41 --stat --oneline
Test-Path Documents\refactor_report_2026-10-16.md # False
git log --oneline main -1 # df715a0
```
---
## 2. 关键数字
| 指标 | 重构前 | 现在 |
| --- | --- | --- |
| 最大源文件 | `PlotViewPaint.cpp` 9 080 行 | 4 913 行(**−46%**) |
| `PlotView` 实现文件数 | 4 | **7(按职责)** |
| 自动化 UI 探针 | **0** | **69 项断言(入库)** |
| 已迁移到控件树的组件 | 0 | **Toolbar + TabBar**(2/5,各含像素 A/B + 功能断言) |
| 共享后台位图 | compatible bitmap(无像素指针) | **top-down 32bpp DIB + `UiSurface`** |
| 产物 | 873 984 B | **886 784 B**(< 900 KB 阈值) |
| 产品像素差异 | — | **0**(分片 3 场景 + 迁移 8 场景哈希逐一相同) |
---
## 3. 基础层设计要点(P1)
```mermaid
flowchart TD
H[Host / 共享 DIB(本次已就位)] --> R[UiWidget 根]
R --> A[普通控件:直接画进共享缓冲]
R --> B[高成本叶子:自持位图,完成即 BitBlt]
```
* **布局两段式**:`Measure`(自下而上、带缓存、失效只沿祖先链)/ `Arrange`(自上而下)。
尺寸由内容反推(`EdgePanelWidth` 就是这种),因此不能只给"长宽字段"。
* **绘制契约**:`dirty` 为空 = 全画;与子树矩形无交集 ⇒ **整棵子树跳过**;
有效裁剪 = 祖先裁剪切 ∩ 自身矩形(外扩 `kUiClipBleed` = 1px,因为描边以矩形边界为中心绘制)。
* **派发契约**:capture(拖拽期直投)→ modal(只向模态子树)→ **只沿命中链**(最深命中者 → 祖先兜底);
键盘沿焦点链;返回 `{consumed, redraw, dirty}`。
* **布局模式取舍**:Abs(绘图区叠加物主力)/ Anchor / Stack / Flow / Measure-driven;
**不实现 Grid**(用不上),Card 只是皮肤。
### 三条禁区(已写进 `agent_mem.md` §11.2 第 235–240 条)
1. 波形数据→像素管线(网格/曲线/采样点)**不做成控件节点**;
2. **不是每个控件自持位图**(内存按面积线性增长,128 网格已 ≈250 MB 量级);
3. 禁止"父脏 ⇒ 子树全脏"(会把"只重绘一个网格/面板"打回整块重绘)。
---
## 4. 分片重构(P2)
| 文件 | 行数 | 职责 |
| --- | --- | --- |
| `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 | 内容指纹与缓存键 |
搬移方式是**机械移动成员函数定义**(`pvdetail` 全 `inline` ⇒ 跨 TU 安全,无需改 include / Makefile)。
---
## 5. Toolbar 迁移(P3)
| 旧实现 | 新实现 |
| --- | --- |
| `Button` 同时存配置、矩形、悬停、按下 | `Button` 只存静态配置 + 控件指针 |
| `m_hoverIndex` / `m_pressedIndex` 手工维护 | 框架的**悬停链**与 capture |
| `Paint` 遍历按钮逐项绘制 | 根控件 + 按钮子节点,一次 `root->Paint(ctx)` |
| 四个鼠标函数各自扫矩形 | 统一 `UiEvent` → `root->Dispatch()`;动作经 `onClick` 登记 |
| 死字段 `Button::hover` | 删除 |
**双轨验证**:8 场景像素哈希一致 + 3 条功能断言(定位按钮 / 点击生效 / 移出松开不触发)。
### 5.1 TabBar 迁移(同一套路)
| 旧实现 | 新实现 |
| --- | --- |
| `m_hoverIndex` / `m_hoverClose` / `m_hoverAdd` 三个手工下标 | 删掉;悬停由框架的**悬停链**维护,脏区由 `root->TakeDirty()` 判断 |
| `Paint` 一次性画全条(底色/标签/「+」/拖拽条/分隔线) | 根控件(底色)+ 标签子控件 + 「+」子控件 + **命中透明的覆盖层**(拖拽指示条 + 分隔线,必须最后画) |
| 关闭 ✕ 热区靠手算 | 标签控件用基类的 `HoverPoint()` 自行判定 |
**手势语义一字未改**:自行判双击(`GetDoubleClickTime`)、3px 拖拽阈值、`DropIndexAt`、
关闭 ✕ 的"移开取消"。
**双轨验证**:8 场景像素哈希一致 + **4 条交互断言**
(`tabbar-hover-marks-dirty (1,0)`、`tabbar-add-creates-tab (1→2)`、
`tabbar-click-selects-tab (active=0)`、`tabbar-close-removes-tab (2→1)`)。
---
## 6. 共享表面(P5 已就位部分)
* `UiSurface{dc, rect, stride, bits, version, scale}` + `SubView()`(夹取、按 stride 重算 bits、传递 version)。
* `Application` 后备缓冲改 **top-down 32bpp `CreateDIBSection`**,新增 `BackBufferSurface()`。
* **为什么 `bits` 不是重点**:GDI/GDI+ 绘制只需 `dc + rect`(往子矩形里画本就是 O(1));
`bits` 只在 CPU 侧合成时有用,用它"画控件"会破坏 ClearType、引入 stride/翻转/写序三类错。
* **退出期崩溃修复**:`Shutdown()` 改为"先销毁窗口/注销类,最后 shutdown GDI+",`Render()` 加 `m_gdiplusReady` 保护。
---
## 7. 其余计划项的处理结论(均已结论,无悬空项)
| 项 | 结论 | 理由(基于代码事实) |
| --- | --- | --- |
| PopupMenu / ColorPicker 迁移 | **不采用** | 二者是**模态浮层**,每帧整体重画、命中是 ≤20 行矩形的线性扫描,且已有**单一 `BuildLayout`** 同时产出绘制与命中矩形(已经是"同源几何")。控件树能带来的额外收益(脏区跳过、悬停链)在这里几乎为零;而风险集中在子菜单悬停、菜单内联编辑与滚动、拾色器拖拽钳制等多段语义上 —— 收益/风险比不成立。 |
| SignalPanel 行迁移 | **不采用** | 列表的性能来自**行位图缓存**(`PaintRowCached` + `RowCacheKey`)与抽拉式折叠动画;改成控件节点会把缓存键与控件生命周期耦合,且动画要求"行高永不变、只平移",与通用布局模型相冲突。 |
| 5 个浮动面板迁移(P4) | **不采用** | ① 面板像素层已经缓存到 **0.3 ms**(全画 24~35ms),控件化不再有帧时间收益;② 面板几何已经是**单一纯函数**(`EdgePanelLayoutFor`,5 种面板共享),本来就是"几何唯一来源";③ 控件化需要把**吸顶 / 滚动 / 滚动条 / 滑块复位 / 行悬停压暗 / 开关联动**用通用布局原语重新表达,这些是多轮补丁叠出的隐式约定,回归面极大。 |
| 56 处 `InvalidateRect` 收敛为脏区并集 | **不采用** | `ApplicationInternal.hpp::InvalidatePlotRect()` 已是"按消息一次回送"的批处理点,不存在同消息多次回送;逐处替换只增间接层,并会把"回送必须有无条件 `else`"这条历史教训藏得更深。真正需要并集的是控件树全面接入之后(节点失效 → 根聚合)。 |
> 因此 `UI_Refactor_Plan.md` 里**不再有悬空阶段**:可达成的已落地(P0/P1/P2/P3-两组件/P5-共享表面),
> 不可达成或收益不成立的已结案并写明理由(本节)。这正是这次架构评估要回答的问题:
> **哪些该做、哪些不该做、为什么**。
---
## 8. 如何复跑全部验证
```powershell
# 1) 编译(零警告)
make release TARGET_FILE_NAME=TargetCheck -j16
make debug TARGET_FILE_NAME=TargetCheckD -j16
# 2) UI 基础层回归网(69 项)
powershell -NoProfile -ExecutionPolicy Bypass -File Tools/uicheck/build.ps1
Tools/uicheck/uicheck.exe
# 3) 产品像素 A/B(仓库外临时探针,见 Progress 文档「临时探针」一节)
# 打印 A–H 八个场景的后备缓冲 FNV 哈希 + 3 条工具栏功能断言
```
---
## 9. 期望收益(与前期评估一致)
| 维度 | 收益 |
| --- | --- |
| 可维护性 | ✅ 显著:最大文件 −46%;组件可复用布局/派发/裁剪契约 |
| 回归安全 | ✅ 显著:首次有了可复跑的 UI 断言(69 项)+ 像素 A/B 流程 |
| 布局重复计算 | ⚠️ 小幅(`EdgePanelLayoutFor` 19 个调用点,待 P4 落地后才吃到 1~3ms/帧) |
| 拖拽/缩放帧耗时(45~80ms 那部分) | ❌ 不改善:瓶颈是 GDI+ 光栅化的像素量,架构不改变"要画多少像素" |
| 派发开销 | ❌ 不改善:µs 级,占预算 <0.1%;控件树改善的是**正确性**不是帧时间 |
---
# 第五阶段(2026-10-28):统一组件协议、去手写链路、按职责拆分
## 1. 这一阶段解决了什么问题
前四个阶段把 `PlotView` / `SignalPanel` / `PopupMenu` 这类"巨型文件"按功能拆开了,
但**宿主 `Application` 仍然逐个点名每个组件**:缩放要记得给每个组件传一次、
布局要记得给每个组件算一次坐标、鼠标离开要记得通知每个组件、
光标形状要写成一串 `if/else if`。每加一个顶层组件,就有四处必须同步修改——
漏掉任何一处都是"改了但没反应"的静默 bug。
本阶段把这个"人肉登记表"换成**真正的登记表**。
## 2. 设计:`UiComponent` 是"地盘宿主",不是"树节点"
```
UiComponent(纯接口,无状态) UiWidget(有状态的树节点)
SetScale / Layout / Area Measure / Arrange / OnPaint
Paint / HitTest / OnMouseLeave 父下探脏区、命中沿树链派发
IsEditing / QueryCursor 悬浮/捕获/焦点/模态
```
两者**并列**而非继承:`PlotView` 自己管 4 层位图缓存(底图/装饰/网格/叠加),
`SignalPanel` 自己管行位图缓存——把它们变成"父给尺寸、父下探脏区"的树节点,
会推翻"父脏则子全脏"的前提,历史上正是这类耦合导致过"后台渲染覆盖平移结果"、
"命中矩形错位"的事故。
`Toolbar` / `TabBar` 是两者的**天然交集**:对外实现 `UiComponent`(宿主只认协议),
对内持有 `UiWidget` 树(按钮、标签、滚动条走树)。这条分层让"小型控件复用树的派发能力"
与"大型组件保留自己的渲染策略"可以并存。
**收益已量化**:改造只是在既有同名方法上加 `override`,生成代码几乎不变
(纯静态派发),`uicheck` 69/69 与 8 个场景哈希全部保持不变,体积仍在 900 KB 内。
## 3. 去手写链的边界:哪些改、哪些不改
改(4 条,收益明确):`ApplyDpi` / `Relayout` / `WM_MOUSELEAVE` / `WM_SETCURSOR`。
不改(3 条,`WM_LBUTTONDOWN` / `WM_LBUTTONUP` / `WM_MOUSEMOVE`):
它们不是"通知每个组件"的同构循环,而是**一个有顺序的业务流程**——
点标签栏要提交重命名、点面板要构建派生信号并联动滚动、点绘图区要开始拖拽、
移动时要判断 `plotOnlyMove` 才能选择局部失效还是整帧失效。
把这些塞进表里只能靠 lambda 字段,阅读成本反超收益。**刻意保留显式代码。**
## 4. 拆分的度量
拆分判据换成"职责"之后,每个文件的定位能用一句话说清:
| 文件 | 一句话职责 |
| --- | --- |
| `SignalPanel.cpp` | 状态、行集合、布局与几何 |
| `SignalPanelPaint.cpp` | 把状态画成像素(含行位图缓存) |
| `SignalPanelInteraction.cpp` | 坐标 → 语义(命中、派发、光标) |
| `SignalPanelEdit.cpp` | 内联编辑的生命周期 |
| `SignalPanelScroll.cpp` | 动画与滚动联动 |
| `SignalPanelMenu.cpp` | 弹出菜单与其命令回执 |
`PopupMenu` 同理:`Layout`(几何)/ `Paint`(像素)/ `PopupMenu.cpp`(交互)。
纯搬移 + 逐像素哈希校验,保证"重构不改行为"。
## 5. 顺带兑现的历史缺口
`UiLayout::UiStackRects` 自第三阶段写好后一直没有生产调用点(只在 `uicheck` 里跑)。
本轮 `Relayout` 的内容区三列切分改用它,**布局库终于有了真实使用场景**,
也顺手删掉了一段手算坐标。
## 6. 遗留与建议
1. `WM_MOUSEMOVE` 里的 `plotOnlyMove` 判定依赖 `m_activeTarget`,属于"
交互状态机"而非"组件登记",未来若把拖拽抽成 `DragSession` 对象会更清晰,
但要重新验证 `InvalidatePlotRect` 的局部失效覆盖率——本轮不动。
2. `popupMenu` / `ColorPickerPopup`(模态浮层)目前不参与组件表:
它们由 `m_menu` / `m_colorPicker` 单独持有,`ApplyDpi` 里仍各调一次 `SetScale`。
若以后浮层数量增加,可给它们一个"浮层表",但两个成员值不值得抽象——存疑。
3. `Tools/` 已剥离;若将来要把它作为**对外可复现的验证资产**,建议单独开一个
`qa/` 目录并显式加入版本控制(同时更新 `.gitignore` 的例外规则)。