体积 L3(-fno-exceptions + MemProbe + LTO)与 L1 几何产物层;产物 909,824 -> 788,992 B

## 为什么

用户 2026-10-29 要求:按既定计划完整实现改进,但
① **不得把编译产物拆成 DLL**(项目定位是单文件轻量工具,最终只能有一个 .exe);
② 异常处理改用**预检验**而不是 try/catch(-fno-exceptions 省体积,且 try/catch 本身开销大);
③ 先删除 Documents 内三份 UI 重构文档再按"计划 / 当前进度 / 最终报告"重建;
④ 顺便审计代码,删除无用/过时注释,修复名实不符;
⑤ 提交到本地仓库,期间不要占用用户的鼠标/键盘/屏幕。

## 改了什么

### 1. 体积(909,824 B -> 788,992 B,-120,832 B,-13.3%)

Makefile 新增三个可覆盖开关(默认全为 1):

- `LAYOUT_BOUNDS`  编入/剔除「显示布局边界」叠加层
- `NO_EXCEPTIONS`  追加 `-fno-exceptions`
- `LTO`            追加 `-flto`(编译 + 链接)

派生:`FUNC_SECTIONS`(LTO 下为空)、`GC_SECTIONS`(`-Wl,--gc-sections`)、
`ASM_LISTING`/`ASM_LISTING_C`(LTO 下为空,因为 lto-wrapper 会丢弃 `-Wa` 清单)。

两个实测复现的坑(已写进 Makefile 注释):

- **切换 LTO 必须 `make clean`**:LTO 与非 LTO 目标文件混链会让 GCC 15.2.0 在
  `binds_to_current_def_p (symtab.cc:2598)` 处 ICE。
- **`-flto` 与 `-ffunction-sections -fdata-sections` 同时打开也 ICE**(干净重编也必崩,
  报错落点是 `DataModel.hpp:393`,但那是 COMDAT 解析落点、不是原因)⇒
  LTO 下 `FUNC_SECTIONS` 置空,并加 `-flto-partition=one`:默认 11 个分区的 LTRANS
  稳定复现,单分区从未复现。**这是稳定性选项,不是性能选项。**

### 2. 关异常必须成对交付:预检验 + 兜底

新增 `Utilities/Inc/MemProbe.hpp` + `Utilities/Src/MemProbe.cpp`:

- `MemAvailablePhysical()`:`GlobalMemoryStatusEx` + 200ms 缓存
- `MemCanAllocate(bytes)`:预留后仍须保有 1/8 可用内存
- `MemReserveIfRoom(container, n)`:含 `capacity()` 早退与乘法溢出保护
- `MemInstallTerminateHandler()`:OOM 时弹一次带建议的模态提示后 `_Exit`(而不是静默消失)

接入:

- `Utilities/Src/DataModel.cpp`:全仓**唯一**的 `try/catch`(`extremes.reserve` 捕
  `std::bad_alloc`)改为 `MemReserveIfRoom(extremes, count / 8)`
- `Components/PlotView/Src/PlotView.cpp`:`AvailablePhysicalBytes()` 原有一份**完整重复**
  的 `GlobalMemoryStatusEx` + 缓存实现 → 转发 `MemAvailablePhysical()`
- `Components/Application/Src/Application.cpp`:`Initialize()` 开头安装 terminate handler

### 3. L1 几何产物层

新增 `Components/Shared/Inc/UiBox.hpp` + `Src/UiBox.cpp`:

- `struct UiBox { rect, clip, depth, layer }` —— 纯值类型,不继承任何东西
  (继承会和 PlotView 4 层位图缓存 / SignalPanel 行位图缓存的失效体系打架)
- `class UiBoxTable : public UiTextRectSink` —— 只累计几何 + 裁剪/层/遮挡,**不含绘制代码**

`UiComponent::CollectDebugBoxes` → **`virtual void BuildBoxes(UiBoxTable&)`**:
组件从"收集布局边界(调试用)"改为"上报几何产物",不再需要知道"调试"这个概念存在。
`UiDebugBounds.hpp/.cpp` 退化为唯一的消费者(配色 + 包角描边 + 遮挡挖洞)。

除宿主 `ApplicationCommands.cpp` 外,所有组件源码现在只包含 `UiBox.hpp`。

### 4. 审计

- `MemProbe` 里"省 40~60KB"的估算 → 改为实测 27,136 B
- `MemProbe.hpp` 的 `<new>`、`MemProbe.cpp` 的 `<cstdio>`:未使用,删除
- `UiDebugPaintBounds(g, rect, depth)`:零调用者,删除(只保留带 clip 的版本)
- `TabBar`/`Toolbar`/`PlotView`/`SignalPanel` 的 `BuildBoxes` 文档"(调试用)"→"几何 box"
- `PlotView::SetForceFullRepaint` 补写"为什么保留而不优化"

### 5. 文档

`Documents/UI_Refactor_Plan.md` / `_Progress.md` / `_Report.md` 先 `git rm` 再按
「计划 / 当前进度 / 最终报告」重建;`Documents/agent_mem.md` 追加第十三轮记录。

## 怎么验证

```
make clean
make release TARGET_FILE_NAME=JustDataInspector -j16   # 零 error / 零 warning,788,992 B
make debug   TARGET_FILE_NAME=JustDataInspectorD -j16  # 零 warning

uicheck 69/69   lbcheck 21/21  lbresid 5/5  lbzorder 4/4  lbcovr 7/7  lbpanel 10/10
rendercheck: 8 个场景哈希与改造前逐一相同 + FUNC 7/7 PASS, exit=0
  A FAED59DF284153DB  B E6443BFE4E3955F8  C 96A0AA8C402F1729  D E3D5DBD998E43720
  E DA0583CB13C26A6F  F FFB822159F604D1D  G 19F8DE8FE3A8DF18  H DBBDDE4CB2C97C92
```

体积溯源(`objdump -h`,基线 → 最终):
`.text` 735,600 → 657,168;`.rdata` 49,184 → 35,016;
`.xdata` 48,132 → 27,512;`.pdata` 35,424 → 28,524;`.rsrc` 20,656 不变。

## 没做什么(有意为之)

- **没有拆 DLL**(用户明确要求单文件)
- **没有改优化等级**(`-Oz` 保持)
- **L2(`UiDamage` 脏矩形 + 独立叠加层)评估后缓做**:受益方只有"调试开关打开时的帧率",
  而正确实现必须把叠加层旧矩形注入底层组件重绘范围,穿透 PlotView 四条快路径 + 两层缓存;
  在用户反复报过"拖拽错位 / 贴图残留"的背景下,风险与收益不成比例。理由写在
  `Documents/UI_Refactor_Plan.md` §5 与 `PlotView::SetForceFullRepaint` 的文档里。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-29 11:53:54 +08:00
1 parent 739a523af5
commit 044b87383b
34 files changed
+1117 -2200

No files matched your search

+138 -645
View File
@@ -1,664 +1,157 @@
# UI 重构报告 —— 里程碑 M1 最终版(已验收)
# Just DataInspector UI 重构 —— 最终报告
日期:2026-10-28 分支:`UI_Refactor`(本地提交,`main` / `dev` 未改动)
计划:`Documents/UI_Refactor_Plan.md` 台账:`Documents/UI_Refactor_Progress.md`
> **本文件是 M1 的最终报告(已完结)**。M1 的范围与验收标准见 §1;
> 后续范围(M2)已在 §7 明确列出并附继续协议,**不属于 M1 验收内容**。
> 计划见 `UI_Refactor_Plan.md`,过程与实测数据见 `UI_Refactor_Progress.md`。
> 日期:2026-10-29
---
## 0. 一句话结论
## 1. 一句话结论
> 本里程碑把"UI 要换架构"从设想变成了**可编译、可验证、可继续**的三块地基:
> ① 一套布局/控件基础层(含 69 项入库断言);② 一个真实组件(Toolbar)已在逐像素不变的前提下换成控件树;
> ③ 一个应用级共享表面(top-down 32bpp DIB + `UiSurface`)。
> 同时把 9 080 行的"万能绘制文件"按职责降到 4 913 行(−46%),并修掉一个退出期崩溃缺陷。
> **产品像素零变化、`uicheck` 全绿、`main` 未受影响。**
在**不拆 DLL、不改优化等级、不改变任何绘制像素**的前提下,
把界面从"宿主点名各组件 + 各算一遍几何"改成"组件协议 + 几何产物层",
同时把产物从 **909,824 B 降到 788,992 B(−120,832 B,−13.3%)**,
并用 6 组、共 116 条断言 + 8 个逐像素哈希证明零回归。
---
## 1. 交付清单(M1 范围 = 地基 + 两个组件 + 共享表面)
## 2. 收益
> M1 的定义:**建立可验证的重构地基,并用它完成至少两个组件的真实迁移**。
> 这两件事已全部交付并通过验收(见 §1 表格与 §8 复跑步骤)。
### 2.1 结构
| 阶段 | 内容 | 状态 | 证据 |
| --- | --- | --- | --- |
| **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()` 保护 |
| 之前 | 之后 |
| --- | --- |
| 宿主 `if` 链逐个点名 4 个大组件,同一件事要写 5 遍 | 一张有序组件表,新增组件不改宿主 |
| 几何分散在 `Paint*` / `HitTest` / `CollectDebugBoxes` 三处,**三份实现** | `BuildBoxes()` 一处产出,绘制/命中/调试同源 |
| "调试"概念渗透进每个组件 | 组件只吐几何;"怎么画"收敛在 `UiDebugBounds` |
| 单个 `.cpp` 数千行 | 按职责分片(PlotView 5 片、SignalPanel 3 片…) |
提交(全部在 `UI_Refactor`):
### 2.2 体积
```
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:布局/控件基础层 + 离屏回归网
```
| 项 | 数值 |
| --- | ---: |
| 基线 | 909,824 B |
| 最终 | **788,992 B** |
| 减少 | **120,832 B(−13.3%)** |
| 相对 1MB 预警阈值的余量 | 259,584 B(约 253 KiB) |
分支操作(含 evaluator 关心的"在 dev 上移除过时报告"):
构成变化的解释见 `UI_Refactor_Progress.md` §2.3(`.text` −78 KB 来自 LTO,
`.xdata`+`.pdata` −27.5 KB 来自关异常 + gc,`.rdata` −14 KB 来自 gc 掉未引用常量)。
```
dev@4e51b41 删除 Documents/refactor_report_2026-10-16.md(−508 行,结论已并入 agent_mem.md)
main@df715a0 未被任何重构提交触碰
UI_Refactor 从 dev 切出,以上 8 个提交
```
### 2.3 健壮性
复跑取证:
* `-fno-exceptions` 之后没有"静默消失":OOM 走 `MemInstallTerminateHandler()`,
弹一次带建议的模态提示再干净退出。
* 唯一一处 `try/catch` 改成**预检验**(`MemReserveIfRoom`),语义等价且不依赖异常。
* `PlotView` 里重复实现的"可用物理内存查询"被去重到 `MemProbe`(单一实现、单一缓存)。
---
## 3. 关键决策与依据
### 3.1 拒绝"拆 DLL 换体积"
用户的定位是**单文件轻量工具**。拆 DLL 只把字节从 `.exe` 挪到旁边的 `.dll`,
总交付物更大、部署更脆(版本错配、路径缺失),且 `-static` 换来的"拷一个文件就能跑"
这一核心体验会丢掉。**因此体积优化的边界是"编译期与链接期手段",不含产物拆分。**
### 3.2 关异常 → 必须预检验,而不是"少写 try/catch"
`-fno-exceptions` 的真实代价不是"少了个 try",而是**分配失败从"可恢复"变成"进程终止"**。
所以必须成对交付:
1. 所有"按数据规模预留"的路径先问内存(`MemReserveIfRoom`);
2. 遗漏路径有兜底提示(terminate handler)。
只做第 1 步是不够的 —— 事后新增的代码很容易忘记检查。
### 3.3 LTO 用 `-flto-partition=one` 换稳定性
`-flto` 单独用会在 COMDAT 解析处 ICE(`binds_to_current_def_p, symtab.cc:2598`),
根因是多分区 LTRANS;单分区稳定(实测 `--gc-sections` 开/关都得到 791,552 B,
说明这一档体积收益来自 LTO 本身而不是分区数)。
代价是 LTRANS 串行 —— 这是**用构建时间换"构建能过"**,值得。
同时确认:`-flto` 与 `-ffunction-sections -fdata-sections` 不能同时开(同一个 ICE),
LTO 下把 `FUNC_SECTIONS` 置空。
### 3.4 L1 而不是"给所有元素加基类"
`PlotView` 有 4 层自持位图缓存、`SignalPanel` 有行位图缓存,它们要求"像素由组件自己负责"。
把几何塞进继承体系会和缓存失效体系打架(历史上"后台渲染覆盖用户平移""命中错位"
就是这类冲突)。`UiBox` 是**纯值类型**,因此可以被绘制、命中、将来的脏矩形共用,
却不需要虚函数与生命周期管理。
### 3.5 L2 缓做
见 `UI_Refactor_Plan.md` §5。一句话:**受益方只有调试开关打开时的帧率,
而正确实现要穿透 `PlotView` 全部四条快路径与两层缓存。**
在"用户反复报告过拖拽错位/贴图残留"的背景下,这笔风险不值得为一个调试特性去承担。
---
## 4. 验证方法与结果
### 4.1 为什么"逐像素哈希"是唯一可信的验收
"看起来一样"不可作为判据:这类重构最容易出的问题是**1px 偏移**与**边界条件**
(例如某个元素在某个 DPI 下多画了一条线)。`Tools/rendercheck` 对 8 个典型场景
(空白 / 单网格 / 多网格 / 分组 / 面板展开 / 菜单 / 拾色器 / 样式区)渲染到内存位图后
取 FNV 哈希,**与基线逐一比对**,因此任何像素级差异都会暴露。
结果:8 个哈希与改造前**逐一相同**,且 `JustDataInspector.exe` 在 L1 前后**字节数完全相同**。
### 4.2 断言矩阵
| 探针 | 断言 | 覆盖 |
| --- | ---: | --- |
| `uicheck` | 69 | 协议行为、命中、几何同源、浮层表面 |
| `lbcheck` | 21 | 边界收集 + **关闭后逐像素还原** |
| `lbresid` | 5 | 同一状态连续两帧逐像素相同(残留判据) |
| `lbzorder` | 4 | 浮层压住本体 |
| `lbcovr` | 7 | 覆盖完整性(时间轴/滚动条/胶囊/文本框/缓存行) |
| `lbpanel` | 10 | 面板遮挡、面板内容裁剪、滚动不闪烁、菜单条目裁剪 |
| 合计 | **116** | — |
### 4.3 方法论教训(已写入 `agent_mem.md`)
* **体积结论必须区分稳态与瞬时**;改编译开关后**必须 `make clean`**(混链会 ICE)。
* **判断"残留"不能用"拖拽帧 vs 松开后一帧"**(预热进度不同会产生几十万像素合法差异),
正确判据是**同一状态连续两帧逐像素相同**。
* **`--gc-sections` 已经承担了内联去重**(`UiPointInRect` 等自有符号重复组数为 0),
不必为了体积手工把 inline 实现搬进 `.cpp`。
* **探针判"盒子越界"必须按绘制层过滤**,否则会把更上层浮层的合法越界算成错误。
---
## 5. 风险与缓解
| 风险 | 缓解 |
| --- | --- |
| LTO 构建偶发 ICE(编译器缺陷,非代码缺陷) | `-flto-partition=one`;`LTO=0` 可一键回退(体积回到 882,688 B,仍远低于阈值) |
| `-fno-exceptions` 下遗漏一处分配检查 | terminate handler 兜底 + 全仓已无 `try/catch` + `MemReserveIfRoom` 是唯一预留入口 |
| LTO 构建时间翻倍 | 日常开发可用 `make release LTO=0`;提交前用 `LTO=1` 校验体积 |
| 探针脚本未纳入版本库,重建时缺 `-flto` | 已写入 Progress §6 待办;脚本本身只是本地验证工具 |
| L2 未做,布局边界打开时帧率下降 | 该开关是调试特性,默认关闭;文档已记录取舍 |
---
## 6. 遗留
1. **L2 `UiDamage` + S4 叠加层**:设计已定型(`kMaxRects=12`、超限退化为包围盒、
整数像素外扩 1px、只增不减;`PlotView::Paint(g, UiDamage&)` 契约;6 条路径各自 `damage.Add`),
但未实施。`UiBoxTable` 的几何可以直接喂给它,不需要重新收集。
2. **探针脚本入版本库**(`Tools/*/build.ps1`、`.preflight/*/build.ps1`)。
3. **`.lst` 汇编清单在 LTO 下不可用**:需要时用 `make release LTO=0`,这一点已在 Makefile 注释中写明。
---
## 7. 交付确认
```powershell
git show 4e51b41 --stat --oneline
Test-Path Documents\refactor_report_2026-10-16.md # False
git log --oneline main -1 # df715a0
git status --short # 干净
make clean; make release TARGET_FILE_NAME=JustDataInspector -j16
# → 零 error / 零 warning,788,992 B
```
---
## 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` 的例外规则)。
---
# 第六阶段报告:让"布局边界"变成可以随时打开的一面镜子
## 1. 这一阶段解决了什么问题
前几阶段把代码结构理顺了,但**没有工具**去验证"我算出来的矩形到底是不是我画的那个矩形"。
本项目主体仍是手算几何(全仓约 700 处 `gd::RectF`),一旦命中与绘制不同源,症状是
"点不到 / 点错位置 / 悬停高亮漏一格"这类只能靠肉眼猜的问题。
「显示布局边界」把这些不可见的矩形变成可见的一层叠图。
## 2. 设计取舍:基类给协议,组件自己上报
上一轮的结论是"只在共同基类里实现只能覆盖 4 个顶层区域"。本轮把它落实为分层协议:
```
UiComponent::PaintDebugBounds() 默认只画 Area() ← 宿主遍历一次覆盖 4 大区
└─ Toolbar/TabBar 覆盖:跳过根控件,遍历控件树子节点 ← 已上树的部分零成本
└─ SignalPanel 覆盖:表头/筛选框/列表/样式区 + 可见行
└─ PlotView 覆盖:每个网格区域
UiWidget::PaintDebugBounds(depth) 递归整棵控件树 ← 将来上树的控件自动获得
```
**为什么不放在 `UiWidget` 一处就够**:`PlotView` / `SignalPanel` 的主体几何还没上树,
把边界做成"只有树节点才有"就等于只覆盖了 20% 的矩形。
## 3. 三个必须守住的工程约束
1. **不进缓存**。`PlotView` 有 4 层位图缓存、`SignalPanel` 有 192 槽行位图缓存。
调试层只画在 `Render` 的最后一步;探针用"关掉后逐像素回到开启前"这条断言把它钉住。
2. **整帧回送**。`Render` 有"只回送绘图区"的快路径,而边界跨越所有装饰区 ⇒
`fullFrame` 必须并入 `m_showLayoutBounds`,否则看起来像"没生效"。
3. **锐利**。1px 线若被抗锯齿摊成 2px 灰边,就无法判断真实边界;
因此临时 `PixelOffsetModeNone` + 关抗锯齿 + 矩形对齐整像素,线宽按设备像素固定。
## 4. 顺带做掉的可见收益优化
`Toolbar` 原先每次 `Layout` 都重建整棵控件树(拖动面板分隔条时每帧一次)。
改为 `m_treeDirty` 按需重建后,`SetActionLabel` 也不再"双写覆盖表与控件"——
**文本的唯一权威来源只剩覆盖表**,重建时统一生效。
另外把 `Toolbar` / `TabBar` 里重复的 `Dispatch(); return UiRectHasArea(TakeDirty());`
收敛成 `UiWidget::DispatchRedraw()`。`TakeDirty()` 会清零,漏掉一次就把下一次真实变化
误判成"无变化"——这类"静默不重绘"的坑在项目历史上出现过多次。
## 5. 验证策略的变化
过去每轮都用"8 个场景的**整帧**哈希"证明零差异。本轮**整帧哈希必然变化**(多了一个按钮),
于是给探针加了「工具栏带以下的区域哈希」:8 个场景的区域哈希与改造前**逐一相同**,
从而证明改动的像素范围被完全限制在工具栏内。
另外新增了一个独立的离屏探针 `.preflight/lbcheck`(21 条断言),
专门验证"开关打开后确实出现调试色 / 关闭后逐像素还原 / 菜单锚定与勾选翻转"。
## 6. 数据
```
make release: 零 error 零 warning;TargetCheck.exe 897,024 B(< 900 KB)
lbcheck 21/21 · rendercheck FUNC 7/7 · 区域哈希 8/8 相同 · uicheck 69/69
```
## 7. 遗留与后续
1. 边界没有文字标签(哪个框是 `row#12` 还是 `lane3` 需要靠层级配色猜)。
加标签要解决"GDI 文本不受 GDI+ 裁剪约束",建议直接用 `UiTextClipped`。
2. `PlotView` 的 5 类浮动面板内部矩形、`SignalPanel` 的行内子块(复选框/徽标/✕)尚未上报。
两者的几何都已经集中,属于"再插一个循环"的量级。
3. 开关状态不持久化 —— 重启回到关闭。
4. 设置菜单目前只有一项,但分组结构(`HEADER`)已经就位,后续按组追加即可。
---
# 第七阶段报告:把"布局边界"从一根框变成一张可以逐元素对照的几何图
## 1. 上一版为什么不理想
两个独立的问题被混在一起:
1. **观感**:四条边等宽(层级 0 用 2px、其余 1px),同一个元素的"角"比"边"粗;
父子矩形共边时两条同色线叠在一起 ⇒ 更粗。于是看起来"就是包了个边、粗细不一"。
2. **覆盖**:`PlotView` / `SignalPanel` 的内部元素全是"按需计算的矩形函数",
没有任何代码把它们交给调试层 ⇒ 整块区域只有一个大框,"复选框、信号名、GX、✕"
全被裹在外层单位里。
第 2 点并不是调试功能的缺陷,而是**几何没有统一出口**的症状。本轮把它一次解决。
## 2. 描边:四角粗、四边细
```
边:固定 1 设备像素(4 条独立实心矩形,非 DrawRectangle —— 后者会偏移半像素)
角:层级 0 = 4px、其余 3px,沿水平/垂直各延伸 cornerLen(≤14px 且 ≤ 短边一半)
内缩:层级 > 0 整体内缩 1px,父子共边不再叠成粗线
填充:无
```
包角的意义不只是好看:**多条边重合时,靠"角的形状"就能分辨层次**(父是青蓝大包角、
子是同位置的品红小包角),这是纯直线做不到的。
## 3. 覆盖率:从"组件自己画"改成"组件收集、宿主统一画"
```cpp
class UiDebugBoxList; // 值类型容器,装 {rect, depth, tag}
virtual void CollectDebugBoxes(UiDebugBoxList&); // UiComponent 默认只登记 Area()
void UiWidget::CollectDebugBoxes(boxes, depth); // 递归整棵控件树
Application::PaintLayoutBounds(...) // 收集完一次性按 depth 升序描边
```
**为什么不是"所有元素都继承同一个基类"**:`PlotView` 的 4 层位图缓存、
`SignalPanel` 的 192 槽行位图缓存,都要求"绘制由组件自己负责"。
把几何塞进继承体系会与缓存体系打架(谁脏了谁重画、缓存键里要不要混调试状态……)。
而 box 是**纯值类型**,只描述"哪里有一个矩形",不参与绘制 —— 于是
**几何可以被绘制、命中与调试共用**,还不需要任何生命周期管理。
这正是项目里已有三处雏形(`m_decorRects` / `m_legendHits` / `m_freeMarkerHits`,
即"绘制时登记、命中时读取")的正式版本。
## 4. 覆盖率数据
| 组件 | 覆盖内容 |
| --- | --- |
| `Toolbar` | 区域 / 每个按钮 / **按钮内容(文字或齿轮图标)** |
| `TabBar` | 区域 / 每个标签条 / 新建按钮 / 每个 ✕ |
| `SignalPanel` | 4 个分区 + 筛选框与清空✕ + 全部显示 + 滚动轨道/滑块 + 样式区 5 行及其 12 色块/自定义/3 模式/3 调整/线宽与透明度轨道和数值 + 目标行/名称/GX + **每个可见行**的折起三角/复选框/色块/名称/GX/✕(组行另加组名/计数)+ 内联编辑框 |
| `PlotView` | 区域 / 每个网格 / GX 徽标 / 装饰层矩形 / 图例托底 + 色块 + 文本 / 5 类浮动面板的 20+ 个字段(面板、标题、✕、折起三角、徽标、编辑行、按钮、内容区、滚动轨道与滑块、透明度轨道与数值、两个开关、各行复选框/数值/滑轨/复位/名称/✕、分组栏/表头/三角/✕、每个固定点行与 ✕) |
| `PopupMenu` | 浮层 / 每个条目 / 可滚动可视区 / 轨道 / 滑块 / 二级子菜单与其条目 |
| `ColorPickerPopup` | 面板 / 标题 / 预览 / 色号 / 色域 / 色相条 / 3 条分量滑轨与数值 / 输入框 |
标签增加了**尺寸门槛**(< 56×16 逻辑像素不画):复选框、色块、✕ 紧邻,逐个小框贴字会糊成一片。
## 5. 顺带的几何同源与性能
`RowRect(row)` 是 **O(row)** 的逐行累加,而画一行要调它 **5 次**(✕ / 徽标 / 名称 / 组计数 /
折起三角各一次)——200 行列表只见 30 行时每帧上万次加法,命中测试每次鼠标移动同样走这条路。
本轮加了 `m_rowTop` 前缀和(由 `UpdateAnimGeometry()` 统一重建),查询变 **O(1)**,
长度不一致时自动退回逐行累加以保证正确性。
另外把 `(m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area` 在 6 处的重复收敛成
`PanelBounds()`;齿轮按钮改为主按钮配色,与「导入文件」构成一组文件/设置入口;
齿轮图标重画(齿占最外一圈、外环缩到 0.54R),否则齿会被环的描边吃掉只剩"甜甜圈"。
## 6. 验证
```
make release / make debug 零 error 零 warning
TargetCheck.exe 910,336 B(889 KiB)
lbcheck 21/21 PASS(含"关闭后逐像素还原")
rendercheck 「工具栏带以下」区域哈希 8/8 与上一轮逐一相同 + FUNC 7/7
uicheck 69/69
```
## 7. 遗留
1. 仍有两类几何没有上报:`PlotView` 的坐标轴刻度带、`SignalPanel` 的样式区滑块**滑块**(只有轨道)。
2. `m_decorRects` 按"是否落在该网格纵向范围内"归属,跨网格时可能归到相邻网格;
调试用途可接受,若要精确应改成绘制时就带上 laneIndex。
3. 开关状态仍未持久化。
4. `TargetCheck.exe` 已接近 900 KiB(889 KiB),`CollectDebugBoxes` 的文字常量与
`std::to_wstring` 是主要增量;若继续增长需要把标签文本改回静态字面量并去掉动态编号。
---
# 第八阶段报告:调试叠加层的三个真实缺陷(尺寸、残留、层级)
## 1. 症状与真实成因
用户报的三个问题看起来都像"边界画得不好",实际是**三个互不相干**的缺陷:
| 症状 | 真实成因 |
| --- | --- |
| 小元素变成粗描边 | 包角粗细是**固定值**(3–4px),没考虑元素尺寸 |
| 拖滚动条时"大量绘制残留",松开并移动鼠标才消失 | ① 我的收集代码读了**位图坐标系**的 `m_decorRects`,生成横跨工具栏的幽灵框;② 绘图区的三条快路径只重画变化区域,而调试线画在最后 ⇒ 旧线残留;③ 组件内越界元素没裁剪 |
| 右键二级菜单被下层边界线覆盖 | 只有按 `depth` 排序,没有"收集顺序/浮层"概念;且调试线画在内容之后,光是排序并不能阻止露线 |
## 2. 尺寸自适应
以短边为基准推导包角:粗细 `min(4/3, 短边/10)`(再夹到 `[1, 短边/4]`),长度 `min(14, 短边/3)`。
短边 ≤ 12 只画四个 L 形角标记(不画边),≤ 5 完全不画。于是:
* 15px 复选框 → 1px 角标记,不再"被框住";
* 40px 按钮 → 3px 包角 + 1px 边;
* 200px 区域 → 4px 包角,形状仍是 `⌜ ⌝ ⌞ ⌟`。
文字标签整块删除(含 `UiDebugPaintStyle`):黑底白字在小元素上必然糊成一片,体积也省 1.5 KB。
## 3. 残留:为什么"松开并移动鼠标"就消失
因为松开鼠标后 `m_dragMode` 回到 `NONE` 且 `m_chromeDirty` 被置位 ⇒ 走**整幅重绘**,
把上一帧残留的边框像素全部盖掉。这反过来证明残留来自"部分重画 + 叠加层"。
修法分三层:
1. **不再读位图坐标系的缓存**(`m_decorRects`),消灭幽灵框;
2. **调试模式强制整幅重绘**(`SetForceFullRepaint`),让每一帧都是完整一帧;
3. **box 带 `clip`**(组件地盘),组件内越界元素(`lane.rect` 的 Y 可以是负数)
不会画到工具栏上。
探针的判据也值得记一笔:不能拿"拖拽帧 vs 松开后一帧"比 —— 两者图层预热进度不同,
会有几十万像素的**合法**差异。正确判据是**同一状态连续两帧必须逐像素相同**。
## 4. 层级:排序不够,还要挖洞
`layer`(收集顺序)+ `depth`(父子)双排序键解决"两个 box 重叠时谁压谁"。
但调试线是画在**所有内容之后**的,下层组件的线仍然会显示在不透明菜单上 ——
于是补上 `AddOccluder()`:菜单/拾色器把本体登记为遮挡矩形,绘制更低层的 box 时
用 `Region::Exclude` 挖洞。
实测:菜单区与二级子菜单区的琥珀/绿色像素均为 **0**(既没有下层行边框,也没有被裁破的包角)。
## 5. 顺带修掉的东西
* 浮层**不能**用"本体矩形"当 clip:二级子菜单会展开到本体之外,会被整个裁掉
(首版就踩了这个坑,子菜单一个框都没有)。
* `Render` 的 `fullChrome` 并入 `m_showLayoutBounds`,装饰区也保证完整一帧。
## 6. 验证
```
lbresid 5/5 (工具栏恒等、同状态两帧恒等、差异不越过绘图区上沿、层序正确)
lbzorder 4/4 (菜单与子菜单区内 0 个下层颜色)
lbcheck 21/21 ;uicheck 69/69 ;rendercheck 区域哈希 8/8 不变 + FUNC 7/7
零 warning;TargetCheck.exe 906,752 B(885.5 KiB)
```
## 7. 遗留
1. 调试模式下强制整幅重绘:283 个网格时帧率明显下降 —— 这是**有意的取舍**
(调试而非日常使用),但若将来要让边界常驻,需要改为"只把叠加层位图化再贴回"。
2. 菜单项逐个画框略显拥挤,可选做"只画外框 + 悬停项高亮框"。
3. 开关状态仍未持久化。
---
# 第九阶段报告:把"边界"从补丁改成机制
## 1. 为什么上一轮只能算"打了三个补丁"
第十轮解决了三个症状(包角太粗、残留、层级),但没有触及根因。根因是:
* **框与形状不重合**(内缩)—— 这是我在第九轮为了"父子共边不叠粗"引入的**错误取舍**;
* **文本没有几何身份** —— 65 处绘制点各自现算矩形、画完即弃,任何"逐处补登记"都必漏;
* **描边图元太多** —— 12 图元/box,纯属实现细节没优化。
## 2. 取消内缩:正确的取舍是"后画的覆盖"
第九轮加内缩的理由是"父子矩形共边时两条同色线叠成更粗的一条"。但这个问题的正确解法不是
缩小子框,而是承认**图层语义**:子框后画,就由子框的颜色覆盖父框的那条边。
内缩的代价是:任何**非直角形状**(胶囊形 GX 徽标、带描边环的色块、圆角按钮)的可见边界
都在外接矩形上,内缩 1px 之后框就比形状小一圈 —— 用户看到的正是"GX 标签的圆弧在框之外"。
现行规则:**框 = 可见形状的外接矩形,绝不内缩。**
## 3. 文本:在唯一出口回报一次
```
class UiTextRectSink { virtual void OnTextRect(const gd::RectF&) = 0; };
void UiSetTextRectSink(UiTextRectSink*); // 开关关闭时为 nullptr
void UiSetTextRectOffset(float dx, float dy); // 离屏位图渲染的坐标换算
```
`DrawUiText()` / `UiTextAtClipped()` 画完后按对齐方式与实测宽度/高度把布局矩形**收窄**
成墨迹矩形再回报。于是:
* 65 处调用点**一行都不用改**;
* 每个文本框就是文字真正占据的那一小块(而不是整行);
* 开关关闭时只是一次空指针判断,正常绘制零影响(`rendercheck` 8 场景哈希逐像素不变为证)。
## 4. 缓存:文本框必须跟着位图一起缓存
面板的行是"画进位图再贴回",命中缓存的帧**根本不调用 `UiText`**。所以:
* 行渲染时用本地 `RowTextCollector` 收集(位图坐标);
* 存进 `RowBitmapCache::textRects` 与位图同生命周期;
* 贴图时按 `(originX, originY)` 回放给外层收集器;
* **无条件收集** —— 否则"关闭调试时滚过列表、再打开调试"会命中缓存而永远没有文本框。
## 5. 性能:图元数量是唯一瓶颈
微基准(同一块 1707×949 DIB,5688 段图元):
| 实现 | ms |
| --- | --- |
| GDI+ `FillRectangle` × 5688(每次新建画刷) | 13.0 |
| 复用画刷 | 12.4 |
| 每 box 一条 `GraphicsPath` + `FillPath` | 22.8 |
| 全部 box 合并成一条 path | 8.4 |
| **每 box 只画 1 个矩形**(474 次) | **0.70** |
| GDI `PolyPolygon` 一次调用 | 114 |
结论:瓶颈是**调用次数**(≈1µs/次),合并路径反而更慢。因此把 12 → **8**:
4 条 1px 边 + 4 个自适应角块。实测 `paintBoxes` **5.892 → 4.028ms(−32%)**。
## 6. 覆盖率
`PlotView` 补齐 6 类(时间轴 / 网格滚动条 / 回到最上方 / 光标差值标签 / 光标读数标签 / 内联输入框),
全部读现成成员。新增探针 `.preflight/lbcovr` 用"已知矩形 vs 收集到的 box"逐条比对,
把"有没有漏项"变成可回归的断言 —— 这比肉眼看图可靠。
## 7. 数据
```
四个探针:lbcovr 7/7 · lbcheck 21/21 · lbresid 5/5 · lbzorder 4/4 · uicheck 69/69
rendercheck:整帧与区域哈希与上一轮逐一相同,FUNC 7/7
TargetCheck.exe:909,824 B(888.5 KiB)
帧耗时(MF4 / 283 网格):base 9.06 / 仅强制整帧 6.66 / 完整调试 11.35 (collect 0.082 + paint 4.028)
```
## 8. 遗留
1. 调试模式仍依赖"强制整幅重绘"(`SetForceFullRepaint`);根治需要让快路径支持
"重画指定矩形集合"。另外实测**基线本身**每帧在 `WarmLaneLayersStep()` 上花约 2.4ms
(9.06 vs 6.66),这是与本功能无关的既有开销,值得单独治。
2. 文本墨迹矩形是"字符单元"高度,不含下伸部(descender)的差异;对除 `g/y/p` 多的文本
会略微偏扁,属可接受近似。
3. `PlotView` 的坐标轴刻度带仍未单独上报(只上报整条 `m_timeAxisRect`)。
---
# 第十阶段报告:把"层级"变成一条可验证的规则
## 1. 先说清楚"既有性能问题"到底是什么
上一轮报告里我写了"`PlotView` 基线每帧在 `WarmLaneLayersStep()` 上花约 2.4ms"。
本轮用更细的探针查证,**这个结论是错的**:
```
排空前 idle 2.60 ms ← 这里包含"图层预热队列排空"的瞬时成本
排空后 idle 0.53 ms ← 真正的稳态
强制整帧 idle 0.46 ms
```
也就是说**没有**每帧白烧 2.4ms;那 2.4ms 是"载入 283 个网格后,需要给 16 个网格补建宽基底图层"
的瞬时开销(每帧补 1 个,1~3ms/帧,约 16 帧结束)。这属于设计内的预热。
不过顺着这条线**确实**找到一处顺序问题:`RollWideBuffer` 先给**所有**网格算内容指纹
(`LaneLayerKey()`,283 次 ≈ 0.7ms/帧),然后才判断"这个网格是否可视、不视则跳过";
而且离屏网格的"结构不可用"分支会**逐帧**把它们塞进待重画队列,再在同一帧被 `remove_if` 清掉。
把可视性判断提到循环最前之后,离屏网格既不哈希也不入队。
**教训**:性能结论必须先区分"稳态"与"瞬时",否则会把预热成本误判成常态浪费。
## 2. "浮层不能被下层覆盖"需要两件事,不是一件
用户先后两次报告同一类现象(面板被网格边界穿透、菜单边界溢出),根源都在**分层只有一半**:
| 机制 | 作用 | 只有它够吗 |
| --- | --- | --- |
| `layer`(`PushLayer`)+ 按 `(layer, depth)` 排序 | 决定**两个盒子重叠时谁压谁** | ❌ 不够 |
| `AddOccluder` + `Region::Exclude` | 让**更低层的盒子整体不进入浮层区域** | ❌ 单独也不够(还需要层号) |
| `SetClip` | 让**滚动容器里滚出的条目不再登记/绘制** | 解决"溢出/闪烁" |
本轮把这三件事在浮动面板上补齐(菜单在上一轮已有 occluder,本轮补 clip)。
## 3. 为什么"滚动时闪烁"
滚动容器(面板内容区 / 菜单条目区)的条目矩形**始终存在**,只是绘制时被裁剪掉。
调试层读到这些矩形就会给"看不见的内容"画框 —— 而滚动时条目不断进出可视区,
框也就不断出现/消失,看上去就是闪烁。修法与菜单一致:登记盒子时把裁剪矩形设为可视区。
实测:面板层 escaped=0、菜单层 belowViewport=0、面板内网格层颜色像素=0,
且真滚动 1295px 的过程中"面板盒子签名"11/11 帧完全一致。
## 4. 顺带的一处小优化
`EdgePanelLayoutFor` 单次约 2~3µs,283 网格 × 5 类 = 1415 次/帧。现在先看该网格的
5 个面板 `open` 标志,全关就跳过整个内层循环。
## 5. 数据
```
零 warning;TargetCheck.exe 909,824 B(888.5 KiB)
lbpanel 10/10(新增)· lbcovr 7/7 · lbzorder 4/4 · lbresid 5/5 · lbcheck 21/21 · uicheck 69/69
rendercheck 8 场景哈希与上一轮逐一相同 + FUNC 7/7
```
## 6. 仍然存在的近似
* 面板层的层号只比网格高 1,而**文本盒子**固定在 layer 2 收集。因此面板**自己的**文字框
能正常显示,但理论上"图例文字"若与面板重叠仍会画在面板上。实际布局下两者不重叠。
若要彻底消除,需要让文本收集器在"面板绘制期间"使用面板层的层号。
* 菜单/面板的条目裁剪用的是"可视区矩形",因此**与边界部分相交的那一条**仍会被登记并裁剪绘制
(这是期望行为:它确实可见)。
唯一产物:`Build/Release/JustDataInspector.exe`(含图标、清单,`-static` 链 CRT)。
**无任何新增 DLL 依赖。**