Files
Just-DataInspector/Documents/UI_Refactor_Report.md
T
DeepSeek V4.1 Flash aa4925a2b7 fix(UI): 边界框不再内缩 + 文本获得几何身份 + 补全漏项 + 描边提速 32%
## 1. 取消内缩(这是"边框不清晰"的主因)

第九轮为"父子矩形共边不叠粗"给 depth > 0 的 box 加了 1px 内缩。结果 GX 徽标
(30x17 逻辑像素的胶囊,圆角 = 高的一半,外接矩形正好等于 LaneBadgeRect)内缩 1px 后
框比形状小一圈 ⇒ 左右尖端与上下圆弧全部落在框外。

现行规则:**框 = 可见形状的外接矩形,绝不内缩**。父子共边时让"后画的覆盖"即可,
这本来就是正确的图层语义。层级 > 0 只影响配色,不再影响几何。

## 2. 文本第一次有了几何身份(唯一出口回报,不逐处登记)

文本是本项目唯一"没有几何身份"的元素:65 处绘制点各自现算 gd::RectF、画完即弃。
逐处补登记必漏,因此在唯一出口回报一次:

  PaintUtil 新增
    class UiTextRectSink { virtual void OnTextRect(const gd::RectF&) = 0; };
    void UiSetTextRectSink(UiTextRectSink*);       // 开关关闭时为 nullptr
    void UiSetTextRectOffset(float dx, float dy);  // 离屏位图渲染的坐标换算
    UiTextRectSink* UiGetTextRectSink();

DrawUiText() / UiTextAtClipped() 画完后按对齐方式 + 实测宽高把布局矩形**收窄成墨迹矩形**
再回报。UiDebugBoxList 直接继承该接口,宿主只需 UiSetTextRectSink(&m_debugBoxes) 一句。
开关关闭时只是一次空指针判断,正常绘制零影响。

## 3. 缓存命中的行也要有文本框

面板的行是"先画进位图再贴回",命中缓存的帧**根本不调用 UiText** ⇒ 文本框第二帧起消失。
做法:行渲染时用本地 RowTextCollector 收集(位图坐标)→ 连同位图存进
RowBitmapCache::textRects → 贴图时按 (originX, originY) 回放。
⚠️ **必须无条件收集**:只在"外层有收集器"时收集会导致"关调试时滚过列表 → 打开调试 →
命中缓存 → 永远没有文本框"。

## 4. 补齐 PlotView 漏项(全部读现成成员)

m_timeAxisRect / m_scrollBarRect / BackToTopRect() / m_cursorSpanRect /
m_cursorLabelRects / EditFieldRect()

## 5. 描边提速:图元 12 → 8

微基准结论:图元数量是唯一瓶颈(每次 FillRectangle ≈1µs)。
旧实现把每条边拆成"中段 1px + 两端各一段加粗"= 12 次;新实现 = 4 条 1px 边 + 4 个自适应角块。
实测 paintBoxes 5.892ms → 4.028ms(-32%),观感几乎一致(角仍是两条粗边的交点)。
⚠️ 不要改写成 GraphicsPath/FillPath(单次约 20µs,比 12 段还慢);
GDI PolyPolygon 一次画 5688 个矩形要 114ms,更不能用。

## 6. 关闭开关时清空容器(释放内存,避免误读)

## 7. 验证

- make release / make debug 零 error 零 warning;TargetCheck.exe 909,824 B(888.5 KiB < 900 KiB)。
- 新增探针 .preflight/lbcovr 7/7:box-time-axis / box-plot-scrollbar / box-back-to-top /
  box-gx-badge-matches-pill(容差 0.5px)/ text-boxes-collected(89 个)/
  row-text-boxes-from-cached-rows / boxes-cleared-when-off。
- 回归:lbcheck 21/21、lbresid 5/5、lbzorder 4/4、uicheck 69/69。
- rendercheck:8 个场景的整帧与区域哈希与上一轮**逐一相同** + FUNC 7/7
  (证明调试功能对正常绘制零影响)。
- 帧耗时分解(MF4 / 283 网格):base 9.06ms、仅强制整帧 6.66ms、完整调试 11.35ms;
  collect 0.082ms、paintBoxes 4.028ms(上一轮 5.892ms)。

## 8. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第九阶段;
Documents/agent_mem.md 同步变更日志与「2026-10-28 第十一轮」硬约束与踩坑。
2026-10-28 17:38:55 +08:00

598 lines
33 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` 的例外规则)。
---
# 第六阶段报告:让"布局边界"变成可以随时打开的一面镜子
## 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`)。