Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cbb589c
perf(sidebar): rebuild the filter pipeline off-main with guarded didSets
Mx-Iris Aug 4, 2026
ebbbe68
perf(content): split the text pipeline so theme changes stop re-fetching
Mx-Iris Aug 4, 2026
ffd5d45
perf(content): cache interfaces so navigation revisits skip the engine
Mx-Iris Aug 4, 2026
98524e8
test(infra): serialize shared-engine tests behind a cross-suite lock
Mx-Iris Aug 4, 2026
fda5fa2
perf(sidebar): materialize Open Quickly rows lazily on match
Mx-Iris Aug 4, 2026
9543909
perf(sidebar): move root image-tree filtering off the main thread
Mx-Iris Aug 4, 2026
c0c1684
perf(ui): coalesce outline expansion autosave into one walk per burst
Mx-Iris Aug 4, 2026
81b5405
docs(plans): record the open-quickly/root/outline perf landing
Mx-Iris Aug 4, 2026
70733b9
perf(ui): collapse per-cell appearance observables into a single stream
Mx-Iris Aug 9, 2026
a2770de
fix(uikit): keep sidebar search case-insensitive under the honest eng…
Mx-Iris Aug 9, 2026
30d6fef
fix(content): track the render half and stop churning a queue per render
Mx-Iris Aug 9, 2026
dc5df85
perf(sidebar): reset the empty-query fast path without building a sna…
Mx-Iris Aug 9, 2026
b7eea71
perf(filter): guard the empty query before materializing haystacks
Mx-Iris Aug 9, 2026
337ccd9
chore(sidebar): drop a dead dependency and pin the root pipeline's co…
Mx-Iris Aug 9, 2026
7f71534
docs(known-issues): record the PR #88 review adjudications
Mx-Iris Aug 9, 2026
3a99ca6
fix(ui): stop a stale flush from wiping the outline expansion autosave
Mx-Iris Aug 10, 2026
920c2aa
fix(sidebar): invalidate the root filter pass in the same turn as the…
Mx-Iris Aug 10, 2026
9f32e85
fix(content): key cached interfaces by the object the interface names
Mx-Iris Aug 10, 2026
523d98d
perf(sidebar): install a superseded Open Quickly pass's haystack build
Mx-Iris Aug 10, 2026
b3c6509
perf(sidebar): seed materialized Open Quickly cells with the pass's h…
Mx-Iris Aug 10, 2026
1123875
perf(sidebar): cap Open Quickly materialization at the top 500 matches
Mx-Iris Aug 10, 2026
4a97946
docs(known-issues): adjudicate the second PR #88 review pass
Mx-Iris Aug 10, 2026
9e6ca6a
docs(task-reports): record the PR #88 fix landings
Mx-Iris Aug 11, 2026
91e2169
fix(content): let a repeat link click hit the interface it already fe…
Mx-Iris Aug 13, 2026
e23725f
perf(sidebar): stop rediscovering offset 0 by scanning the whole hays…
Mx-Iris Aug 13, 2026
37c7a47
perf(sidebar): share one Open Quickly haystack build and invalidate i…
Mx-Iris Aug 13, 2026
7276ae9
docs(known-issues): adjudicate the third PR #88 review pass
Mx-Iris Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ When adding new features, you **MUST** follow these rules:
5. **Swift Language Mode**: All packages use `swiftLanguageModes: [.v5]`
6. **Singletons go through `@Dependency`**:每个项目 singleton 都声明为 `fileprivate static let shared`,并通过 `extension DependencyValues` 中的 `@DependencyEntry` 暴露。调用方统一使用 `@Dependency(\.xxx)`;禁止 `public static let shared`,也禁止在定义文件外调用 `Foo.shared.bar()`。详见 Code Style 下的 **Singletons & Dependency Injection**。
7. **AppDelegate stays thin**: AppDelegate is a dispatch shell, not a service container. Every non-trivial lifecycle responsibility (appearance, debug menu, update checking, version probes, etc.) lives in its own `@MainActor` controller class under `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/`, registered via `@Dependency` per rule #6. See **AppDelegate Convention** under Code Style.
8. **Single-object interface fetches go through the document's interface cache**: fetch one object's interface via `documentState.interfaceCache.interface(for:options:)` with `ViewModel.currentMergedGenerationOptions` as the options — never bare `appDefaults.options` and never `runtimeEngine.interface(...)` directly — so cache keys line up with the content pane and exported text matches what it displays. Bulk consumers (interface export, MCP tools) deliberately bypass the cache and call the engine directly; do not route them through it. See `Documentations/Plans/2026-08-04-navigation-interface-cache.md`.

## Code Style

Expand Down
132 changes: 132 additions & 0 deletions Documentations/Evolutions/0005-cellvm-appearance-single-observed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# 0005 - 高基数 Cell ViewModel 的 Appearance 单流化

- **状态**: Implemented
- **作者**: JH
- **日期**: 2026-08-09
- **关联**: [0004](0004-differentiable-box-lazy-cellvm.md)(本提案与其互补:0004 处理「短命 static cellVM」,本提案处理「长寿命 stateful cellVM」)

## 摘要

把高基数(N >= ~1k)长寿命 cell ViewModel 上的多个分立 `@Observed` 外观属性(`primaryIcon` / `secondaryIcon` / `tertiaryIcon` / `title` / `subtitle`)合并为**单个** `@Observed appearance: RuntimeObjectCellAppearance` 结构体属性,`RuntimeObjectCellDisplayable` 协议相应从 4 个分立 driver 收敛为 1 个 `appearanceDriver`。每行的 Rx 固定成本从 5 套 subject + lock 降到 1 套,行为与渲染结果不变。

首批适用对象:`SidebarRuntimeObjectCellViewModel`(浏览路径,每镜像数千行)与 `SidebarRootCellViewModel`(镜像列表,常驻 ~13k 行)。

## 动机

三轮库侧内存优化(MachOSwiftSection 0001/0002/0003 + swift-demangling 0010/0011)落地后,五镜像稳态从 470-480 MB 降到 262 MB,库侧六个堆簇在全量浏览压力下全部横住。此时唯一仍随浏览量线性增长的成本只剩 RV 自己的 UI 管线,2026-08-09 实测(用户拖拽浏览 SwiftUI 全部 RuntimeObject 后):

- `NSRecursiveLock`:**125,225 把 / 28.1 MiB**(干净基线 54k),全进程第一大 ObjC 类;
- UI/Rx 簇 46.7 MiB(基线 21.4),占浏览堆增量 ~60%;
- 增量来源:+6,895 个 `SidebarRuntimeObjectCellViewModel`,每个带 5 个 `@Observed`。

`@Observed`(RxSwiftPlus)的存储是 `BehaviorRelay`,每个实例内含 `BehaviorSubject` + `NSRecursiveLock`(224 B)+ 同步追踪器,单属性全套 ~450-500 B。5 个属性 × 每行 ≈ 2.2-2.5 KB 的纯管线开销,而这些属性**只在 filter 变化与 specialization splice 时才更新**——事件频率完全撑不起每属性一条流。

lazy 路线走不通:[0004](0004-differentiable-box-lazy-cellvm.md) 的「反模式案例 #1」已裁定 Sidebar cellVM 必须保持 eager(filter 感知 attributed name 的订阅身份、树结构、splice 复用)。本提案是与其正交的另一条路:**不动 eager 树、不动任何行为语义,只减每行的流数量**。

### 非目标

- **不**改 `@Observed` 本身或 RxSwiftPlus 的锁实现——那是上游改动,影响全项目每一个 `@Observed`,风险面完全不同(可作为后续独立提案)。
- **不**动 cellVM 的树结构、filter pipeline、splice 逻辑、`StableID` / `Differentiable` 身份——全部保持原样。
- **不**处理低基数 cellVM(Inspector 各 tab、popover 等 N < 数百的场景)——eager 多流在那些量级下无感,改了徒增 churn。
- **不**触碰 `SpecializationTypePickerCellViewModel`——它走 0004 的 lazy 短命路线,构造后即弃,无长寿命流成本。

## 提议方案

### 1. `RuntimeObjectCellAppearance` 结构体(RuntimeViewerApplication)

```swift
public struct RuntimeObjectCellAppearance: Equatable {
public var primaryIcon: NSUIImage
public var secondaryIcon: NSUIImage?
public var tertiaryIcon: NSUIImage?
public var title: NSAttributedString
public var subtitle: NSAttributedString?
}
```

成员均为引用类型或 Optional 引用,struct 拷贝只是引用拷贝;`Equatable` 用于 `didSet` 去重(等值不发事件)。

### 2. cellVM 改造

```swift
@Observed
public private(set) var appearance: RuntimeObjectCellAppearance
```

`refreshAppearance()` / `rebuildTitleForFilterResult()` 改为组装完整 struct 后一次赋值——顺带把「一次刷新发 5 个事件」的既有冗余消掉,发布变为原子。

### 3. `RuntimeObjectCellDisplayable` 协议收敛

```swift
public protocol RuntimeObjectCellDisplayable {
var appearanceDriver: Driver<RuntimeObjectCellAppearance> { get }
}
```

cell view 的 `bind(to:)` 单订阅、单闭包内更新全部 outlet。订阅数从每可视行 4 条降到 1 条(可视行仅 ~12 行,此处收益次要,主要收益在 cellVM 侧的存储)。

## 影响(App 型)

- **用户可见变化**:无。渲染结果要求逐字节一致(filter 高亮、图标、Open Quickly 均不变)。
- **可发现性**:不适用。
- **数据与配置兼容**:无持久化数据涉及。
- **平台与最低版本**:不变。
- **发布影响**:纯内部重构,无发布注意事项。

## 预期收益与验收标准

同款负载(五镜像索引 + SwiftUI 全量拖拽浏览)对照 2026-08-09 基线:

| 指标 | 基线 | 验收线 | 实测(2026-08-09 落地后) |
|---|---|---|---|
| `NSRecursiveLock` 实例数(全量浏览后) | 125,225 | **≤ 45,000** | **42,218** ✓(−66%;与 6,895 行 × 1 流的推算 ≈ 41k 吻合。稳态为 27,341) |
| UI/Rx 堆簇(全量浏览后) | 46.7 MiB | **≤ 32 MiB** | **17.7 MiB** ✓(−62%) |
| 五镜像稳态堆存活(含 13k 行镜像列表) | 210 MiB | **≤ 205 MiB** | **196.7 MiB** ✓(含 0006 的 NIO 回收;footprint 262 → 239 MB,索引峰值 613 → 546 MB) |
| 行为回归 | — | 现有测试全绿 + filter 高亮 / splice / Open Quickly 手测无回归 | 包内 14 测试全绿(filter 基线的精确发射计数 100/10000/10000 保持不变);用户以同款负载全量拖拽浏览完成复测,无异常反馈 |

**稳态锁减半的构成**:镜像列表 13,159 行 × 每行 2 流(icon + name)→ 1 流,每流约 2 把锁(`BehaviorRelay` + `BehaviorSubject` 各一),恰好对应 54k → 27.3k。heap 中 `BehaviorSubject<SidebarRootCellViewModel.Appearance>` 计数 13,159,与行数一一对应。

## 风险与假设

1. **事件粒度变粗**:原来 title 单独变化只重设 title,现在整个 struct 重发、5 个 outlet 全重设。事件频率低(filter 键入节流后 / splice 一次性),单事件多 4 次赋值可忽略;`SidebarFilterPerformanceBaselineTests` 把关键路径(nil→nil 跳过)钉住,保持不动。
2. **`Equatable` 去重的比较成本**:`NSAttributedString` 的 `==` 在 title 确实变化时才走全比较;等值路径(占绝对多数)由既有 `oldValue == nil, filterResult == nil` 早退挡住,不经过 struct 比较。
3. **协议收敛波及面**:`RuntimeObjectCellDisplayable` 的全部 conformer 与消费 cell view 需同批改;grep 确认后列入落地清单。

## 替代方案考量

### A. Lazy cellVM(DifferentiableBox)

被 0004 明确列为反模式:filter 感知 attributed name 依赖订阅身份,lazy 重建即失效。不重议。

### B. 上游改 RxSwiftPlus:`@Observed` 换 `os_unfair_lock` / lock-free

收益量级相近(224 B 锁 → 8 B),且惠及全项目。但改动在上游仓库、影响所有 `@Observed` 调用点的并发语义(NSRecursiveLock 可重入,unfair lock 不可),需要独立评审与全量回归。作为后续候选提案,不与本提案捆绑。

### C. cellVM 不持有外观,cell 渲染时从模型现算

即「半 lazy」:外观退化为纯函数。filter 高亮需要 cellVM 持有 `filterResult` 并在变化时通知 cell——通知机制绕一圈还是一条流,复杂度不降反升,且打破 0004 划定的两范式边界。

## 测试策略

- 现有 `SidebarFilterPerformanceBaselineTests`、`OpenQuicklyLazyConstructionTests` 全绿(行为契约不变的机器证明)。
- 新增单测:appearance 原子性(一次 `refreshAppearance()` 恰好一个事件)、等值不发事件。
- 验收数字用与基线同款的 `heap -sortBySize` 流程复测(agent 侧已有成套脚本)。

## 落地步骤

1. `RuntimeObjectCellAppearance` + `RuntimeObjectCellDisplayable` 收敛 + 两个 Sidebar cellVM 改造 + cell view 适配,单 commit;
2. 回归测试 + heap 验收复测,数字回填本提案;
3. 状态 `Accepted` → `In Progress` → `Implemented` 随批次原地更新。

## 落地记录(2026-08-09)

改动清单(与提案方案一致,另含协议收敛的连带面):

- 新增 `RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellAppearance.swift`(跨平台,不带 `#if` gate;协议仍仅 AppKit)。
- `SidebarRuntimeObjectCellViewModel`:5 `@Observed` → 1;`refreshAppearance()` 原子组装(顺带消除旧实现「tertiaryIcon 不清零」的隐性残留);`rebuildTitleForFilterResult()` 只换 title;新增 `publishAppearance(_:)` 等值去重。
- `SidebarRootCellViewModel`:2 `@Observed` → 1(嵌套 `Appearance` struct,形态与 5 字段共享结构体不同故单独建型)。
- 协议 conformer 连带单流化:`InspectorSwiftSpecializationCellViewModel`、`InspectorRelationshipsCellViewModel`、`SpecializationTypePickerCellViewModel`(均为 init 一次组装)。
- 消费面:`RuntimeObjectCellView`(单订阅 + `apply(_:)`)、`SidebarRootTableCellView`、两处 `typeSelectStringFor` 直读、UIKit 侧 3 处直读属性。
- 测试:`TitleRebuildCounter` 改观察 `$appearance`(去重语义下计数含义不变,精确计数全部保持);新增 `SidebarCellAppearanceTests`(一次过渡恰一事件、等值重放零事件、display-neutral splice 零事件——最后一项还消除了旧实现的冗余重绘)。

**验收复测(2026-08-09,用户实例,同款负载:五镜像索引 + SwiftUI 全量拖拽浏览)**:浏览增量与基线完全同构(`SidebarRuntimeObjectCellViewModel` 恰为 6,895 个),两条浏览验收线全部达标——`NSRecursiveLock` 125,225 → **42,218**(9.5 MiB,验收线 ≤45k),UI/Rx 簇 46.7 → **17.7 MiB**(验收线 ≤32)。全量浏览后堆存活 242 MiB、footprint 346 MB(含 59 MB 待回收页)、索引峰值 789 MB。heap 中 `BehaviorSubject<SidebarRootCellViewModel.Appearance>` 13,189 / `BehaviorSubject<RuntimeObjectCellAppearance>` 6,896,与行数一一对应,单流化按设计生效。
60 changes: 60 additions & 0 deletions Documentations/KnownIssues/2026-08-09-pr88-review-findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# PR #88(perf/pipeline-optimizations)审查发现裁决 — 2026-08-09

对 PR #88 的 xhigh 级 code review(15 条发现,逐条完成「四问」)经跨会话双向复核后的最终裁决。
审查基线:分叉点 `8e72b6c` vs 分支 `70733b9`。审查报告由发起会话持有;本文件是**留档的裁决结论**:
误报与暂不修的记录在此,已修的登记修复 commit。ID 形式 `PR88.<N>`,编号与原报告一致。

## 已修(本批次,2026-08-09)

| ID | 严重度 | 摘要 | 修复 commit |
|---|---|---|---|
| PR88.2 | Blocker | UIKit 侧 `.just(false)` 在引擎语义如实化后使 iOS 搜索区分大小写(本 PR 引入的回归) | `a2770de`(含引擎层语义契约测试 `FilterEngineCaseSensitivityTests`) |
| PR88.3 | Major | fetch/render 拆分后 render 半段不在 `trackActivity` 内:主题/字号变更全程无加载指示;缓存命中时用户全部等待落在无指示半段 | `30d6fef`(新测试修复前红、修复后绿) |
| PR88.4 | Major | 空查询快路径先建整棵 snapshot forest 再发现不需要(reload 后 haystack 缓存冷,O(N) 白做) | `dc5df85`(新增 snapshot-free `resetToUnfiltered`,等价性测试 `SidebarFilterFastPathTests`) |
| PR88.5 | Major | `FilterEngine.filter` 先跑 `match` 再被空查询 guard 丢弃结果 | `b7eea71` |
| PR88.6 | Major | render 半段每次发射在闭包内新建 `ConcurrentDispatchQueueScheduler`(= 每次一个新 DispatchQueue) | `30d6fef`(与 PR88.3 同 commit,同一代码区域) |
| PR88.8 | Minor | `SidebarRootFilterPipeline.verdicts` 空查询会清空整棵树,仅靠唯一调用方守约 | `337ccd9`(加契约 assert;行为不变) |
| PR88.11 | Minor | cellVM 上 `appDefaults` 自 `cbb589c` 起零引用(`@Dependency` 不触发未使用告警) | `337ccd9`(删除死属性) |

## False positive(误报,留档防止重查)

### PR88.1 — 「macOS 侧边栏过滤框在首次点击 Case Insensitive 按钮前完全不生效」

**裁决:误报,整条撤销**(发起会话已确认撤销)。运行时最小 probe + 源码双证:

- 原推演认为 `NSButton().rx.state` 落到 RxAppKit `HasTargeAction+Rx.swift` 的
`@dynamicMemberLookup` 转发(该实现确实无订阅初值),于是
`Driver.combineLatest(searchString, isSearchCaseInsensitive)` 被闸死。
- 实际决议:**RxCocoa 在 macOS 上自带具体成员 `NSButton.rx.state`**
(`RxCocoa/macOS/NSButton+Rx.swift:21`),具体成员在 Swift 重载决议中永远压过
`@dynamicMemberLookup`;RxCocoa 的 `controlProperty` 在 `Observable.create` 体内
**先 `observer.on(.next(()))` 再装 `ControlTarget`**(`NSControl+Rx.swift:61`)——
订阅瞬间即发一次当前值。
- 运行时验证(RxAppKit 0.5.4,与仓库 pin 同版):订阅即得初值;程序赋值
`state = .on` 不发射(这点原推演正确);combineLatest 从未被闸。
`searchCaseInsensitiveButton.state = .on` 在视图构造期执行、先于 `setupBindings`
订阅,因此初值捕获 `.on` → 默认大小写不敏感在 macOS 上真实生效。

**连带撤销**:本仓其余 4 处 `rx.state` 调用点同样解析到 RxCocoa,无需排查。

**留档的真实陷阱(这是这条误报里值得记住的部分)**:RxAppKit 的
dynamicMemberLookup / 自有 ControlProperty **确实不发初值**(同文件
`click(with:isStartWithDefaultValue:)` 的显式开关反证这是既定设计)。分界反直觉:
**同一个 NSButton 上,`rx.state`(RxCocoa)有初值,`rx.isCheck` /
`rx.stateBoolValue`(RxAppKit 自有实现)没有。** 对没有 RxCocoa 具体成员兜底的属性,
`combineLatest` 闸死的风险是真实存在的。判此类问题的排查顺序:先查 RxCocoa `macOS/`
有没有该控件的具体成员,再查 RxAppKit `Components/`,都没有才轮到 dynamicMemberLookup。

## 暂不修(backlog,后续拾起)

| ID | 严重度 | 摘要 | 状态与理由 |
|---|---|---|---|
| PR88.7 | Minor | `SemanticString+ThemeProfile` 出口处 `.copy()` 对大接口多一次深拷贝 + 瞬时 2× 峰值 | 保留拷贝(跨线程不可变性契约,已有注释);用现成 `content.attributedStringBuild` signpost 实测大接口占比后再裁决是否优化 |
| PR88.9 | Minor | `StatefulOutlineView` 展开状态合并持久化在窗口期内被 `beginFiltering` 打断时静默丢弃、不重排。**另有第二条丢弃路径**(第三轮补注,2026-08-13):`scheduledExpansionPersistStructureVersion == dataStructureVersion` 守卫失配时同样静默丢弃且不重排(`StatefulOutlineView.swift:309`)——该守卫是第二轮修 `PR88R2.1` 时(`3a99ca68`)才加入的,本条初次登记时并不存在。修法相同(失败时重排一次),但**两条路径都要修**,只修 `filteringState` 那一半等于没修 | 后果限于「重启后恢复不到最新展开状态」;改失败重排属小改动,随下一轮 outline 工作拾起 |
| PR88.10 | Minor | `RuntimeInterfaceCache` 仅按条数封顶(16),无字节预算/内存压力驱逐 | 稳态基线已降至 239 MB,大接口常驻敏感度上升;建议补字节预算或改 `NSCache`,需要作者对 16 的窗口做实测后定 |
| PR88.12 | Minor | `SharedLocalEngineTestLock` 启动屏障无 deadline,沙盒环境下整个 target 静默挂死 | 补 deadline + `Issue.record`;测试基建项,随下一轮测试工作拾起 |
| PR88.13 | Minor | 测试直写进程级 `Settings` / `AppDefaults`(UserDefaults 支撑),跨 suite 可见且崩溃时污染真实偏好 | 正解是注入 `UserDefaults(suiteName:)`;改动面涉及 Settings 依赖注入,单独立项 |
| PR88.14 | Minor | 测试接缝进入生产类型(`expansionAutosavePersistCount`、为测试去 `final`) | 与「成员默认 private」约定相抵触;重构为注入回调的成本与收益需权衡,暂记 |
| PR88.15 | Minor | 4 份新文档落在已归档的 `Plans/`、两处索引未更新;AGENTS.md 规则 #8 链接指向 Plans | 分叉点早于归档规则确立,属时序问题而非违规;**rebase 到最新 main 后**按新约定归并成 Evolutions 提案 / Internal 说明并补索引,届时更新本行 |

> 修复后回填:某条 backlog 被修掉时,按本目录惯例在行内登记修复 commit,不删行。
Loading