Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
ee860bb
fix(sidebar): invalidate node-derived state on every reload outcome
Mx-Iris Aug 14, 2026
9117025
perf(sidebar): check the generation before an Open Quickly pass build…
Mx-Iris Aug 14, 2026
97c253e
fix(content): stop a stale redirect from hiding a correct cache entry
Mx-Iris Aug 14, 2026
35ebcf9
perf(content): key cached interfaces by RuntimeObjectKey
Mx-Iris Aug 14, 2026
2e40bd2
refactor(sidebar): drop the Open Quickly haystack seeding that has no…
Mx-Iris Aug 14, 2026
e6e70af
docs(known-issues): adjudicate the fourth PR #88 review pass
Mx-Iris Aug 14, 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
10 changes: 9 additions & 1 deletion 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 Expand Up @@ -647,7 +648,14 @@ let rowClicked: Signal<Candidate> = tableView.rx
2. The data set is large enough that eager construction is visible in Instruments (typically N >= 1k, confirmed by a signpost baseline).
3. There is no per-row UI state that cannot be derived from the model (expanded/collapsed flag, multi-select checkmark, drag-preview metadata). If you need such state, build a local `struct` conforming to `Differentiable` directly — do NOT add mutable fields onto `DifferentiableBox`.

Sidebar / Inspector cellViewModels (e.g. `SidebarRuntimeObjectCellViewModel`, `InspectorSwiftSpecializationCellViewModel`) own filter-aware attributed names or async metadata loading, so they must stay eager — lazy reconstruction would drop their subscription identity. The `SpecializationTypePicker` popover is the canonical lazy case (10k+ candidates when a generic parameter has no constraint).
**Two different things get called "lazy" here, and the eligibility tree above governs only the first:**

- **Stateless lazy (`DifferentiableBox`)** — the driver array carries identity boxes and the cellViewModel is rebuilt inside the cell builder on *every* render, so nothing survives between renders. That is why the three conditions are absolute: a cellViewModel with post-init `@Observed` mutation or an Rx subscription would silently lose it. The `SpecializationTypePicker` popover is the canonical case (10k+ candidates when a generic parameter has no constraint).
- **Lazily materialized behind a warm cache** — the driver array still carries fully-built cellViewModels, but each is constructed on first match and kept in a map for the rest of the session, so subscription identity and post-init state survive. Open Quickly uses this to avoid building a cellViewModel per row of a 14k-object image; see `SidebarRuntimeObjectListViewModel.openQuicklyCellViewModel(at:)`.

`SidebarRuntimeObjectCellViewModel` owns filter-aware attributed names, so it fails the eligibility tree and must never take the first form — but it is the type Open Quickly materializes lazily in the second form, precisely because the cache means it is never *re*constructed. Inspector cellViewModels (`InspectorSwiftSpecializationCellViewModel`, `InspectorRelationshipsCellViewModel`) stay eager on both counts: their row counts are low enough that neither form pays for itself.

Reach for either form only once eager 1:1 construction is measurably hurting — both trade a memo, and the invalidation it needs, for the allocation they save.

```swift
// ViewModel — driver element is a value-type identity box, not the cellViewModel
Expand Down
152 changes: 152 additions & 0 deletions Documentations/Evolutions/0005-cellvm-appearance-single-observed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# 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,与行数一一对应,单流化按设计生效。

## 与提案的差异(2026-08-14 补记)

第四轮 code review 发现落地内容与本提案的「非目标」一节直接冲突,按「提案是决策快照、
不回头修改正文」的规则,差异登记于此,正文保持原貌。

- **非目标写「**不**触碰 `SpecializationTypePickerCellViewModel`」,实际改了。** 落地记录
的改动清单把它与两个 Inspector cellVM 一并列入「协议 conformer 连带单流化」。原因是
`RuntimeObjectCellDisplayable` 从 4 个分立 driver 收敛为 1 个 `appearanceDriver` 之后,
所有 conformer 必须同批改造,否则不编译——即协议收敛这一步把「低基数 cellVM 不动」这条
非目标变成了不可能。提案写非目标时未预见到这一点。
- **非目标写「**不**处理低基数 cellVM(Inspector 各 tab、popover 等)」,同上,实际一并改了。**
同一个原因。
- **落地记录称改动清单「与提案方案一致」,这句话不准确。** 括号里的「另含协议收敛的连带面」
提示了这件事,但不足以让读者意识到它推翻了两条非目标。

影响:一个只读到「非目标」一节的读者会以为这三个 cellVM 未被触碰,从而对它们早已被移除的
per-outlet driver 作出错误推断。上述三点即为更正。

本节不改变提案状态(仍为 `Implemented`),也不改变已落地的代码。
Loading