Files
Just-DataInspector/Documents/UI_Refactor_Report.md
T
DeepSeek V4.1 Flash 739a523af5 fix(UI): 浮层不再被下层边界穿透 + 菜单条目裁剪 + 修正性能误判
## 0. 仓库同步

远端 gitea 已把 UI_Refactor 合入 main(PR #1,合并提交 322b658,分支保留)。
main 与 UI_Refactor 均同步到该提交;dev 分支远端从未存在(意外产物),本地已删除。

## 1. 修正上一轮的性能误判(重要)

上一轮报告称「PlotView 基线每帧在 WarmLaneLayersStep 上多花约 2.4ms」。本轮用两级计时查证,
该结论是错的:

  排空前 idle      2.60 ms   ← 含"图层预热队列排空"的瞬时成本
  排空后 idle      0.53 ms   ← 真正的稳态
  强制整帧 idle    0.46 ms

那 2.4ms 是载入 283 个网格后给 16 个网格补建宽基底图层的**瞬时**开销(每帧补 1 个,约 16 帧结束),
属于设计内的预热,不存在每帧白烧的固定成本。

仍修掉顺带发现的一处顺序问题:RollWideBuffer 原来对全部 283 个网格先算 LaneLayerKey()
(逐网格内容哈希 ≈0.7ms/帧)再判断可视性,且离屏网格的"结构不可用"分支会逐帧把它们塞进
待重画队列再被清掉。现在把 LaneVisuallyRelevant 判断提到循环最前面(见函数内注释)。

## 2. 浮动面板被网格边界穿透(层级 + 挖洞 + 裁剪)

根因:PlotView::CollectDebugBoxes 把网格盒子与面板盒子放在同一绘制层,只靠 depth 排序;
而调试线画在所有内容之后,"后画"只决定谁压谁,不能阻止下层组件的线仍然可见。

修复三步:
- 收集面板前 boxes.PushLayer() ⇒ 面板层高于网格层;
- 每个面板 boxes.AddOccluder(layout.panel) ⇒ 绘制更低层盒子时用 Region::Exclude 挖洞;
- boxes.SetClip(layout.panel) ⇒ 面板内部元素(如滚出内容区的行)不再登记到面板外。

## 3. 滚动时面板/菜单边界闪烁或溢出

滚动容器里的条目矩形始终存在,只是绘制时被裁剪掉;调试层读到这些矩形就会给"看不见的内容"画框,
滚动时条目不断进出可视区,框也就不断出现/消失 ⇒ 闪烁 / "边界超出菜单"。

- 面板内容裁到 layout.panel;
- 菜单条目裁到 m_scrollEnabled ? m_scrollViewport : m_rect;二级子菜单条目裁到 m_subRect。

## 4. 顺带小优化

EdgePanelLayoutFor 单次约 2~3µs,283 网格 × 5 类 = 1415 次/帧。现在先看该网格的 5 个面板
open 标志,全关就跳过内层循环。

## 5. 验证

- make release / make debug 零 error 零 warning;TargetCheck.exe 909,824 B(888.5 KiB < 900 KiB)。
- 新增探针 .preflight/lbpanel 10/10:
  panel-open / panel-contents-clipped-to-panel / panel-registered-as-occluder /
  no-lane-bounds-inside-panel(面板内网格层颜色像素=0)/ scrolling-actually-happened
  (真滚动 0.0→1295.1)/ panel-boxes-stable-while-scrolling(11/11 帧签名一致)/
  menu-open-scrollable / no-box-escapes-menu / scrolled-out-items-not-collected。
- 回归:lbcovr 7/7、lbzorder 4/4、lbresid 5/5、lbcheck 21/21、uicheck 69/69。
- rendercheck:8 个场景哈希与上一轮**逐一相同** + FUNC 7/7(本轮不应改变任何正常绘制像素)。

## 6. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第十阶段;
Documents/agent_mem.md 同步变更日志与「2026-10-29 第十二轮」的结论修正、浮层三件事、踩坑。
2026-10-29 10:21:20 +08:00

36 KiB
Raw Blame History

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 个提交

复跑取证:

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)

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. 如何复跑全部验证

# 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. 覆盖率:从"组件自己画"改成"组件收集、宿主统一画"

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 收集。因此面板自己的文字框 能正常显示,但理论上"图例文字"若与面板重叠仍会画在面板上。实际布局下两者不重叠。 若要彻底消除,需要让文本收集器在"面板绘制期间"使用面板层的层号。
  • 菜单/面板的条目裁剪用的是"可视区矩形",因此与边界部分相交的那一条仍会被登记并裁剪绘制 (这是期望行为:它确实可见)。