Files
Just-DataInspector/Documents/UI_Refactor_Plan.md
T
DeepSeek V4.1 Flash c4faa930e2 UI 重构:生成里程碑最终报告,并在计划文档中标注阶段状态
- Documents/UI_Refactor_Report.md 重写为「本里程碑最终版」:
  交付清单表(P0/P1/P2/P3-Toolbar/P5(1) + 退出崩溃修复,逐项附证据)、八个提交清单、
  关键数字(4,913 行 / 69 项断言 / 883,200 B / 0 像素差异)、基础层设计要点与三条禁区、
  分片表、Toolbar 新旧对照、共享表面说明、未纳入本里程碑的三项(附继续协议)、
  可复跑的验证步骤与期望收益表。
- Documents/UI_Refactor_Plan.md:阶段表标注已完成的 P0/P1/P2、部分完成的 P3/P5。

验证(本次最终跑):make release / make debug 零警告;Tools/uicheck 69 passed / 0 failed;
8 场景渲染哈希与改造前逐一相同(A 2166933… H 23F390D…);3 条工具栏功能断言 PASS;探针退出码 0。
2026-10-28 12:37:44 +08:00

305 lines
18 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 重构计划(UI_Refactor 分支)
> 目标读者:本项目的后续 Agent(可能是另一台设备 / 另一个模型)。
> 本文是**唯一施工蓝图**;实时进度见 `Documents/UI_Refactor_Progress.md`,最终结论见 `Documents/UI_Refactor_Report.md`。
> 硬约束的权威来源仍是 `Documents/agent_mem.md`(§11.2);本文与之冲突时**以 agent_mem.md 为准**。
---
## 0. 为什么要重构(量化基线)
| 指标 | 现状 | 出处 |
| --- | --- | --- |
| `PlotViewPaint.cpp` | **9 080 行**(注释 1 295 / 空行 517 / 仅大括号 2 026 / **有效代码 5 242**) | `Get-Content \| Measure-Object` |
| `PlotView.hpp` | 3 649 行、**310 个方法**、**203 个成员变量**、31 个嵌套类型 | 同上 |
| `EdgePanelLayoutFor` | **651 行**、**19 个调用点**,每帧被命中/层序/光标/绘制各算一遍 | `Select-String` |
| `Application.cpp` 的 `InvalidateRect` | **56 处** + 手写 `m_chromeDirty` / `m_panelDirty` / `panelOnly` / `fullFrame` 组合 | 同上 |
| 动画 | **5 套**手写动画散在 3 个组件,共用一个 10ms 定时器 | 同上 |
| 自动化 UI 探针 | **0**(`.preflight` 已被删除且被 gitignore) | 仓库现状 |
`PlotViewPaint.cpp` 里混住四类内容:真绘制 ≈3.5k / 光栅化缓存 ≈2.5k / 缓存键 ≈1.1k / **布局与命中几何 ≈1.3k**。
**五条根因**
1. 上次拆 13 695 行大文件时按**函数名**("带 `Paint` 前缀")而非**关注点**分片 ⇒ 缓存、缓存键、面板布局、命中几何全被倒进 paint 分片。
2. 没有控件抽象 ⇒ 每个叠加层自带"几何 + 命中 + 绘制 + 缓存键"四份重复;5 个实验性面板各 800~1 200 行,靠 10 多个平行数组(`rowRefs/rowChecks/rowPoints/rowOrder/rowLevelTracks/...`)下标对齐"假装"成树。
3. GDI / GDI+ 双栈妥协被**每个绘制点重复支付**(GDI 文本不认 GDI+ 裁剪、`UiFillRect` 的 GDI 快路径绕过裁剪、GDI 文本忽略 alpha…)。
4. "必须混进缓存键,否则贴旧像素"这条不变量**没有归属**(4 套手写 key + 一堆 `Collect*` ≈1.1k 行;历史 ≥4 次真实 BUG 是"忘了混一个字段")。
5. 1 295 行硬核"为什么"注释让行数虚高 —— **保留,不删**(多 Agent 协作防回归的核心资产)。
---
## 1. 设计目标与非目标
### 1.1 目标(必须达成)
1. **几何只算一次**:布局是"按需失效的 pass",命中/绘制/光标共用同一份结果。
2. **失效从布尔变矩形**:`dirty ∩ bounds` 为空 ⇒ 整棵子树跳过(这是唯一真正省钱的层次)。
3. **裁剪契约统一**:`Paint` 进入时同时收紧 GDI 裁剪与 GDI+ 裁剪,根治"文字画到图例/工具栏上"这类已出现 3 次的 BUG。
4. **缓存键有归属**:由"持有该内容的节点"产生,父层只聚合。
5. **事件派发显式化**:`{consumed, redraw, dirtyRect}` 代替 `bool 要不要重绘`;capture / 模态 / hover / 焦点 / 光标仲裁由基类提供。
6. **动画统一 tick**:一个动画列表 + 现有 `kScrollTimerId` 推进。
7. **零行为变更的先行阶段**:先拆文件、补回归网,再动语义。
### 1.2 非目标(明确不做,写进禁区)
| 禁区 | 原因 |
| --- | --- |
| ❌ 波形数据→像素管线控件化(网格/曲线/采样点不做成节点) | 布局与虚调用会进每帧热路径;采样/顶点量与控件无关 |
| ❌ 每个控件自持位图 | 内存按面积线性增长:逐网格图层已是屏幕面积 2 倍(5 网格 ≈16MB,**128 网格 ≈250MB**) |
| ❌ "父脏 ⇒ 子树全脏" | 会把"只重绘一个网格/一个面板"打回整块重绘(历史最易发生的退化) |
| ❌ 多线程光栅 | GDI+ 内部串行,实测 **1.03x**;且后台改写 `m_area`/`ViewMin` 会覆盖用户平移(第一代已因此回退) |
| ❌ 逐控件细粒度像素缓存 | 画一个按钮 20~80µs vs 贴图 5~10µs ⇒ 净亏损,还要付"忘混键"的风险 |
| ❌ 引入属性对象体系(学 Blink) | 那是为 Blink 规模准备的;本项目用"节点产生 key"即可 |
### 1.3 借鉴来源与取舍
| 借鉴 | 采纳 | 说明 |
| --- | --- | --- |
| Chromium `views::View` 的**失效驱动布局 pass** | ✅ 全采纳 | 对应 §3 的 `UiWidget::Measure/Arrange` + `UiLayoutCache` |
| `views::View::PaintChildren` 的**脏矩形下探** | ✅ 全采纳 | 对应 §4 的 `PaintContext.dirty` |
| `SetPaintToLayer()` 只提升**少数**子树 | ✅ 保持现状量级(4 层) | 不再新增层 |
| `cc` 的**记录-光栅分离 / tiling / checkerboarding / 每帧预算** | ✅ 已具备,不改 | `PolylineCacheEntry`、宽基底+补条、贴过期但位置正确、`RenderPendingLaneRebuilds(1)` |
| `cc` 的**多线程光栅 / GPU 合成** | ❌ 不采纳 | GDI+ 无 GPU 路径;并行实测 1.03x |
| Dear ImGui 的**每帧全量重画** | ❌ 不采纳 | 本项目整幅重绘 ≈80ms,必须走失效 |
---
## 2. 目标架构
```mermaid
flowchart TD
H[UiHost<br/>持有 app-sized 32bpp top-down DIB<br/>z 序 · 脏区并集 · 一次 BitBlt 回送 · 动画 tick]
H --> R[UiRoot : UiWidget]
R --> D{脏区树<br/>dirty ∩ bounds 空 ⇒ 整棵子树 return}
D --> A[普通控件:直接画进共享缓冲<br/>按钮 / 文本 / 列表行 / 时间轴 / 图例]
D --> B[高成本叶子:自持位图,完成即 BitBlt 贴回<br/>波形网格 / 装饰层 / 光晕 / 浮动面板]
A --> S[SurfaceView{ dc, rect, stride, bits, version }]
B --> S
```
**分层原则**
- **共享一张缓冲**:省内存、免分配抖动、合成顺序天然确定。
- **只有高成本叶子自持缓冲**:这是"把重画换成贴图"的唯一手段。
- **脏区向下取交集**(不是并集)。
- **布局由下往上、每帧最多一轮 Measure**;尺寸由内容反推(`EdgePanelWidth` 就是这种)。
### 2.1 新增文件
| 文件 | 职责 | 依赖 |
| --- | --- | --- |
| `Components/Shared/Inc/UiLayout.hpp` / `Src/UiLayout.cpp` | 纯几何:`UiInsets`、栈/流/锚点/对齐、`UiLayoutCache` | 仅 `Theme.hpp`(`gd::RectF`) |
| `Components/Shared/Inc/UiWidget.hpp` / `Src/UiWidget.cpp` | 控件基类:树、Measure/Arrange、双栈裁剪 Paint、事件派发、失效、capture/模态/hover/焦点/光标、动画 tick | `UiLayout.hpp`、`PaintUtil.hpp` |
| `Components/Shared/Inc/UiHost.hpp` / `Src/UiHost.cpp` | 宿主:共享 DIB + `SurfaceView` + 脏区并集 + 回送 + 消息翻译 + 动画定时器 | `UiWidget.hpp` |
| `Tools/uicheck/`(入库) | 离屏回归网(`SW_HIDE` + 注入消息 + 从 `m_backDc` 取帧) | `Build/Release/*.o` |
> ⚠️ `Components/Shared` 已在 `Makefile` 的 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS` 中 ⇒ 新增 `.cpp` **无需改 Makefile**(按 basename 自动收集)。
---
## 3. 布局层设计(`UiLayout`)
### 3.1 为什么不是简单的"宽高字段"
基类若只有 `width/height`,会在三处立刻失效:
1. 所有坐标是**逻辑像素 × scale**(本机 DPR 1.25),控件必须知道缩放;
2. 面板宽度是**由内容反推**(`EdgePanelWidth` = 列宽 max + 行 ✕ + 滚动条预留);
3. 离屏位图要求**设备像素整数对齐**(`floor`/`ceil` 方向错了就留 1px 接缝 —— 样式补丁曾踩过)。
⇒ 采用 **`Measure(约束) → 期望尺寸` / `Arrange(最终矩形) → 实际矩形`** 两段式。
### 3.2 需要的布局种类(按实际用量排序)
| 布局 | 用途 | 备注 |
| --- | --- | --- |
| **Abs(绝对)** | 绘图区叠加物主力:光标线、取值胶囊、吸附环、越界三角、固定点、面板初始位置、滚动条 | 保留"自由布局",但它是**主力**而非备选 |
| **Anchor(四边贴合)** | 工具栏整体靠右、时间轴贴底、滚动条贴右、面板夹在 `m_plotRect` 内 | 讨论中缺失,必须补 |
| **Stack(竖向流)** | 信号列表行、分组行、面板条目(名称行 + 参数行 + 滑块行) | 需支持"行高由内容决定 + 吸顶(sticky) + 滚动视口" |
| **Flow(横向流 + 溢出)** | 图例条目 + 空间不足的 `+N`、工具栏按钮组 | 讨论中缺失,必须补 |
| **Measure-driven** | 面板宽度按实测文本撑开、列宽 `max(缺省, 内容, 列标题)` | 必须有,否则又回到"绘制侧省略号" |
| Grid | **用不上**(绘图区不是表格) | 不实现 |
| Card | 只是"面板皮肤" | 作为 `UiPanelSkin` 绘制样式,不是布局 |
### 3.3 `UiLayoutCache`(本次最关键的性能点)
`EdgePanelLayoutFor` 的 19 个调用点全部来自"同一份几何被反复问"。做法:
```cpp
class UiLayoutCache
{
public:
// key 由调用方提供(例如 laneIndex + kind + 面板宽 + 缩放 + 条目数)
const LayoutEntry* Find(std::uint64_t key) const;
const LayoutEntry& Store(std::uint64_t key, LayoutEntry&& entry);
void Invalidate(std::uint64_t key); // 定向失效
void Clear();
};
```
**硬约束**:`Invalidate` 必须由"改变几何的那条路径"**显式**调用(与 `m_edgeScans` 的定向失效同一纪律),**禁止**为了让缓存命中率好看而整体 `Clear()`。
---
## 4. 控件基类设计(`UiWidget`)
### 4.1 接口骨架
```cpp
struct UiEventResult
{
bool consumed = false; // 事件已被消费(不再下传/上抛)
bool redraw = false; // 需要重绘
gd::RectF dirty; // 需要重绘的矩形(客户区坐标;空 = 未知 ⇒ 取自身 bounds)
};
class UiWidget
{
public:
virtual ~UiWidget();
// ---- 树 ----
void AddChild(std::unique_ptr<UiWidget> child);
void RemoveChild(UiWidget* child);
const std::vector<std::unique_ptr<UiWidget>>& Children() const;
// ---- 布局(两段式,各自带失效)----
void InvalidateMeasure(); // 向上冒泡到根
void InvalidateArrange();
gd::SizeF Measure(const UiMeasureContext& ctx); // 自下而上,命中缓存则直接返回
void Arrange(const gd::RectF& bounds); // 自上而下;bounds 一定 > 0
// ---- 绘制(双栈裁剪 + 脏区下探)----
void Paint(UiPaintContext& ctx); // 内部:与 dirty 求交 → OnPaint → 递归子节点
virtual void Invalidate(const gd::RectF& rect); // 局部失效(向上冒泡并入)
// ---- 事件 ----
UiEventResult Dispatch(UiEvent& event); // 命中 → 子节点(反 z 序)→ OnEvent
// ---- 命中 / 光标 ----
bool Contains(int x, int y) const;
virtual HCURSOR QueryCursor(int x, int y) { return nullptr; }
// ---- 交互仲裁(由 Host 调用)----
UiWidget* Capture(); void SetCapture(); void ReleaseCapture();
void SetModal(bool modal); // 模态子树:Host 只向它派发
void SetHitTransparent(bool value); // 装饰元素:不参与命中
// ---- 动画 ----
virtual bool AdvanceAnimation(std::int64_t nowMs) { return false; }
protected:
virtual gd::SizeF OnMeasure(const UiMeasureContext& ctx) = 0;
virtual void OnArrange() {}
virtual void OnPaint(UiPaintContext& ctx) = 0;
virtual UiEventResult OnEvent(UiEvent& event) { return {}; }
virtual std::uint64_t ContentKey() const; // 缓存键的来源(父层只聚合)
};
```
### 4.2 `Paint` 的硬契约(必须由基类保证,不是"每个控件记得")
1. **双栈裁剪**:进入时 `SaveDC + IntersectClipRect`(GDI)+ `Graphics::SetClip(Intersect)`(GDI+),退出时还原。这一条一次性消灭"滚动时文字串到标题栏""全选高亮溢出输入框""选区画到工具栏上"。
2. **脏区下探**:`ctx.dirty = ctx.dirty ∩ bounds`;若为空 ⇒ **整棵子树直接 return**(不调 `OnPaint`、不遍历子节点)。
3. **绘制顺序 = 子节点顺序 = z 序**;命中/事件 = **反 z 序**(与现在 `m_panelOrder` + `TopPanelAtPoint` 反向遍历等价,但不再靠人工维护)。
4. **禁止**在 `OnPaint` 内改 `bounds`/子节点集合(会与本帧的 dirty 不一致)。
### 4.3 事件派发的硬契约
- **`consumed`** 决定是否上抛;**`dirty`** 参与本帧脏区并集。
- **capture**:抓取期间所有鼠标事件**直接投给抓取者**(跳过命中),与 Windows `SetCapture` 语义一致。本项目硬约束:中键平移与左键拖拽**共用同一个 capture**,松左键**不能**无条件 `ReleaseCapture()`。
- **模态**:菜单打开时 Host 只向模态子树派发,且"取消底层拖拽"时必须**排除菜单自己的 `EDIT_FIELD`**。
- **hover**:Host 维护 hover 链,跨节点 `leave/enter`;悬停是**跨组件**的(面板行 → 网格固定点高亮;列表行 → 曲线光晕)⇒ 通过 Host 级"悬停信号总线"广播,不靠父子关系。
- **双击消歧**:窗口带 `CS_DBLCLKS`;没有双击语义的位置必须在 `DoubleClickIsPlainPress()` 返回 true,由 Host 改走普通按下(否则"连点丢一半")。
### 4.4 失效契约
- 节点持 `m_dirty`(自身坐标系)+ `m_dirtyChildren`;
- `Invalidate(rect)` 并入自身并**向上冒泡**(父层只并集矩形,**绝不**把子树的 dirty 清零);
- Root 暴露 `TakeDirty()` 给 Host,Host 再与窗口更新区域取并集。
---
## 5. 宿主层设计(`UiHost`)
1. **单一 app-sized 32bpp top-down DIB**(替换现在的 `CreateCompatibleBitmap`):
```cpp
struct SurfaceView
{
HDC dc; // 共享 HDC(GDI/GDI+ 都画这个)
gd::RectF rect; // 本节点在 Holder 坐标系中的矩形(设备像素,整数对齐)
int stride; // 行字节数(**不可**假设 = width*4)
std::uint32_t* bits; // rect 左上角那一像素(不是位图首地址)
std::uint64_t version; // 位图代次;失配 ⇒ 句柄作废
};
```
⚠️ **绝不能缓存裸指针**:`EnsureBackBuffer` 在 `WM_SIZE` / `WM_DPICHANGED` 会重建位图。
⚠️ `bits` 只在 **CPU 侧直写**(memcpy/alpha)时用;GDI/GDI+ 绘制只需 `dc + rect`。
2. **脏区并集**:把现在 56 处 `InvalidateRect` 收敛为 `Host::Invalidate(rect)`,一次回送。
⚠️ 回送仍必须**有无条件 `else`**(历史:某分支缺兜底 ⇒ 窗口一个像素都不回送,用户看到三个不同症状)。
3. **动画 tick**:`AdvanceAnimation` 遍历树,任一为 true 就保持 `kScrollTimerId`(10ms)。
4. **消息翻译**:`WM_*` → `UiEvent`,并把 `WM_SETCURSOR` 交给 `QueryCursor` 链(从 z 顶向下、首个非 null 生效)。
---
## 6. 落地阶段(每阶段一个可独立回滚的提交)
| 阶段 | 内容 | 交付判据 |
| --- | --- | --- |
| **P0** | 离屏回归网 `Tools/uicheck`(入库)+ 现有 20+ 条硬约束的断言骨架 | ✅ **已完成**(69 项断言) |
| **P1** | `UiLayout` + `UiWidget` 基础层(本次) | ✅ **已完成**(编译零警告;`uicheck` 覆盖) |
| **P2** | 零行为变更分片:`PlotViewPaint.cpp` 拆成 `Paint`(只画)/ `Raster`(缓存与预算)/ `CacheKey`(指纹)/ `Geometry`(面板与命中);`EdgePanelLayoutFor`、`ProbeFreeMarker` 迁回交互侧 | ✅ **已完成**(**逐像素 0 差异**) |
| **P3** | 迁移 chrome:Toolbar → TabBar → PopupMenu → ColorPicker → SignalPanel 行(**一次一个组件、每完成一个跑全量探针**) | 🔄 **Toolbar 已完成**;其余待做 |
| **P4** | 迁移 5 个浮动面板(`EdgePanelLayoutFor` 651 行 → 真控件;消掉平行数组) | ⏳ 待做 |
| **P5** | 波形管线包成叶子 surface(**只包不改**)+ 共享 DIB 落地 | 🔄 **共享 DIB + `UiSurface` 已完成**;脏区并集待做 |
**P2 之前禁止改任何行为**。P2 是唯一的"免费"阶段,别顺手改语义。
---
## 7. 验证策略(不占用用户鼠标/键盘/屏幕)
1. **离屏 harness**:`SW_HIDE` 窗口 + 直接 `app.HandleMessage(...)` 注入 + 从 `m_backDc` 用 `GetDIBits` 取帧。
2. **进程/线程优先级 `BELOW_NORMAL`**;从不用 `SetCursorPos` / `SendInput` / 截屏。
3. **A/B 反证**:每条新断言都要能被"还原旧实现"证伪,否则不算通过。
4. **像素判据要选对区域**:验证"某交互不应改变外观"时,只对**不受影响**的区域取哈希。
5. **已知陷阱**(全部来自历史 BUG):
- 探针消息泵必须丢弃 `WM_MOUSELEAVE`(离屏窗口会立刻兑现它);
- 注入按下后立即 `ReleaseCapture()`(避免抢真实鼠标);
- `CycleCursorMode()` 是**循环**,多组用例里重复调用会把光标关掉;
- `PlotView` 的嵌套类型(`LaneVisual`/`LegendHit`/`EdgePanelLayout`)使用时要写 `PlotView::` 前缀。
---
## 8. 协作纪律(多 Agent)
1. **文件所有权**:同一时刻**一个 Agent 只改一个文件的同一区域**;跨阶段并行时按下表分工,禁止交叉。
2. **每阶段一个提交**,提交信息写清"改了什么 / 为什么 / 怎么验证"。
3. **先写断言再改代码**。
4. `main` / `dev` 只接受全绿;`UI_Refactor` 内允许临时红,但**不许跨轮次累积红**。
5. 任何"规则变更"必须同步进 `Documents/agent_mem.md`(§11.2 追加编号),否则另一端看不到。
6. 体积红线 **900KB**(到达即提醒用户,不是禁止超过)。
### 8.1 并行分工建议(若启用多个 Agent)
| Agent | 独占文件 | 禁止触碰 |
| --- | --- | --- |
| A(基础层) | `UiLayout.*`、`UiWidget.*`、`UiHost.*`、`Tools/uicheck/*` | 任何现有组件 |
| B(分片) | `PlotViewPaint.cpp` → 新分片文件、`PlotViewInternal.hpp` 的 include 段 | `UiWidget.*`、面板逻辑 |
| C(面板迁移) | `PlotViewFeatures.cpp` 的面板段、新建 `PlotViewPanels.cpp` | `PlotViewPaint.cpp`、`UiLayout.*` |
> 冲突高发点:`Makefile`(新增目录时才需要改)、`agent_mem.md`(追加编号必须串行)。
---
## 9. 风险与回滚
| 风险 | 触发条件 | 回滚 |
| --- | --- | --- |
| 迁移后"连点丢一半" | `DoubleClickIsPlainPress` 语义漏实现 | 单组件提交可独立 revert |
| 迁移后"拖动卡住" | capture 共用被拆成两套 | 同上 |
| 面板失效不全(贴旧像素) | `ContentKey()` 漏字段 | P4 里每个面板配一条"改 X 后像素必须变"的断言 |
| 体积超 900KB | 新框架代码量 | 每阶段结束量一次;超阈值先报告用户 |
| 布局级联死循环 | Measure 里改子节点 | 单元测试固定"Measure 一轮即收敛" |