跳转到内容

SusAudit - 内置检查(Debug / QA)

内置于 sus-coresus-router 中的自动审计模块。 全部通过 #if UNITY_EDITOR || DEVELOPMENT_BUILD 控制开关。 在 release 构建中会被编译器完全剔除 - 运行时零开销

SUS Inspector Editor window with Overview, Inspect, Health, Compile, Connect, and Settings tabs open on the element tree

总览表

#ModuleWhere it's wiredTriggerAutomatic
1ClickAuditSusComponent + ClickAuditService注册元素上的每次 ClickEvent
2BoundsAuditSusComponent 构造函数mount 后 2 帧
3CallbackAuditSusComponent.cs 方法 + 16 个 .sharqClickEvent 处理器的 guard 条件 / 耗时⚠️ .sharq
4OverlayAuditOverlayHost.AddToOverlay()元素加入 overlay
5StateAudit.sharq(SusButton/Toggle/Dropdown/ListGroup/Modal)对相互冲突的 props 做 WatchEffect⚠️ .sharq
6LifecycleAuditSusComponent.OnDetachFromPanelHandler()从 panel 上 Detach
7NavigationAuditSusRouter.Push/Replace/PushNamed/ReplaceNamedResolve() 返回 null
8PerformanceAuditSusComponent 构造函数mount 后 500ms
9DebounceAuditSusComponent 构造函数交互元素上的每次 ClickEvent
10ClickTargetSizeAuditSusComponent 构造函数(与 BoundsAudit 一起)mount 后 2 帧
11StackDepthAuditSusRouter.PushRecord_history.Add() 之后
12GuardAuditSusRouter.Navigate()NavigateCore 之后 - 若结果为 Aborted
13ModalStackAuditOverlayHost.AddToOverlay()modal 加入 overlay
14EmptyStateAudit.sharq(SusListGroup/SusDropdown)Items.Count == 0 但元素处于打开/可见状态⚠️ .sharq
15RemountLoopAuditSusComponent.OnAttachToPanelHandler()1 秒内 >5 次 attach
16OverflowAuditSusComponent 构造函数子元素超出父元素边界(Unity 不做 clip)
17DeadRouteAuditSusRouter(手动调用 AuditUnusedRoutes()已注册但从未使用过的路由🔧 手动
18SusTable StateAudit.sharq(SusTable)WatchEffect - ItemsPerPage > ItemsPage > TotalPages⚠️ .sharq
19LayoutReentryAuditSusComponent.OnGeometryChangedForBreakpoint()500ms 内 >20 次 GeometryChanged 事件
20IdleGuardAuditSusComponent 构造函数元素可见 30 秒以上且没有任何点击
21FocusTrapAudit.sharq(SusModal)FocusEvent - Tab 把焦点移出 modal⚠️ .sharq

✅ = 完全自动,组件中无需添加任何内容。 ⚠️ = 需要一行代码 - SetClickAuditDescription("Name"),或者 .sharq 中的 WatchEffect(已内置于所有组件)。 🔧 = 需要手动调用方法。


1. ClickAudit - 点击是否到达了元素

Files: sus-core/Runtime/Diagnostics/ClickAuditService.cs, sus-core/Runtime/SusComponent.cs

问题: 元素已注册为可点击,但点击时事件没有到达它 - 被上方的另一个元素拦截了(tooltip、overlay、pickingMode 设置错误的 modal)。

工作原理:

  • ClickAuditService.Instance.Install(panel) - 在第一次 SusBootstrap.Mount<T>() 时自动调用。
  • 该 service 在 panel.visualTree 上注册一个全局 ClickEvent handler。
  • 每次点击时,将 evt.target 与已注册的元素做比较。
  • 如果点击没有命中任何已注册的元素,会记录下拦截元素所在的层。

警告示例:

[ClickAudit] Active: 'SusButton' blocked at center. Reason: Covered by 'SusTooltip'

接入方式: 元素在 Created() 中通过 SetClickAuditDescription("Name") 自我注册。已内置于 50 个下游组件(kit 40 + game 10) 。


2. BoundsAudit - 尺寸是否非零

File: sus-core/Runtime/SusComponent.cs(构造函数)

问题: Unity UITK 不会对尺寸为零的元素调用 ContainsPoint - 点击会直接穿过去。常见原因:

  • USS/styles 中没有设置 flexGrowwidthheight
  • 父元素没有分配任何尺寸
  • display: none / visible: false
  • 元素还没有挂载到 panel 上

工作原理: 通过 schedule.Execute(...).StartingIn(150) 做延迟检查 - 等待 2-3 帧让布局稳定下来,然后检查 worldBound.widthworldBound.height

警告示例:

[BoundsAudit] 'SusUnitCard' has zero bounds after mount (0×0).
  display=Flex, visible=True, pickingMode=Position.
  Clicks will NOT reach this element.

3. CallbackAudit - OnClick 是否真的执行了

Files: sus-core/Runtime/SusComponent.cs, 16 个 .sharq

问题: ClickEvent 触发了,元素可以被点中,点击也穿透过去了 - 但 handler 却没有被调用。常见原因:

  • Disabled.Value = true → handler 提前 return
  • Loading.Value = true → handler 提前 return
  • 某个 guard 条件:if (someCondition) return;
  • handler 执行了,但耗时 > 50ms(可疑地长)

两种子模式:

3a. Guard-lock(AuditClickBlocked

csharp
// In SusButton.sharq:
if (Disabled.Value)
{
    AuditClickBlocked("Disabled");  // ← warning to the console
    return;
}

示例:[CallbackAudit] 'SusButton' click blocked: Disabled

3b. 计时(AuditClickStart / AuditClickEnd

csharp
var t0 = AuditClickStart();
OnClick?.Invoke();
AuditClickEnd(t0);  // if > 50ms → warning

示例:[CallbackAudit] 'SusButton' OnClick took 127.3ms

已接入(16 个 .sharq): SusAlert, SusButton, SusDropdown, SusEmptyState, SusForm, SusLink, SusListGroup, SusMenuButton, SusNumberInput, SusPagination, SusRating, SusSnackbar, SusStepper, SusTabs, SusToggle, SusUnitCard


4. OverlayAudit - UI 重叠

File: sus-core/Runtime/OverlayHost.cs

问题: 当一个元素被加入 OverlayHost(tooltip、dropdown、modal)后,它可能通过自己内部可拾取的子元素拦截原本要发给下层元素的点击。

工作原理:AddToOverlay() 中,元素插入之后,Query<VisualElement>() 会查找所有 pickingMode == Position 的后代元素;如果找到了(且分类不是 Modal)就会发出警告。

警告示例:

[OverlayAudit] 'SusTooltip' added to overlay in Tooltip category
  with 3 pickable children. It may block clicks to underlying UI.
  Consider setting pickingMode=Ignore on children.

5. StateAudit - prop 一致性

Files: SusButton.sharq, SusToggle.sharq, SusDropdown.sharq, SusListGroup.sharq, SusModal.sharq

问题: 状态组合互相矛盾 - Disabled=true 同时 Loading=true(spinner 永远不会显示)、IsOpen=true 同时 Disabled=true(下拉框打开了但处于不可用状态)、Selected 不在 Items 中(没有任何高亮项)、Model=false 但 modal 仍然可见。

工作原理: 每个 .sharq 组件内部有一个 WatchEffect 监视相关的 prop 组合。

ComponentRule
SusButtonDisabled && Loading → 两者同时为 true
SusToggleDisabled && Loading → 两者同时为 true
SusDropdownIsOpen && Disabled → 打开但被禁用
SusListGroupSelected 不在 Items 中 → 没有任何高亮项
SusModalModel=falsedisplay ≠ None → 隐藏的 modal 仍然可见

警告示例:

[StateAudit] SusButton: both Disabled and Loading are true.
  Loading spinner will never be visible.

[StateAudit] SusDropdown: IsOpen=true while Disabled=true.
  Dropdown should close when disabled.

[StateAudit] SusListGroup: Selected value is not in Items.
  List will have no highlighted item.

[StateAudit] SusModal: Model=false but element is still visible
  (display=Flex). Modal should be hidden.

6. LifecycleAudit - 订阅泄漏

File: sus-core/Runtime/SusComponent.csOnDetachFromPanelHandler

问题: 元素已经从 panel 上移除,但它对 Prop<T>.Changed 的订阅仍然存活 - 这是内存泄漏,也是重新挂载时出错的原因。

工作原理: detach 时记录 _bindings.Count;1 秒后检查 panel 是否仍为 null 且订阅数量没有下降 → 判定为泄漏。

警告示例:

[LifecycleAudit] 'SusUnitCard' detached but 3 bindings were not disposed.
  Possible leak.

7. NavigationAudit - 未解析的路由

File: sus-router/Runtime/SusRouter.cs

问题: 调用了 SusRouter.Push("/settings") 但路径无法解析(Resolve 返回 null)- 用户点了按钮但什么都没发生。

工作原理: 每个导航方法(PushReplacePushNamedReplaceNamed)在 record == null 时发出警告。

警告示例:

[NavigationAudit] Push('/battle/unknown') — route not found.

8. PerformanceAudit - 元素过多

File: sus-core/Runtime/SusComponent.cs(构造函数)

问题: 层级过深或子元素过多(>500 个 VisualElement)→ PickAll 变慢、layout 耗时变长、UI 卡顿。

工作原理: mount 后 500ms,Query<VisualElement>() 统计子树中所有元素的数量。若 > 500 → 警告。

警告示例:

[PerfAudit] 'SusTable' has 1423 VisualElements.
  Consider virtualization (SusTable) or paging.

9. DebounceAudit - 双击

File: sus-core/Runtime/SusComponent.cs(构造函数)

问题: 用户在很短时间内(< 300ms)快速点击按钮两次 - 可能导致重复提交(两次 API 调用、扣两次货币等)。

工作原理: SusComponent 构造函数注册了一个 ClickEvent 回调(它会在 Created() 中注册的 handler 之前触发 - UITK 保证这个注册顺序)。对于每个交互元素(通过 SetClickAuditDescription 注册),它会记录上次点击的时间;如果间隔小于 300ms,就发出警告。

警告示例:

[DebounceAudit] 'SusButton' rapid double-click (87ms).
  Possible unintended double-submit.

注意: 这是一个审计,不是拦截。它不会阻止第二次点击 - 只是提醒开发者需要在业务逻辑里做防抖。


10. ClickTargetSizeAudit - 点击目标太小

File: sus-core/Runtime/SusComponent.cs(构造函数,与 BoundsAudit 一起执行)

问题: 一个交互元素(按钮、图标、复选框)小于 30×30px - 手指或鼠标难以点中。HIG 建议最小 44×44px。

工作原理: 与 BoundsAudit 一起,对于设置了 SetClickAuditDescription 的元素 - 如果 worldBound 大于 0 但在某个轴上小于 30px → 警告。

警告示例:

[ClickTargetAudit] 'SusIcon' tap target is small (16×16px).
  HIG recommends ≥44×44. Consider padding or min-size.

11. StackDepthAudit - 导航栈深度

File: sus-router/Runtime/SusRouter.cs

问题: 导航历史无限增长(> 50 条)- 可能是循环导航(A → B → A → B...),或者本该用 Replace() 却误用了 Push()

工作原理: 每次 PushRecord_history.Add() 之后,检查 _history.Count > 50

警告示例:

[StackDepthAudit] Router history has 67 entries.
  Possible circular navigation or unbounded stack growth.
  Consider using Replace() instead of Push().

12. GuardAudit - 被拒绝的导航

File: sus-router/Runtime/SusRouter.cs

问题: 用户点了导航按钮,但导航被某个 guard 或生命周期钩子静默拒绝了 - 屏幕没有切换,也没有显示任何错误。

工作原理: Navigate()NavigateCore 之后唯一的拦截点。如果结果是 NavigationResult.Aborted → 警告并附带 from→to 路径。

警告示例:

[GuardAudit] Nav from '/lobby' → '/battle' was rejected by a guard
  or lifecycle hook (BeforeLeave/CanLeave/BeforeEach/CanEnter/BeforeResolve/BeforeEnter).

覆盖所有中止点: BeforeRouteUpdateBeforeLeaveCanLeave(ISusRouteGuard)、BeforeEach(全局)、CanEnter(ISusRouteGuard)、BeforeResolve(全局)、BeforeEnter


13. ModalStackAudit - modal 过多

File: sus-core/Runtime/OverlayHost.cs

问题: 屏幕上同时打开了超过 5 个 modal - 要么是 bug(modal 没有正确关闭),要么是糟糕的 UX(用户分不清哪个 modal 是当前活跃的)。

工作原理:AddToOverlay() 中,元素加入之后统计 _stack.Count(e => e.Category == OverlayCategory.Modal)。若 > 5 - 警告。

警告示例:

[ModalStackAudit] 7 modals on screen.
  Deep modal stacking may indicate a flow bug or unclosed modals.

架构

sus-core/Runtime/
├── SusComponent.cs            ← BoundsAudit, ClickTargetAudit, PerfAudit,
│                                 DebounceAudit, LifecycleAudit, RemountLoopAudit,
│                                 OverflowAudit, IdleGuardAudit, LayoutReentryAudit,
│                                 AuditClickBlocked/Start/End
├── Diagnostics/
│ └── ClickAuditService.cs ← ClickAudit (global click interception)
├── OverlayHost.cs             ← OverlayAudit, ModalStackAudit

sus-router/Runtime/
└── SusRouter.cs               ← NavigationAudit, GuardAudit, StackDepthAudit,
                                  DeadRouteAudit

Downstream UI packages may register additional component-level audits
(CallbackAudit, StateAudit, EmptyStateAudit, FocusTrapAudit, …).

开启 / 关闭

所有模块在以下条件下处于激活状态:

csharp
#if UNITY_EDITOR || DEVELOPMENT_BUILD

在 release 构建中(!UNITY_EDITOR && !DEVELOPMENT_BUILD),这些代码会被编译器完全剔除 - 一个字节、一次调用都不会留下。


14. EmptyStateAudit - 空列表 / 空下拉框

Files: SusListGroup.sharq, SusDropdown.sharq

问题: Items.Count == 0,但元素处于打开或可见状态 - 用户看到的是一个空容器。

工作原理: .sharq 内部的 WatchEffect 追踪这个组合。

警告示例:

[EmptyStateAudit] SusListGroup: Items is empty but element is visible.
[EmptyStateAudit] SusDropdown: IsOpen=true but Items is empty.

15. RemountLoopAudit - 循环重新挂载

File: sus-core/Runtime/SusComponent.csOnAttachToPanelHandler

问题: 1 秒内 > 5 次 attach → 通常是响应式的 bug(某个 WatchEffect 反复切换可见性/布局)。

警告示例:

[RemountLoopAudit] 'SusUnitCard' attached 7 times in 1s.

16. OverflowAudit - 子元素超出边界

File: sus-core/Runtime/SusComponent.cs(构造函数)

问题: 子元素超出了父元素的边界。Unity UITK 不支持 overflow: hidden

警告示例:

[OverflowAudit] 'SusCard' has 3 child(ren) exceeding parent bounds (320×180).

17. DeadRouteAudit - 未使用的路由

File: sus-router/Runtime/SusRouter.cs

问题: 已注册但从未被导航到过的路由属于死代码。

调用方式: router.AuditUnusedRoutes()(手动,从 dev panel 或测试中调用)。

警告示例:

[DeadRouteAudit] 3 registered routes were never navigated to:
  - /settings/audio (settings-audio)

18. SusTable StateAudit - 分页不一致

File: SusTable.sharq

问题: ItemsPerPage > Items.Count - 一页就能放下所有内容,分页控件是多余的。Page > TotalPages - 表格会渲染出空行。

工作原理: 一个 WatchEffect 监视 Controller.Value.ItemsItemsPerPagePageTotalPages

警告示例:

[StateAudit] SusTable: ItemsPerPage=25 but only 3 items. Single page.
[StateAudit] SusTable: Page=5 exceeds TotalPages=3. Table may show empty rows.

19. LayoutReentryAudit - 递归布局

File: sus-core/Runtime/SusComponent.csOnGeometryChangedForBreakpoint

问题: 某个 WatchEffect 改变了尺寸/位置 → GeometryChangedEventUpdated() → 同一个 WatchEffect 再次执行 → 死循环。UI 在高 CPU 负载下卡死。

工作原理: 用一个 500ms 的滑动窗口统计 GeometryChangedEvent 发生的次数。若每个窗口内 > 20 次 - 警告。

警告示例:

[LayoutReentryAudit] 'SusCard' 47 geometry changes in 500ms.

20. IdleGuardAudit - 元素从未被点击过

File: sus-core/Runtime/SusComponent.cs(构造函数)

问题: 一个交互元素已经可见 30 秒以上,却一次都没被点击过 - 可能是 pickingMode=Ignore、被一个透明的 overlay 盖住了,或者是忘记写 ClickEvent handler。

工作原理: mount 后 30 秒做一次性检查。若 _lastClickTime == 0 - 发出警告并附带诊断细节(pickingMode、worldBound)。

警告示例:

[IdleGuardAudit] 'SusButton' visible but never clicked.
  pickingMode=Ignore, worldBound=(320,180 80×32).

21. FocusTrapAudit - 焦点逃出 modal

File: SusModal.sharq

问题: modal 打开时,Tab 把焦点移到了它后面的元素上 - 这是一个可访问性问题。

工作原理: FocusEvent + this.Contains(focused)。若 Model=trueevt.target 不在 modal 内 - 警告。

警告示例:

[FocusTrapAudit] SusModal: focus escaped outside modal to 'TextField'.

相关文档

22. ScreenAudit - 屏幕结构的文本转储

File: sus-core/Runtime/Diagnostics/ScreenAudit.cs

问题: 我们需要在不启动 Unity Editor UI 的情况下回答“用户在屏幕上看到了什么?”。 AI agent 没法看屏幕 - 它需要一份文本报告。

工作原理: 三种文本转储模式,写入 Debug.Log(通过 read_console MCP 工具读取)。

22.1 LayoutDump - 带坐标的全元素地图

══════════ LayoutDump ══════════
Root: TemplateContainer  panel=True
Screen: 1920×1080

│SusApp ⚙ 🖱 [3ch] flex (0,0 1920×1080)
│  classes: .sus-app .theme-dark
│  📍clickable area: (0,0)→(1920,1080)
││SusMainMenu ⚙ 🖱 [1ch] flex (0,0 900×1080)
││  📍clickable area: (0,0)→(900,1080)
│││SusButton ⚙ 🖱 [1ch] flex (300,200 200×40)
│││  📍clickable area: (300,200)→(500,240)
││SusSettings ⚙ ⊘ HIDDEN [0ch] none (0,0 0×0)

图标说明:

  • = SusComponent,🖱 = 可点击, = 忽略点击(picking disabled)
  • HIDDEN = 不可见(display: none / visible=false / 尺寸为零)
  • 📍clickable area = 实际可点击区域

22.2 PickableLayerAudit - 可点击元素的 z 序

══════ PickableLayerAudit ══════
Z-order = DOM order (last sibling = topmost). No z-index in Unity.

[LAYER 000] SusMainMenu ⚙ ⚠OVERLAPPED
  area: (0,0 900×1080)
  picking=Position visible=True enabled=True
  ← blocked by [001]SusOverlayPanel
[LAYER 001] SusOverlayPanel ⚙
  area: (0,0 900×1080)
  picking=Position visible=True enabled=True

⚠ 2 elements have overlapping bounds with higher z-order elements.

读法: [LAYER 000] - 数字越大,离观察者越近(绘制在越上层)。 ⚠OVERLAPPED - 有一个 z 序更高的元素与该元素重叠:点击会落到上层的元素上。

22.3 FullPropsDump - 每个 SusComponent 上每个 Prop 的值

══════ FullPropsDump ══════
── SusMainMenu #main-menu (900×1080)
  IsOpen = true
  Mode = "deployment"
── SusButton #play-btn (200×40)
  Text = "PLAY"
  Disabled = false
  Loading = false
  Variant = "primary"
Total: 2 SusComponents dumped.

快捷键

Ctrl+Shift+~ - 一次性把三种转储都跑一遍并输出到 console。由 SusBootstrap 自动接好。

自动转储

每次路由导航时PushReplacePushNamedReplaceNamedBackForwardGo)- 一次成功的跳转完成之后,会自动记录以下内容:

  • LayoutDump - 新屏幕上的元素地图
  • FullPropsDump - 每个 SusComponent 上每个 Prop 的值

每次 SusButton 被点击时 - OnClick?.Invoke() 执行完之后,会自动运行 FullPropsDump。这让 agent 能拿到按钮动作执行完成后 UI 状态的快照。

这两种机制都受 #if UNITY_EDITOR || DEVELOPMENT_BUILD 控制,在 release 构建中会被完全剔除。

Agent 使用方式

# Via MCP: read Unity console
read_console -> filter_text="[LA]" # LayoutDump (element map)
read_console -> filter_text="[PA]" # PickableLayer (z-order)
read_console -> filter_text="[FP]" # FullPropsDump (props values)

Agent 得到的是屏幕的文本画面,可以:

  • 理解结构:有哪些组件、以什么顺序排列
  • 发现重叠:哪些元素挡住了点击
  • 查看当前状态:所有组件的 prop 值