---
description: Ailoha iOS current main 联系人字段输入导致 Notes 重复投影的源码链、优化编译机制基线、Debug Simulator 页面复现与最小重构合同
status: request_changes
updated: 2026-08-28
sources:
  - repo://ailoha-agent-iOS@6922f359ad08da70242e4ec0c5c4a1486bc2908d
  - brain://reports/ios-frontend-refactor-map/evidence/current-main-contact-detail-2026-08-28/contact-notes-projection-five-process-summary.json
  - brain://reports/ios-frontend-refactor-map/evidence/current-main-contact-detail-2026-08-28/contact-detail-typing-debug-simulator-summary.json
  - https://app.notion.com/p/3c9a444a6c00812699c0f17e0d631963
confidence: high
sensitivity: internal
---

<!-- brain-capture:ios-frontend-current-main-contact-detail-update-performance -->

# 只改联系人姓名，为什么背后的备注列表也要全部再算一遍

> 一句话：当前 `main` 的联系人详情把页面资料、Activity、历史记录、弹窗和编辑草稿放在同一个 `ObservableObject`。姓名输入框每改一个字，root VM 就发布一次变化；正在显示的 Notes 子树因为观察同一个 VM，也会重新解析、拆分、按日期分组全部备注。Debug Simulator 中，12 次姓名输入稳定触发 12 次页面 body、12 次 Notes body、12 次备注解析和 12 次日期分组。1,000 条备注时，仅这段与姓名无关的备注投影每次中位数约 23.6ms；5,000 条时约 91.5ms。它已经是可复现的工程问题，但还不是“生产用户都卡”的结论。

观察时间：2026-08-28。代码快照：`main@6922f359`。当前状态：**源码因果链、优化编译的分组机制、Debug Simulator 页面路径和状态截图已闭合；真机 Release、生产备注数量分布和 Instruments trace 尚未取得。**

## 用户在哪个动作里碰到它

用户停在联系人详情的 Notes tab，点开“姓名、公司、职位、邮箱、电话或地址”任一字段开始输入。编辑卡片浮在底部，背后的联系人资料和备注仍在画面里。

用户的真实意图只是改一个草稿字符。理想更新边界是：输入控件和它的校验状态。当前更新边界却是整个 `ContactDetailViewModel` 的所有观察者；Notes 因而重复处理完全没变的备注数组。

恰当的工程颗粒不是“重写联系人页”，也不是“把 `ObservableObject` 换成 `@Observable`”，而是：

**编辑草稿变化时，不让不依赖草稿的 Notes 投影失效；备注本身变化时，再只重算一次备注投影。**

## 先保留已经做对的东西

- 联系人 by-ID 入口会先查 cache，cache miss 才展示占位并后台拉取；不要为这次优化破坏首屏策略。
- Notes 的解析和日期分组已经抽到 `ContactNotesGrouping`，详情页与分享页共用；它可以直接成为 full projection oracle。
- 日期 formatter 是静态实例，不是每条备注都新建。
- `ContactFieldEditor` 已是单字段弹层，确认、删除、关闭边界清楚；它天然适合拥有本地 draft。
- Widget edit 的 `baselineNotes`、`widgetSessionNewNoteIndices`、重复文案 count-aware 规则已有明确语义，不能为了缓存丢掉。
- Notes、Activity、Info 已有视觉 tab 边界；可以沿这些边界收窄 observation，而不必一次拆完整个 feature。

## 当前因果链

```text
用户在 ContactFieldEditor 输入一个字符
  → Binding 写入 ContactDetailViewModel.editingText (@Published)
  → root objectWillChange
  → ContactDetailPage 重新求值
  → 当前 Notes tab 把同一个 root VM 交给 ContactNotesView
  → ContactNotesView 作为 @ObservedObject 重新求值
  → partitioned 重新读取并解析全部 currentContact.notes
      → entries(from:) 对每条备注解析 YYYY-MM-DD 前缀
      → baseline / session-new 规则重新拆 new 与 history
  → dateGroups(from:) 再遍历 history、生成日期 key、分桶并排序
  → ScrollView + VStack 继续承载全部分组和 note bubble
```

这条链中有四个能独立修改、独立测试、独立回滚的颗粒。

## 颗粒一｜编辑草稿放错了 owner

`editingText`、`editingCompanyText`、`editingJobTitleText` 是 root VM 的 `@Published`。输入框每次修改都向整棵联系人详情树广播；但草稿在用户确认前并不是联系人 canonical state，也不应该影响 Notes、Activity 或 Info。

**最小改法：** 新增纯局部 `ContactFieldDraft`，由 `ContactFieldEditor` 或一个窄 `ContactFieldEditorModel` 持有。打开弹层时从 root 拷入一次；输入期间只改 local state；确认时向 root 发一个 `ContactFieldCommit`，取消时直接丢弃。

**为什么这一刀最值：** 它不改 API、联系人保存语义、Widget 提交或备注分组，只把高频按键状态从低频业务状态中拿出来。正常输入从“每个字符广播”变成“确认时提交一次”。

## 颗粒二｜Notes 观察了整个联系人 VM

`ContactNotesView` 真正需要的是 notes、fallback date、baseline、session-new indices 和编辑 note 的 action，却接收整个 `ContactDetailViewModel`。因此任何 root Published 变化都可能叫醒它。

**最小改法：** 页面组合层把 Notes 所需输入投影成不可变 `ContactNotesInput`，再传一个 `onEditNote(index)` action；或者给 Notes 一个只发布这些字段的窄 model。不要把 root VM 继续传入 Notes。

建议合同：

```swift
struct ContactNotesInput: Equatable {
    let rawNotes: [String]
    let fallbackDay: Date
    let baselineNotes: [String]?
    let sessionNewIndices: [Int]
}

struct ContactNotesProjection: Equatable {
    let newEntries: [ContactNoteEntry]
    let historyGroups: [ContactNoteGroup]
}
```

`ContactNotesProjection` 只在 `ContactNotesInput` 真正变化时生成。姓名、电话草稿、history loading 或弹窗状态变化时，projection 次数必须是 0。

## 颗粒三｜四个 child state 又把变化转发回 root

email、phone、address、note 都是 `EditableFieldState`，自身已经是 `ObservableObject`；VM 又订阅它们的 `objectWillChange` 并手动 `send()` 到 root。这个转发把叶子变化扩大成页面级变化。

**怎么改：** 先建立 consumer 测试和 observation counter，再让真正需要某个 child 的叶子直接观察它，删除对应 root forwarding。不要四个一起盲删：Widget edit、dirty/validation 和保存映射必须先有行为测试。

**边界：** 本轮源码搜索没有发现 View 直接观察这四个 child；这说明“删除转发”不是一行安全修复，而是需要先把 consumer 接到新边界，再删旧桥。

## 颗粒四｜长 Notes 列表的投影和渲染都没有窗口边界

`ContactNotesGrouping` 对每条 note 解析前缀并按日分组，成本随备注数增长；页面还用 `ScrollView + VStack`，会创建所有 note bubble。API 只限制每条 note 最长 256 字符，没有声明 notes 的 `maxItems`，所以客户端不能假定数组永远很短。

这其实是两个问题：

1. **投影成本：** `LazyVStack` 不会阻止全量解析和分组；先解决 observation 与 projection owner。
2. **视图构建成本：** projection 收窄后，如果真实上界仍大，再把组与 bubble 迁到 lazy container。

Notes 当前用原始数组下标作为 entry ID。插入或删除会让后续 identity 全部漂移；原始字符串又可能重复，不能直接拿字符串当唯一 ID。因此在上 `LazyVStack` 或增量 note patch 前，要先决定服务端稳定 note occurrence ID，或明确客户端 revision-scoped identity。不能用 `id: \.self` 掩盖重复项。

## 实验一｜直接编译 production 分组逻辑

Harness 直接编译 current-main 的 `ContactNotesGrouping.swift`，`swiftc -O`，每格 5 个独立进程。`unrelated_field_edit` 表示备注完全没变但重新投影；`note_append` 表示只新增一条仍全量投影。

| 场景 | notes | 五进程中位数 | 五进程 p95 中位数 |
|---|---:|---:|---:|
| 无关字段变化 | 100 | 1.540ms | 1.661ms |
| 无关字段变化 | 1,000 | 14.900ms | 15.402ms |
| 无关字段变化 | 5,000 | 74.318ms | 75.026ms |
| 新增一条 note | 100 | 1.541ms | 1.606ms |
| 新增一条 note | 1,000 | 14.986ms | 15.636ms |
| 新增一条 note | 5,000 | 74.432ms | 75.076ms |

这个实验确认了接近线性的分组机制，也确认“新增一条”目前没有增量快路。它不包含 SwiftUI、视图构建、布局或设备热状态，不能当 App 总耗时。

## 实验二｜真实联系人详情页输入姓名

在 current-main clean snapshot 中只加入 Debug 启动入口和计数器；真实页面仍走生产 `ContactDetailPage → ContactNotesView → ContactNotesGrouping`。iPhone 17 Pro Simulator、iOS 26.5、Debug，每档 3 次独立启动，每次打开姓名编辑框并写入 12 次。

| notes | Page body | Notes body | 解析 / 分组次数 | 每次输入的无关 Notes 投影三次 | 中位数 |
|---:|---:|---:|---:|---|---:|
| 100 | 12 / run | 12 / run | 12 + 12 / run | 2.88 / 6.26 / 5.73ms | 5.73ms |
| 1,000 | 12 / run | 12 / run | 12 + 12 / run | 23.64 / 23.53 / 24.11ms | **23.64ms** |
| 5,000 | 12 / run | 12 / run | 12 + 12 / run | 93.60 / 91.53 / 90.99ms | **91.53ms** |

截图 `debug-simulator-1000-notes.png` 同时显示姓名编辑卡和背后的 Notes 列表，证明测量场景确实成立。截图不能证明没有 hitch；日志数字也不能替代真机 Release trace。

三条结论要分开：

- **事实：** 12 次姓名输入导致 12 次 Notes body 和 12 次全量备注投影。
- **事实：** 机制成本随 notes 增长；5,000 条 synthetic fixture 在 Debug Simulator 上单次无关投影约 91.5ms。
- **未知：** 生产用户的 notes 数量分布、最低支持真机的 Release p95，以及它当前是否已经构成用户投诉或 hitch。

## 推荐方案

### L0｜先让回归可见

- 给 editor draft mutation、Notes projection、Notes visible count、body update 和 fallback 加低基数 signpost / debug counter；不记录联系人名或备注正文。
- 固定 0 / 10 / 100 / 1,000 notes fixture，另加重复文案、合法/非法日期前缀、Widget baseline/session-new 组合。
- 用现有 `ContactNotesGrouping` 的结果做 oracle；先保证内容、顺序和分组完全一致。

### L1｜本轮最小推荐实现

1. 把 `editingText/company/title` 移到 editor-local draft；confirm 时一次性 commit。
2. 新增 `ContactNotesInput` 和状态化 projection owner，只在 notes 相关输入变化时 full project 一次。
3. `ContactNotesView` 不再观察 root VM，只拿 projection + action。
4. 建 consumer test 后，逐个删除 child `objectWillChange → root` forwarding。
5. 保留 full grouping oracle；任何 baseline/session-new 不确定性都走 full projection，而不是猜增量。

这一层先把最浪费的“无关字段输入 → Notes 重算”降到 0，不碰服务端 note schema，也不要求全页一次迁到新 Observation 框架。

### L2｜只有真实上界支持才做

- 服务端为 note occurrence 提供稳定 ID 和可分页/窗口化合同；客户端将 canonical notes 与 viewport projection 分开。
- Notes 使用 lazy container，只创建可见 bubble；插入/删除保持稳定 identity 与 VoiceOver 焦点。
- 若 note append 频率和规模仍超预算，再做按受影响日期桶的增量 projection，并与 full oracle 逐步对照。

## 完整验收合同

### Gate 0｜固定事实

- [ ] 记录 source SHA、Xcode、configuration、device、OS、fixture hash 和独立运行次数。
- [ ] current full projection 结果保存为 oracle；不得用截图人工判断分组等价。
- [ ] fixture 覆盖空列表、重复原文、相同日期、非法日期、纯日期无正文、baseline 重复项和 session-new 重复 index。
- [ ] 生产 `notes_count` 只上传区间桶，不上传正文或联系人 ID；拿到分布前不把 5,000 条说成常见用户场景。

### Gate 1｜状态与正确性

- [ ] 打开姓名、公司、职位、邮箱、电话、地址 editor 后连续输入 20 次；未确认前 canonical contact 不变。
- [ ] 取消编辑不产生 contact mutation；确认只产生一次语义 commit，保存/错误/重试行为与旧版一致。
- [ ] Notes projection 与 full oracle 在所有 fixture 上完全等价：组头、日期倒序、组内顺序、正文剥离、New/history 分区都相同。
- [ ] Widget update/delete/create 的 baseline 和 session-new 行为不变。
- [ ] 删除 child forwarding 后，email/phone/address/note 增删改、dirty、validation、rollback 和保存映射都有 hosted test。

### Gate 2｜更新边界

- [ ] Notes 首次可见允许 projection 1 次；随后 20 次姓名输入的 Notes body update 与 Notes projection 都为 **0**。
- [ ] company/title 双字段连续输入 20 次，Notes projection 为 **0**。
- [ ] 真正新增/编辑/删除一条 note 时，每个一致事务最多发布 1 次 Notes projection。
- [ ] Activity 加载、history 展开、保存 spinner 和无关 alert 变化都不触发 Notes projection。
- [ ] 用 SwiftUI Instrument cause/effect graph 证明更新 lane 落在 editor subtree；不能只看普通日志变少。

### Gate 3｜真机 Release 性能

同一台最低支持设备和一台近期主流设备，0 / 100 / 1,000 notes 各跑 5 个独立进程，报告全部原始值、中位数和 p95。

- [ ] 无关字段 20 次输入：Notes projection count = 0；app-owned 单次主线程输入区间 p95 ≤8ms，无 >100ms hang。
- [ ] 1,000 notes 首次 projection p95 ≤16.7ms；如果达不到，先看 parsing / grouping trace，再决定是否增量或预计算。
- [ ] 相比 current baseline，1,000 notes 的无关输入 projection CPU 下降 100%；不是“减少一点”。
- [ ] 连续开关 editor 20 次、切 Notes/Activity/Info 20 轮，ViewModel、projection cache 和 bubble 不逐轮增长；异常时抓 memgraph。
- [ ] 若生产 p99 notes 明显小于 100，可降低长列表工程优先级，但 observation 误边界仍需由 update-count gate 防回归。

### Gate 4｜状态截图与视频

截图、视频和 timing JSON 共用 `caseID · commit · device · OS · notesCount · action`。截图证明状态，视频证明连续性，trace 证明耗时。

1. `01-notes-1000-initial.png`：1,000 notes 首次打开，header、tab、首个日期组正确。
2. `02-name-editor-open.png`：姓名编辑卡打开，Notes 留在背后，无布局跳变。
3. `03-name-typing-final.png`：连续输入后的最终草稿可见；canonical header 在确认前保持旧值。
4. `04-name-cancel.png`：取消后旧姓名恢复，Notes 内容和滚动位置不变。
5. `05-name-confirm.png`：确认后 header 只更新一次，Notes 分组不变。
6. `06-note-append.png`：新增 note 只出现一次，New/history 归类正确。
7. `07-duplicate-notes.png`：相同文案的两条 note 都存在且编辑目标正确。
8. `08-invalid-date.png`：非法日期前缀按 fallback 规则展示，无丢失。
9. `09-large-scroll.png`：生产上界 fixture 从顶部滚到底部，无空白、重复或 identity 跳变。
10. `contact-editor-boundary-proof.mp4`：一镜到底展示开姓名编辑、连续输入、取消、再确认、切 tab、加一条 note；中间不剪掉等待或失败。

当前已保存的 `debug-simulator-1000-notes.png` 是 baseline 场景证据，不含 case metadata，不算 Gate 4 完成。

### Gate 5｜灰度与恢复

- [ ] projection 新旧策略可在内部开关间切换；失败回到 full projection，不回到 broad root observation。
- [ ] dogfood 记录低基数的 notes bucket、projection duration/count、editor mutation count 和 fallback reason；不上传正文、姓名、手机号或联系人 ID。
- [ ] 任一 oracle mismatch、Widget New 分组错误、编辑 target 错位或 VoiceOver 焦点回归，立即关闭新 projection strategy。

## 给另一台电脑上执行 Agent 的范围

**直接交付：** editor-local draft、Notes input/projection seam、full oracle tests、observation counters、100/1k fixture、真机证据包。

**不要做：** 不顺手重写 `ContactManager`；不改联系人 REST / ARPC 合同；不静默截断 notes；不先全量迁移 `@Observable`；不以 `LazyVStack` 代替 observation 修复；不使用原始 note 字符串作为唯一 ID。

**主要文件：**

- `ContactDetailViewModel.swift`：把 editor draft 与 Notes projection 输入从 root 广播中拿出来；旧保存逻辑保留为 commit consumer。
- `ContactDetailPage.swift` / `ContactEditSheet.swift`：组合 `ContactNotesInput` 与 action，不再把 root VM 透传到 Notes。
- `ContactFieldEditor.swift`：拥有 local draft，提交一个 typed commit。
- `ContactNotesView.swift`：只消费 projection；后续是否 lazy 由真机上界决定。
- `ContactNotesGrouping.swift`：现有 full oracle；先补等价测试，不先改算法。
- `EditableFieldState.swift`：consumer 接到窄边界后逐个删除 root forwarding。
- `api-contracts/http_api_v1.yaml`：只记录 notes 目前无 `maxItems` 的事实；分页/稳定 note ID 属于单独产品/API 决策。

## 最终判断

### 已证实

- 姓名输入的每次 `editingText` mutation 会叫醒整个联系人页面和当前 Notes 子树。
- Notes 每次醒来都会重新解析、拆分和日期分组全部备注，即使备注没有变化。
- 这段投影接近随备注数线性增长；Debug Simulator 1,000 / 5,000 notes 的每次无关输入中位数约 23.6 / 91.5ms。
- API 对单条 note 有 256 字符上限，但没有 notes 数量上限；长列表不能只靠假设排除。

### 仍是推断或未知

- 生产用户常见和 p99 的备注数量。
- 最低支持真机 Release 是否已经出现可感知 hitch。
- lazy view 构建占总成本多少；本轮只直接计量了 projection。
- 稳定 note identity、分页和产品保留历史的目标合同。

### 推荐当前决策

把它列为 **P0 工程切片、P1 产品风险**：先实现 editor-local draft 与窄 Notes projection，两项都有明确的 0-update 验收；同时拿生产数量桶和真机 Release trace。这样既不等投诉才处理一个已闭合的无关工作，也不把 Simulator 数字夸成线上事故。

跨电脑执行入口：[Contact 观察边界 Notion 实施总包](https://app.notion.com/p/3c9a444a6c00812699c0f17e0d631963)。
