---
description: Ailoha iOS 前端性能问题的工程认知颗粒、改造方案与分层验收清单
status: active
updated: 2026-08-28
sources:
  - repo://ailoha-agent-iOS@6922f359ad08da70242e4ec0c5c4a1486bc2908d
  - brain://reports/ios-frontend-refactor-map/current-main-chat-update-performance-baseline-2026-08-28.md
  - brain://reports/ios-frontend-refactor-map/current-main-photo-import-memory-baseline-2026-08-28.md
  - brain://reports/ios-frontend-refactor-map/current-main-contact-detail-update-performance-baseline-2026-08-28.md
  - https://developer.apple.com/documentation/xcode/understanding-and-improving-swiftui-performance
  - https://developer.apple.com/videos/play/wwdc2025/306/
  - https://developer.apple.com/documentation/xctest/performance-tests
  - https://developer.apple.com/videos/play/wwdc2018/416/
confidence: medium
sensitivity: internal
---

# iOS 前端性能工程认知与改造清单
<!-- brain-capture:ios-frontend-performance-engineering-checklist -->

这不是一份“看到 `GeometryReader` 就删、看到 `@Published` 就迁移”的清理清单。它要训练的是判断：**用户在哪个场景感觉慢，慢是由哪条数据流造成的，最小应该改到哪一层，又该用什么证据证明真的变好了。**

## 先把边界说清楚

- [x] **固定代码基线：** `ailoha-agent-iOS@6922f359ad08da70242e4ec0c5c4a1486bc2908d`。下文 current-main 行号只对应这个修订；旧 Release 差异另标修订。
- [x] **已证实的性能事故 P0：0 项。** 当前没有真机 Release trace、生产 hang/hitch telemetry 或内存终止证据。
- [x] **建议按 P0 顺序投入的性能工作：4 项。** 它们位于长对话、图片输入、Calendar 和联系人字段编辑四条路径；“P0 工作”是投入顺序，不是对线上事故的虚假定级。
- [x] **事实和推断分开：** 源码与独立机制实验已证明 Chat 全量投影、图片工作集放大、Calendar 全量日期投影；Contact 还用 Debug Simulator 证明姓名每输入一次都会重跑 Notes 全量投影。Simulator 只支持机制判断，不能单独证明生产用户已经卡顿。
- [ ] **最终定级仍缺：** 同一台真机、同一份 Release build、同一 fixture、同一操作脚本的 before/after Instruments 收据。

Apple 的判断框架很朴素：先用 SwiftUI Instrument 找 long update 和频繁更新，再用 Time Profiler、Hangs/Hitches 判断 CPU 是否真的来自 SwiftUI；性能测试则用 XCTest 的 clock、CPU、memory、hitch 和 signpost metrics 建可重复基线。图片内存看解码后的像素尺寸，不看 JPEG/HEIC 文件有多小；需要缩图时，ImageIO 可以避免先解出完整原图造成的峰值。

## 什么叫“恰到好处的工程认知颗粒”

一条可以独立判断、独立修改、独立测试、独立回滚的颗粒，至少要包含下面五件事：

- [ ] **一个用户动作：** 例如“长对话收到一条新消息”，不能写成空泛的“优化 Chat”。
- [ ] **一个状态 owner：** 例如 `TaskChatViewModel` 的 render projection，不能把整个页面、网络和数据库混成一锅。
- [ ] **一个主成本：** 全量重建、全尺寸解码、全量分组、无效观察或布局反馈，必须只抓主矛盾。
- [ ] **一个输入与输出：** 输入是一条消息 patch、一张照片或一个事件 delta；输出是一组 rows、缩略图 artifact 或日期桶 snapshot。
- [ ] **一套输赢标准：** 先证明输出没变，再证明 CPU、内存、更新次数或 hitch 变少。

颗粒太大时会变成“重写 Calendar”“迁移 Observation”；颗粒太小时会变成“缓存一个 formatter”“加一个 `Equatable`”。前者无法回滚，后者可能完全没碰到用户感知的瓶颈。

---

## P0-00 — 先建立同一把尺子，否则所有“优化”都只是感觉

**问题上下文**

当前性能报告只有源码风险，没有 Release 真机 trace。如果团队成员各自在不同 Simulator、不同数据量、不同分支上“觉得快了一点”，这些结果不能比较，也无法阻止回归。

**工程认知颗粒**

- [ ] **场景 fixture：** 把长对话、10 图导入、Calendar 生产上界数据做成稳定 fixture；不要临时手点随机账号数据。
- [ ] **操作脚本：** 固定冷启动/热启动、打开页面、滚动、更新、返回的顺序。
- [ ] **测量边界：** 给 `buildRows`、图片 prepare、Calendar projection 加不含用户内容的 signpost；不要把整次测试包成一个无法定位的大区间。
- [ ] **环境元数据：** 每份收据记录 commit、设备、OS、Xcode、Release/Debug、fixture 版本和重复次数。
- [ ] **结果双轨：** 同时保存正确性结果和性能结果；只变快但内容错了，仍然失败。

**怎么改**

- [ ] 先建立 `PerformanceFixtures`，只保存合成数据或脱敏结构，不拷贝真实用户对话、图片和联系人。
- [ ] 用 `XCTOSSignpostMetric` 测三个明确区间；用 `XCTClockMetric`、`XCTCPUMetric`、`XCTMemoryMetric` 分别看墙钟、CPU 和物理内存。
- [ ] 真机 Release 用 SwiftUI、Time Profiler、Hangs/Hitches、Allocations/Memory Graph 复核；Simulator 只做快速回归，不作为最终性能结论。
- [ ] 每个场景至少重复 5 次，冷路径和热路径分开报告；先写中位数和最差样本，不只挑最好的一次。
- [ ] UI 自动化等待真实条件，例如“第 1000 行出现”“导入完成标记出现”，禁止 `sleep(2)` 猜时间。

**为什么这样好**

它把争论从“我觉得”变成“同一场景下，哪一段工作花了多久、更新了几次、占了多少内存”。以后换渲染器、状态模型或缓存策略，也能继续用同一把尺子比较。

**好的验收标准**

- [ ] 同一 fixture 的 before/after 输出一致。
- [ ] 同一设备和脚本可重复，结果波动可解释；不能靠重跑直到变绿。
- [ ] trace 能把慢点落到具体 signpost 或调用栈，而不是只看到“整个页面 1.2 秒”。
- [ ] 所有绝对阈值先标“临时预算”，在首轮基线后由团队确认；不要把拍脑袋数字冒充现有 SLO。

---

## P0-01 — 长对话只更新一行，主线程仍重新解释整段历史

**用户场景**

用户在几百到几千条消息的 Task Chat 中阅读历史，同时收到一条新消息、processing 状态变化、点一次反馈或展开一条内容。理想行为是只处理受影响的 row 和它的依赖边界；当前主数据路径会重新生成整份 `chatRows`，列表层再扫描整份 rows。

**当前问题与溯源**

- [x] **事实：** `TaskStateManager.taskPublisher` 已用 `removeDuplicates` 拦住等价快照；每份真的变过的 `TaskData` 进入 `TaskChatViewModel.apply(_:)` 后仍调用 `rebuildChatRows()`。锚点：`TaskStateManager.swift#L70-L82`；`TaskChatViewModel.swift#L128-L185`。
- [x] **事实：** `expandedMessageIds` 与 `optimisticFeedback` 的单点变化也直接 full rebuild。锚点：`TaskChatViewModel.swift#L34-L50`。
- [x] **事实：** `buildRows` 已把旧 widget 回溯从 O(n²) 降到 O(n)，但仍全量做 selection 投影、索引、feedback、widget action、前后缀 anchors 和全部 row。锚点：`ChatRowItem.swift#L101-L220`。
- [x] **事实：** current main 只剩 Chat V2；稳定 ID、同 tick coalescing、后台 freeze、内容无变化早退和滚动锚点都应保留。`applyRowsNow` 仍全量去重、比较、重建字典、ID 数组和 snapshot。锚点：`ChatCollectionView.swift#L510-L651`。
- [x] **机制证据：** 直接编译 production `ChatRowItem.swift` 的 5 进程优化实验中，5000 行 append 的投影 + renderer preparation 中位数约 8.49ms；它不包含 UIKit apply、Markdown 和 cell layout。
- [x] **页面证据：** iPhone 17 Pro / iOS 26.5 Debug Simulator 离线页面每档 3 次独立启动，5000 行只追加一条纯文本的“投影 + 同步列表路径”配对合计中位数约 81.9ms；100 / 1000 行为 25.2 / 38.2ms。绝对值不是 iPhone Release SLO。
- [ ] **推断：** 长会话局部更新有较高概率占用多个 frame budget；是否构成生产 hitch / hang，仍须最低支持真机的 Release Instruments 与生产历史分布裁决。

**需要建立的工程认知**

- [ ] **Canonical state 与 render projection 是两种东西。** `TaskData` 是业务真相，`[ChatRowItem]` 是渲染投影；业务真相变化不等于所有渲染行都变化。
- [ ] **总量和增量要分开。** 首次加载可以全量 build；后续变化应表达成有限 patch，无法安全解释时明确 full fallback。
- [ ] **稳定 ID 不等于增量计算。** `row.id` 稳定只能帮助 renderer 复用 cell；如果上游仍全量解析和建行，CPU 仍然白花。
- [ ] **内容 revision 与 row ID 不是一回事。** ID 表示“还是同一行”，revision 表示“这一行的 Markdown/card 内容是否真的变了”。
- [ ] **滚动锚点是独立状态。** 保持阅读位置、跟随底部和数据 patch 不应互相隐式触发；否则优化数据层时很容易把滚动体验改坏。
- [ ] **列表 coalescing 不等于投影 coalescing。** 当前列表会合并同 tick 的最后提交，但 ViewModel 在此之前可能已全量算过多次。
- [ ] **append 不一定只影响最后一行。** status group、同 cid selection、latest widget 与后缀门禁可能让尾段或旧 row 改变；必须从安全边界重算并与 full oracle 比较。

**怎么改：最小可行层**

- [ ] 把现有 `ChatRowItem.buildRows` 原样封装为 `ChatTimelineProjector.fullBuild`，它是行为 oracle，不先重写。
- [ ] 新增有限的 `ChatTimelineChange` 与 `ChatTimelinePatch`；无法归类或索引失效时带 reason 回退 full build。
- [ ] 第一批只做 feedback、expansion、processing：分别更新目标 turn/message/thinking row，不碰复杂 append。
- [ ] 第二批 append 从最近 turn/response 安全边界重算尾段，并显式处理 latest-widget / suffix-anchor 影响；每步与 full oracle 等价后才启用。
- [ ] V2 renderer 只更新 patch 涉及的 `rowsById` 和 reconfigure IDs；结构变化才改 snapshot。保留当前 coalescing、freeze、重复 ID、prepend 和 pinned-follow。

**怎么改：结构层**

- [ ] 若 L1 真机仍超预算，再让 Task authority 发布 typed message delta；本轮不先重写 canonical store。
- [ ] production `history_count` 分布证明需要后，再设计 cursor/window/prepend 合同；不能只给已有 `limit` 传小值静默截断上下文。
- [ ] Markdown / widget `rowID + contentRevision` 缓存只有在 trace 指向解析 / cell 配置后再做，并声明内存淘汰。

**为什么新方案更好**

正常更新成本从“历史总长度 n”收敛到“本次变化行数 k + 明确依赖边界”。full oracle 和 fallback 让团队可以逐类打开增量路径，不拿消息正确性换跑分，也不重新维护 V1/V2 双轨。

**测试清单**

- [ ] **纯逻辑：** 100 / 1,000 / 5,000 rows；append、update、delete、prepend、临时 ID→server ID、feedback、expansion、processing header、selection/widget 门禁都要覆盖。
- [ ] **等价 oracle：** 随机生成合法 conversation 操作序列，每一步比较 `incrementalResult == fullBuildResult`。
- [ ] **未变化行：** spy 断言一条 feedback 更新不会重新解析无关 Markdown/card；同一 revision 的 build 次数必须是 0。
- [ ] **异步：** 连续推送 20 次 processing 变化，等待 patch commit 事件，不用固定 sleep；验证 coalescing 后的最终内容和提交次数。
- [ ] **性能：** 最低支持真机 Release；5000 行 feedback/expansion 的 projection p95 ≤4ms、renderer 同步 commit p95 ≤8ms；5000 行 append 的 app-owned 主线程区间 p95 ≤16.7ms、无 >100ms app hang。
- [ ] **UI：** 真机 Release 跑长对话打开、快速滚动、上滚阅读时收到新消息、贴底时 streaming、键盘开合、prepend 历史和复杂卡片。
- [ ] **滚动正确性：** 上滚用户不能被拉到底；原本贴底的用户继续跟随；prepend 后当前阅读行在视口位置基本不变。
- [ ] **内存：** 反复进出同一会话 10 次，VM、renderer、cell/content cache 能释放或稳定在有解释的上限。

**好的评估标准**

- [ ] 增量结果与 full build 完全等价。
- [ ] 单行变化只触碰预期 row ID；不出现“为性能丢消息/错序/重复行”。
- [ ] 一次增量提交只发布一次可见 snapshot。
- [ ] 同设备同 fixture 下 5000 行 append 的 app-owned 主线程时间至少下降 60%；500→5000 行的单行 feedback/expansion p95 不超过 2 倍。
- [ ] 1000 / 5000 行 first content ready p95 ≤350ms；连续 20 个同 turn 更新在一个 16ms 合并窗内最多一次可见提交。
- [ ] 截图、连续视频和 trace 共享 case ID；截图证明状态、视频证明连续性、trace 证明耗时，不能互相替代。

**容易做错的改法**

- [ ] 只把 `buildRows` 搬到后台，但仍每次全量做；主线程可能轻了，总 CPU 和电量问题还在。
- [ ] 只加 `EquatableView` 或 `AnyView` 缓存；上游全量 projection 没变，且可能藏住状态错误。
- [ ] 一次同时换 store、projection、renderer、滚动算法；一旦出问题无法判断是哪一层。

完整源码链、45 份逻辑结果、3×3 页面运行、状态截图清单和跨电脑实现合同见 `brain://reports/ios-frontend-refactor-map/current-main-chat-update-performance-baseline-2026-08-28.md`。

---

## P0-02 — 一次选 10 张照片，整批原图常驻并重复压缩

**用户场景**

用户从 Home 输入框或 Camera 页面一次选择多张照片并准备发送。理想行为是每次只处理少量图片，UI 只拿展示需要的缩略图，上传读取文件；当前两个入口都先拿完整 `Data`，再构造完整 `UIImage`，最后把整批图片留在数组里。

**当前问题与溯源**

- [x] **事实：** `InputPanel` 的 PhotosPicker 最多选择 10 张，逐张 `loadTransferable(Data.self)` → `UIImage(data:)` → append 到 `[UIImage]`。锚点：`InputPanel.swift#L299-L321`。
- [x] **事实：** `CameraPage` 的相册入口重复了同样路径。锚点：`CameraPage.swift#L59-L81`。
- [x] **事实：** picker 两条路径都没有像素上限；下游为每张创建不同 registry task，预检用 detached 压缩只取 byte count，`FileUploader` 上传前再执行同一压缩策略。
- [x] **事实：** remove/clear 会取消外层 registry task，但 detached 预检不自动继承取消，后续流程也没有显式 cancellation guard。
- [x] **事实：** 仓内已有 ImageIO thumbnail 示例，可作为技术参考，但它只生成 160px 目的地缩略图。锚点：`ImportDestinationSheet.swift#L264-L278`。
- [x] **机制证据：** iOS 26.5 Simulator、三独立进程的 10×48MP 合成 JPEG 场景中，current-style 峰值增量中位数 785.1MB（570.5–912.2），一次 prepare 候选 82.0MB（82.0–82.2）；释放后都回到基线约 +18–19MB。它证明瞬时工作集机制，不是 iPhone SLO 或生产 OOM。
- [ ] **推断：** SwiftUI 上下文中的无结构 `Task` 没有写出明确后台解码边界，存在主 actor 参与解码或大量内存工作紧贴 UI 生命周期的风险；需要 Time Profiler 验证执行线程。

**需要建立的工程认知**

- [ ] **文件大小不是图片内存。** 2MB HEIC 解码后可能是几十 MB；判断内存要看像素宽 × 高 × 每像素字节数。
- [ ] **缩放和下采样不是一回事。** 先 `UIImage(data:)` 再画小图，完整原图已经解码；ImageIO thumbnail 才能在解码边界控制工作集。
- [ ] **展示资产和上传资产是两种表示。** UI 需要小 thumbnail；上传可能需要受控尺寸/质量的文件，不需要让页面持有十个 full `UIImage`。
- [ ] **并发不是越多越快。** 多张大图并行解码会叠加峰值；应把最大并发当资源预算。
- [ ] **取消也是性能功能。** 用户退出页面后仍继续解码和压缩，就是看不见的 CPU、内存和电量浪费。
- [ ] **资源要有 owner。** 临时文件由 import session 持有；成功、取消、失败、登出都必须可清理。

**怎么改：最小可行层**

- [ ] 建一个共享 `PhotoImportPipeline.prepare(item:policy:)`，Home 与 Camera 只能走这一条入口；逐张发布 `preparing/ready/failed`，不再回调整批 `[UIImage]`。
- [ ] policy 明确 `maxPixelSize`、输出格式/质量、最大并发数、临时目录和取消语义。
- [ ] 用 ImageIO 从 `Data` 或文件 URL 直接 downsample；不要先创建原尺寸 `UIImage`。
- [ ] 并发限制为 1–2 张，先以保守工作集开始，再用真机数据调整。
- [ ] 一次 prepare 产出 `600px preview + 2048px encoded upload URL + metadata`；预检只读文件大小，上传不再重复编码。
- [ ] 页面退出或新一批选择开始时取消旧 session，并可靠删除它拥有的临时文件。

**怎么改：结构层**

- [ ] 把 import 分成 acquire → validate → downsample/transcode → persist temp artifact → publish preview 五个阶段。
- [ ] 每阶段有明确输入、输出和错误；不允许用 `try?` 把损坏图片、权限错误和取消混成“什么都没选到”。
- [ ] 上传层只依赖 file-backed artifact；UI 层只依赖小 preview。这样上传重试不会迫使页面长期保留大图。

**为什么新方案更好**

峰值工作集由“选择数量 × 原始分辨率”变成“有限并发 × 明确像素上限”。两个入口共享同一条策略，未来修改画质、清理和取消时不会一处修好、一处继续 OOM。

**测试清单**

- [ ] **纯逻辑：** 测 pixel budget、目标尺寸、方向变换、质量策略和文件名/生命周期状态机。
- [ ] **格式矩阵：** 1 / 5 / 10 张；HEIC、JPEG、PNG、超长 panorama、旋转 EXIF、损坏数据、超大像素但小文件。
- [ ] **并发：** 用 decoder spy 记录同时活跃数量，断言不超过 policy；不能用测试耗时推测并发数。
- [ ] **取消：** 在第 3 张处理中取消，验证后续 decode 不启动、已生成临时文件被删除、UI 不再收到迟到结果。
- [ ] **错误：** 单张损坏时明确产品策略——整批失败或跳过坏图；测试必须与产品行为一致，不能静默吞错。
- [ ] **内存：** 真机 Release 用 `XCTMemoryMetric`/Allocations 比较峰值 resident memory；导入 10 张后返回页面，工作集应回落。
- [ ] **CPU/线程：** Time Profiler 中不应出现原尺寸 decode/resize 长时间占用 Main Thread。
- [ ] **UI：** 选图过程中滚动、输入、取消、退后台、低内存警告、再次选图；应用不能冻结或重复回调。
- [ ] **清理：** 成功、失败、取消、强退后重启分别验证临时目录没有不可解释残留。

**好的评估标准**

- [ ] 输出图片方向、清晰度、顺序和上传结果与旧路径一致或符合新 policy。
- [ ] 最大并发有机器断言，不靠代码评审猜测。
- [ ] 真机 10×48MP 峰值相对旧路径至少下降 60%，且 10 张峰值不超过 1 张峰值的 2.5 倍。
- [ ] 全部 upload artifacts ready 不比旧路径慢 20% 以上；第一张 preview ≤500ms（iCloud acquire 单列）。
- [ ] 取消/离开后 3s 内回到选择前 +30MB，连续 10 轮不逐轮爬升。
- [ ] 页面退出后 decode、回调和临时文件都停止/清理。
- [ ] UI 只持有受控像素的 preview，不持有原尺寸整批 `[UIImage]`。

**容易做错的改法**

- [ ] 先 `UIImage(data:)` 再 `UIGraphicsImageRenderer` 缩小；峰值已经发生。
- [ ] 用 `TaskGroup` 同时解 10 张图；墙钟可能变短，内存峰值会更危险。
- [ ] 只修 Home，不修 Camera；两条入口很快再次分叉。

完整三颗粒因果、原始数据、状态截图清单与跨电脑实现合同见 [Ailoha 多图导入｜内存重构合同](https://app.notion.com/p/3c9a444a6c0081d5a99ee67c58b55c4e) 与 `brain://reports/ios-frontend-refactor-map/current-main-photo-import-memory-baseline-2026-08-28.md`。

---

## P0-03 — Calendar 改一个事件，却重新展开和排序全部事件

**用户场景**

用户拖动、保存或同步一个 Calendar event。理想行为是只更新旧日期和新日期涉及的桶；当前任何 `events` 赋值都会在 MainActor 同步遍历全部事件、逐日展开跨天事件并排序全部日期桶。

**当前问题与溯源**

- [x] **事实：** `CalendarViewModel` 整体是 `@MainActor`。锚点：`CalendarViewModel.swift#L18-L24`。
- [x] **事实：** `events.didSet` 无条件调用 `precomputeGroupedEvents()`。锚点：`CalendarViewModel.swift#L27-L31`。
- [x] **事实：** projection 遍历全部 events，把每个跨天事件按日展开，再排序每个日期桶，最后发布 `eventsByDate`。锚点：`CalendarViewModel.swift#L170-L200`。
- [x] **事实：** `applySavedEvent` 和 `updateEvent` 只改一个数组元素，也会触发上述全量路径。锚点：`CalendarViewModel.swift#L475-L508`。
- [x] **事实：** canonical `events` 和 derived `eventsByDate` 都是 `@Published`，一次业务事务可能让观察者先后因两份状态失效。
- [ ] **推断：** 大数据量、跨天事件和频繁拖动时会放大主线程工作；是否已经越过帧预算，需要生产上界 fixture + signpost 证实。

**需要建立的工程认知**

- [ ] **Canonical state 只保存一份真相。** `events` 是真相，`eventsByDate` 是索引/投影；投影应由同一个 owner 维护，不能靠 `didSet` 暗中连锁。
- [ ] **单事件变化要表达 old/new。** 只有同时知道旧事件和新事件，才能精确算出受影响日期并删除旧桶记录。
- [ ] **首次全量和日常增量是两条合法路径。** refresh/load 可以后台 full build；create/update/delete 应走 delta。
- [ ] **受影响范围是旧日期集合 ∪ 新日期集合。** reschedule、跨天缩短/延长和删除都遵守同一规则。
- [ ] **一次事务只发布一次可见 snapshot。** 页面不能先看到新 `events` 配旧 `eventsByDate` 的中间态。
- [ ] **日期逻辑需要固定 Calendar/time zone。** DST、all-day、跨年不是边缘装饰，而是 projection 正确性的核心。

**怎么改：最小可行层**

- [ ] 将 canonical 存储改成 `[EventID: CalendarEvent]` 或保持数组但增加 ID index；不要每次 `firstIndex` + 全量重建。
- [ ] 定义 `CalendarEventDelta(old:new:)`，统一 create / update / delete。
- [ ] delta 计算旧、新覆盖日期，先从受影响桶移除旧 ID，再插入新 event，只排序这些桶。
- [ ] 把 `events.didSet` 的隐式副作用移到显式 `apply(delta:)`；调用方不能直接写数组绕过 owner。
- [ ] full refresh 在后台构造完整 projection，回 MainActor 一次提交 canonical + projection snapshot。

**怎么改：结构层**

- [ ] 建 `CalendarProjectionStore`，对外只暴露 immutable snapshot 和 `apply(delta:)`。
- [ ] cache invalidation、日期桶 projection 和 UI publish 由同一事务 owner 完成，避免三套日期范围算法漂移。
- [ ] 如果实际 UI 只需要当前可见 range，projection 可以按 month/range keyed；但先测全量增量是否已经足够，不提前做复杂分页缓存。

**为什么新方案更好**

单事件操作成本从“全部事件 + 全部跨天日期”缩小为“这一个事件覆盖的日期”。显式 delta 也让缓存、乐观更新、失败回滚和测试都围绕同一个事实边界展开。

**测试清单**

- [ ] **等价 oracle：** 对同一事件集合，`incrementalSnapshot == fullProjection(events)`。
- [ ] **操作矩阵：** create、update title、同日改时间、跨日 reschedule、缩短/延长、多日删除、乐观更新失败回滚。
- [ ] **时间边界：** DST 前后、时区切换、all-day、零时长、跨月、跨年、endTime 恰在午夜。
- [ ] **随机序列：** 生成一串合法 delta，每一步都与 full rebuild 比较，捕获漏删旧桶和重复插入。
- [ ] **触碰范围：** spy 记录 touched date keys，必须等于 old/new 覆盖日期并集；单事件更新不能触碰无关月份。
- [ ] **发布次数：** 一次业务事务只发布一次一致 snapshot；不让 View 观察到 canonical/derived 中间态。
- [ ] **性能：** 小/中/生产上界 fixture 下重复单事件更新；扩大总事件数时，增量更新时间应主要由受影响天数决定。
- [ ] **UI：** 真机 Release 同时做月/日切换、拖动事件、后台同步和快速返回；记录 SwiftUI update、hitch 和主线程 projection signpost。

**好的评估标准**

- [ ] 增量结果与 full rebuild 在所有 fixture 上等价。
- [ ] 单事件事务只触碰必要日期、只发布一次 snapshot。
- [ ] 生产上界 fixture 下 projection 不再形成明显 Main Thread 长任务。
- [ ] 优化后缓存命中、保存回写和失败回滚语义不变。

**容易做错的改法**

- [ ] 只把 full build 丢到后台；频繁更新仍浪费 O(n)，还新增并发 snapshot 过期问题。
- [ ] 只缓存当前月份，但更新跨月事件时漏掉旧桶。
- [ ] 保留 `events.didSet`，同时再加增量更新；两套 owner 会重复执行并产生错乱。

---

## P0-04 — Contact 只改姓名，背后的 Notes 也全部重算（PERF-001 仍是 P1 risk）

**用户场景**

用户停在 Notes tab，只编辑姓名、电话或公司。当前 `ContactDetailViewModel` 同时持有基础联系人、任务/日历、history、弹窗、保存状态、编辑草稿和四组 editor state；每个草稿字符都会发布 root 变化。Notes 因为观察同一个 root VM，会重新解析、拆分和按日期分组完全没变的备注。

**当前问题与溯源**

- [x] **事实：** VM 约有 36 个 state wrapper，包含数据、请求、导航/弹窗和编辑草稿。锚点：`ContactDetailViewModel.swift#L20-L84`。
- [x] **事实：** email/phone/address/note 的 child `objectWillChange` 都转发到 root VM。锚点：`ContactDetailViewModel.swift#L199-L216`。
- [x] **事实：** `ContactInfoView`、`ContactActivityView`、`ContactNotesView` 都观察同一个 root VM。
- [x] **页面证据：** iPhone 17 Pro / iOS 26.5 Debug Simulator 每档三次独立启动；12 次姓名输入稳定触发 12 次 `ContactDetailPage` body、12 次 `ContactNotesView` body、12 次全量解析和 12 次日期分组。
- [x] **耗时证据：** 1,000 条备注时，每次无关输入的 Notes 投影中位数约 23.6ms；5,000 条约 91.5ms。优化编译的 production grouping 五进程实验为 14.9 / 74.3ms，确认近线性机制。
- [x] **合同边界：** OpenAPI 只限制每条 note 最长 256 字符，没有 notes `maxItems`；长列表是合同允许的输入，但生产分布未知。
- [ ] **未知：** 最低支持真机 Release 是否形成 hitch、生产 p50/p95/p99 notes 数量，以及视图构建相对投影的占比。

**工程认知颗粒**

- [ ] **草稿不是 canonical contact。** 按键状态应由 editor-local draft 持有，确认时才 commit 一次。
- [ ] **Notes 需要输入，不需要 root VM。** 把 raw notes、fallback day、baseline、session-new indices 投影成不可变 input，再给 action closure。
- [ ] **投影和视图构建是两个成本。** `LazyVStack` 不会消除全量 parsing/grouping；先收窄 observation，再由 trace 决定 lazy 化。
- [ ] **数组下标不是长期 identity。** insert/delete 会让后续 ID 全漂；原文又可能重复，不能用 `id: \.self` 糊住。

**怎么改**

- [ ] 第一刀把 `editingText/company/title` 放进 `ContactFieldEditor` 的 local draft；confirm 发 typed commit，cancel 丢弃。
- [ ] 第二刀建立 `ContactNotesInput → ContactNotesProjection`；输入未变时 projection 为 0，View 不再观察 root VM。
- [ ] 先补 consumer test，再逐个删除四个 child `objectWillChange → root` forwarding。
- [ ] 保留 `ContactNotesGrouping` full result 作为 oracle；Widget baseline/session-new 解释不了时 full fallback。
- [ ] 真机证明 production 上界仍有大量 view construction 后，再引入 lazy container 和稳定 note occurrence ID。

**测试与评估**

- [ ] 纯测 editor validation、dirty/rollback、save mapping；projection 覆盖重复 note、非法日期、baseline/session-new。
- [ ] 姓名 / company-title 连续输入 20 次：Notes body update = 0，Notes projection = 0；确认只产生一次语义 commit。
- [ ] 新增/编辑/删除 note 每个事务最多发布一次 projection，且与 full oracle 完全等价。
- [ ] 最低支持真机 Release：1,000 notes 的无关输入 projection CPU 下降 100%，单次 app-owned 输入区间 p95 ≤8ms、无 >100ms hang。
- [ ] 截图验证 editor 打开、输入、取消、确认、重复 note、非法日期和大列表；一镜到底视频验证滚动位置、切 tab 与 note append 的连续性。
- [ ] 好结果是“字段输入只更新编辑区”，不是“代码文件变短”或“换成了新宏”。

完整三档运行值、截图、四个颗粒与跨电脑实现合同见 `brain://reports/ios-frontend-refactor-map/current-main-contact-detail-update-performance-baseline-2026-08-28.md` 与 [Contact 观察边界 Notion 总包](https://app.notion.com/p/3c9a444a6c00812699c0f17e0d631963)。

---

## P1-02 — Home 用一次无变化的 Published 写入，强迫外层重新布局

**用户场景**

Home 首次进入、键盘升降、输入模式切换和底部 TabBar 布局变化。当前代码注释明确写出：在 input idle 时无条件写 `isTabBarHidden = false`，借 `@Published objectWillChange` 强制外层 `safeAreaInset` 重算。

**当前问题与溯源**

- [x] **事实：** `HomePage.onAppear` 会在值可能已经是 false 时再次写 false。锚点：`HomePage.swift#L305-L317`。
- [x] **事实：** InputPanel 用两个 global `GeometryReader` reporter，在 frame 变化后异步发布给父页面。锚点：`InputPanel.swift#L12-L33,#L155-L179`。
- [x] **事实：** Home 把 frame 写入 State，并进一步调度 keyboard lift 计算。锚点：`HomePage.swift#L203-L223`。
- [ ] **推断：** measure → async publish → state change → layout 的反馈链可能在键盘/动画期间产生额外 layout passes；需要 SwiftUI cause/effect graph 确认频率。

**工程认知颗粒**

- [ ] layout state 和业务 state 不应借同一个 `objectWillChange` 通道互相唤醒。
- [ ] 无变化写入不是“刷新 API”，而是 owner 边界错误的信号。
- [ ] 几何变化要去重/设阈值；亚像素或动画中间帧不一定值得发布业务 State。
- [ ] 底部 chrome、keyboard avoidance、task list 各自需要明确 owner。

**怎么改**

- [ ] 先给 `navUIState` publish、frame reporter、keyboard lift 加计数/signpost，复现一次进入、一次键盘升降、一次 segment 切换。
- [ ] 把 TabBar/safe-area 的恢复做成显式、幂等的 layout owner API；值没变就不 publish。
- [ ] frame reporter 只在超过可解释阈值或关键边界变化时提交，合并同一帧的 panel/control frame。
- [ ] 如果系统 safe-area/keyboard API 已能表达最终布局，就删除手工 global frame 反馈；不要为了“少一个 GeometryReader”而另造更复杂坐标系统。

**测试与评估**

- [ ] 状态测试断言 no-op assignment 不发布。
- [ ] UI 脚本覆盖首次进 Home、onboarding→Home、四种 InputPanel mode、键盘开合、Live/Remembered、旋转/不同屏幕尺寸。
- [ ] 每个动作记录 frame publish 次数、layout update groups 和 hitch；改后不能靠增加延迟掩盖抖动。
- [ ] 验收同时看底栏不丢、输入框不跳、记忆区 keyboard lift 正确和更新次数下降。

---

## P1-03 — Release 的 Chat 双轨尚未进入同一闭环；main 已 V2-only

**用户场景**

当前 Release 仍可由 remote/debug flag 选择 SwiftUI V1 或 UICollectionView V2；`main@6922f359` 已删除 V1 与 flag，只构造 V2。现在不能继续把“双跑”写成全项目 current 事实，也不能因为 main 结构收敛就跳过 Release adoption 与 V2 本身的性能验证。

**当前问题与溯源**

- [x] **事实 / Release@0b90745：** Router 根据 flag 选择 `TaskChatPage` 或 `TaskChatPageV2`。
- [x] **事实 / main@6922f359：** Router 只构造 V2；`TaskChatPage.swift` 与 `ChatPageDebugFlags.swift` 不存在，旧 rollout cache 在启动时清理。
- [x] **事实 / main@6922f359：** V2 仍会在非等价 TaskData、feedback 或 expansion 时重建完整历史 rows，renderer 再准备完整 snapshot；结构收敛没有自动消除 O(n) 更新机制。
- [ ] **未知：** Release 真实 rollout 占比、V2 稳定期、崩溃 / hitch / accessibility 指标与进入 Release 的 owner 尚未绑定。

**工程认知颗粒**

- [ ] branch closure、Release adoption 与 production validation 是三件事；main consumer=0 只证明第一件。
- [ ] main 不再需要维护 V1/V2 性能对比矩阵；它需要 V2-only fixture、预算和防止旧 consumer 回流的 guard。
- [ ] Release 的 rollout 仍需 owner、观察周期、回滚信号和删除 / 合并日期。
- [ ] 比较项不仅是帧率，还包括首内容、长会话、滚动锚点、键盘、内存、无障碍和崩溃。

**怎么改与测试**

- [ ] main：复用 P0-01 的 100 / 1k / 5k fixture，只测 V2 的 streaming、prepend、复杂卡片、图片、键盘、deep link、后台恢复，并保存 Release 真机 trace。
- [ ] main：加旧页文件、debug flag、条件路由 consumer=0 guard，防止已经关闭的双轨重新长回来。
- [ ] Release：补当前 flag 分流、真实 rollout / crash / hitch / accessibility 收据，绑定迁移 owner；满足门槛后把 main 的结构收敛带入 Release。
- [ ] 回退只针对已声明的 Release rollout 合同；不要为了保留“可回滚”在 main 重新引入永久 V1。

---

## P1-04 — 不可见页面仍做工作：把“寿命”当成性能边界

**上下文**

Chat 基线里已经有一段值得保留的正向样板：`TaskChatViewModel.releaseSubscription()` 在页面真离场时取消数据管线和 topic；代码注释明确说明，否则不可见页面会继续收到 store 变化并全量 `rebuildChatRows`。current-main 锚点：`TaskChatViewModel.swift#L80-L118`、`TaskChatPageV2.swift#L924`。

这说明项目已经遇到过“看不见的页面还在白跑”这一类性能问题。下一步不是再抽一个万能 LifecycleManager，而是把同样的判断应用到其他长任务。

**工程认知颗粒**

- [ ] 每个 subscription、decode、refresh、timer、notification observer 都要回答：谁创建、何时取消、迟到结果交给谁。
- [ ] 页面隐藏、scene background、账号退出是三个不同生命周期，不能只靠 `deinit`。
- [ ] 取消不只停止 callback，还要停止上游 CPU/network/system work。
- [ ] re-entry 应回放 canonical snapshot，不靠旧页面偷偷保持订阅来“保鲜”。

**检查与测试**

- [ ] 对 Chat、图片 import、Calendar refresh、Contact history 分别做 enter→start→leave→late result 测试。
- [ ] 用 `confirmation(expectedCount: 0)` 或 spy 证明离场后没有 UI publish、projection、decode 或新请求。
- [ ] 反复进出 10 次，订阅数、Task 数、timer 数和 resident memory 不持续增长。
- [ ] 不用固定 sleep；在可控 continuation/clock 上暂停工作，再精确触发 leave 与 resume。

---

## P2 — 只在 trace 命中时处理的候选，不要做机械式“优化”

### P2-01 — `ForEach(id: \.self)`

- [x] 静态扫描有 33 处，但大量是 enum、整数 range、日期或固定选项，identity 本身稳定。
- [ ] 只有元素会变、hash 昂贵、存在重复值或 reorder 导致 identity 漂移时才是问题。
- [ ] 测试应构造 insert/delete/reorder，验证 State 不串行、行不被误重建；不要按命中数量批量改成 UUID。

### P2-02 — Formatter 构造

- [x] 静态扫描有 82 个 formatter 信号，其中包含 static/cache；命中不等于热路径问题。
- [ ] 只有 formatter 在 row/body 高频构造且 Time Profiler 命中时，才提到共享 formatter 或 FormatStyle。
- [ ] 验收要比较输出 locale/timezone 正确性，不能为了缓存引入线程安全或时区陈旧问题。

### P2-03 — `GeometryReader` / PreferenceKey

- [x] 它们是合法布局工具；真正的问题是高频 measure→state→layout 反馈。
- [ ] 用 SwiftUI cause/effect graph 看是不是自身变化又触发自身，不按 API 名字定罪。
- [ ] 修复后必须覆盖旋转、键盘、Dynamic Type 和不同尺寸；“少一次布局”不能以错位为代价。

### P2-04 — 隐式动画

- [ ] 只有大范围 transaction、对高频值动画、或动画触发昂贵 layout 时才处理。
- [ ] 测试正常动画与 Reduce Motion，两者都要正确；不能用全局禁动画隐藏性能问题。

### P2-05 — `@MainActor`

- [x] 类型在 MainActor 上不自动等于慢；真正要找的是 actor 上执行了解码、全量 projection、解析或同步 IO。
- [ ] Time Profiler 先抓调用栈，再把纯计算移出主 actor，最后只回主线程提交 snapshot。
- [ ] 不要把 mutable UI state 直接送到 detached task；先做 immutable input snapshot，并在返回时校验 generation。

### P2-06 — 大文件和 Swift Observation 迁移

- [x] 文件行数不是性能指标；`@Observable` 也不是自动性能按钮。
- [ ] 只有当 owner、effect 和 observation dependency 可以被拆清时才重构。
- [ ] 好结果是依赖更窄、测试更直接、trace 更少，不是“每个文件少于 300 行”。

---

## 每个性能 PR 都要带的评审清单

### 问题是否说清楚

- [ ] 用户动作是什么？慢发生在开始、持续过程还是结束？
- [ ] 这是源码事实、trace 事实、推断还是假设？
- [ ] 主成本是 CPU、内存、布局、IO、网络等待还是隐藏后台工作？
- [ ] 哪个 owner 应该负责，为什么不是相邻层？

### 改法是否有品味

- [ ] 先做最小机制修复，再决定是否值得结构重构。
- [ ] 正常主流程一眼能看懂；没有用 cache/flag/observer 拼出新的隐式控制面。
- [ ] 总量路径和增量路径都有明确入口。
- [ ] 失败、取消、离场和迟到结果不是 `try?` 后消失。
- [ ] 旧路径有删除门和回滚方式，不永久双跑。

### 测试是否能判输赢

- [ ] 有 full/correctness oracle，证明结果没改坏。
- [ ] 有参数化规模和边界 fixture，不只测 3 条 demo 数据。
- [ ] 异步测试等待事件，不使用固定 sleep。
- [ ] 有 clock/CPU/memory/hitch 中真正相关的指标，不是什么都测一遍。
- [ ] 有真机 Release trace；Simulator 结果只作开发反馈。
- [ ] before/after 使用同 commit 基线、设备、OS、fixture 和操作脚本。
- [ ] 报告中同时写最好、中位和最差样本，异常样本有解释。
- [ ] 回归门不会被无关错误误判为“性能 mutant 已被杀死”。

### 什么时候算完成

- [ ] 用户可见结果不退化。
- [ ] 正确性测试、组件行为测试、性能测试、真机场景四层都过。
- [ ] trace 证明主成本真的下降，而不是从主线程搬到看不见的后台继续浪费。
- [ ] 新代码的 owner、取消、缓存、失效和回滚边界写清楚。
- [ ] 指标不达标时能回退到上一个稳定机制，不需要回滚整轮重构。

## 建议执行顺序

1. [ ] 建 P0-00 fixture、signpost 和真机基线。
2. [ ] 做 P0-02 图片下采样与 bounded work；它边界最清晰，最容易形成独立收益和测试模板。
3. [ ] 做 P0-04 Contact editor-local draft + Notes 窄投影；无关按键触发 12/12 次 Notes 重算已被页面实验闭合，改动边界比整页拆分小。
4. [ ] 做 P0-03 Calendar delta projection；先用 full build 作 oracle，再替换单事件路径。
5. [ ] 做 P0-01 Chat incremental projection；先数据层，后 renderer 收敛。
6. [ ] 用 SwiftUI trace 判断 P1-02 Home 的布局反馈是否进入下一轮，不凭 GeometryReader 数量排队。
7. [ ] 完成 P1-03：main 保持 V2-only，Release 补 rollout / adoption 证据并关闭旧 V1 合同。
8. [ ] P2 只处理 trace 命中的具体 call site，不发起批量语法清理。

## 这份清单还缺什么证据

- [ ] 生产数据上界：最长对话、单联系人 notes 数、Calendar event 数、单次典型图片数与图片像素分布。
- [ ] 最低支持设备与团队认可的交互预算。
- [ ] Release 真机 SwiftUI/Time Profiler/Hangs/Hitches/Memory traces。
- [ ] 线上 hang、hitch、memory warning/termination 的 telemetry 基线。
- [ ] Release Chat V1/V2 的真实 rollout 占比、稳定期和删除 / 合并 owner；main V2-only 的真机预算。

拿到这些证据后，应更新优先级和绝对门槛；在此之前，本清单只把**可验证的代码机制**排好顺序，不把猜测升级成事故。
