---
description: Ailoha iOS SwiftUI/UIKit 性能代码风险、可反证条件与 Instruments 验证计划
status: request_changes
updated: 2026-08-28
sources:
  - repo://ailoha-agent-iOS@5e6ac6e41c53780c85808c92290d2bd38f809123
  - repo://ailoha-agent-iOS@6922f359ad08da70242e4ec0c5c4a1486bc2908d
  - brain://reports/ios-frontend-refactor-map/current-main-contact-detail-update-performance-baseline-2026-08-28.md
confidence: medium
sensitivity: internal
---

# 性能风险审计
<!-- brain-capture:ios-frontend-performance-p0-prioritization -->

## 结论边界

当前仍没有真机 Release ETTrace/Instruments 或生产 telemetry，因此不能声称“线上已卡顿”。不过 Contact 与 Chat 已有 Debug Simulator 页面复现，多图与 Calendar 已有机制实验；这些可以证明具体运行链，不等于生产严重度。Apple 的 [SwiftUI performance 指南](https://developer.apple.com/documentation/Xcode/understanding-and-improving-swiftui-performance) 与 [WWDC25 Optimize SwiftUI performance with Instruments](https://developer.apple.com/videos/play/wwdc2025/306/) 说明了应如何用 SwiftUI Instrument 关联 long view-body/update lanes、依赖变化和调用栈；最终产品定级仍由 Release build trace 与生产分布决定。

需要继续下钻时，使用 [性能工程认知与改造清单](performance-engineering-checklist.md)：它按 P0/P1/P2 把每个候选拆到用户动作、状态 owner、主成本、最小改造边界、正确性 oracle、性能测量和常见误判。

## P0-only 决策

这里把两个容易混淆的口径分开：

- **已证实的性能事故 P0：0 项。** 当前没有真机 Release trace、线上 telemetry、hang/hitch 时长或内存终止证据，不能把静态风险写成已发生的 P0。
- **建议按 P0 优先投入的性能工作：4 项。** 这是工作优先级，不是事故严重度。四项都位于核心用户路径，并且已经能从固定修订定位到一个可独立修复、独立验证、独立回滚的技术机制。

### P0-WORK-01 — 长对话只更新变动的消息，不重建整条时间线

**人话：** current main 已经只剩 Chat V2，但一条新消息或状态变化进来，仍会重新生成整份 `chatRows`，renderer 也会准备整份 snapshot。对话越长，每次更新重复做的工作越多。旧 V1 的布局问题只属于历史 / Release 迁移证据，不再拿来解释 main 的当前成本。

**根因链：**

1. `TaskChatViewModel.apply(_:)` 每次收到新的 `TaskData` 都调用 `rebuildChatRows()`。
2. `rebuildChatRows()` 从完整 `taskData` 重新生成整个 `[ChatRowItem]`，然后再次发布。
3. V2 renderer 会遍历完整 rows 准备 diffable snapshot；复杂 Markdown、联系人卡片和内容 revision 会继续放大无关工作。
4. V2 已有稳定 ID、按行 reconfigure 和后台冻结等支点，但 projector → renderer 还没有完整的增量 patch 合同。

**恰当的技术颗粒：**

1. 新增纯函数 `ChatTimelineDiff(old:new:) -> ChatTimelinePatch`，只输出 insert / update / delete 的稳定 row ID。
2. 为 Markdown / card 行增加 `rowID + contentRevision` 缓存；内容没有变化就不重新解析和构造。
3. ViewModel 发布 patch 或稳定 row snapshot，不再对每次 processing-state 变化无条件重建全部行。
4. 保持 main 的旧页 / flag consumer=0；用 full rebuild 作 correctness oracle，逐类打开增量路径，不同时重写数据层和 UI 层。

**为什么新方案更好：** 更新成本从“与历史总长度相关”收敛到“与本次变化的行数相关”；稳定 ID 和内容 revision 让 SwiftUI/UIKit 都能跳过未变化行；迁移仍可逐步回滚。

**源码锚点：** `main@6922f359` 的 `TaskChatViewModel.swift`、`ChatRowItem.swift`、`ChatCollectionView.swift`；机器与页面证据见 [长对话单行更新基线](current-main-chat-update-performance-baseline-2026-08-28.md)。

**P0 验证门：** 同一真机 Release build 跑 100 / 1,000 / 5,000 rows，连续追加与更新消息；记录主线程总时间、long update lane、hitch/hang、峰值内存和首条消息可见时间。只有 trace 证明核心对话不可用或明显超预算，才把事故严重度升为 P0。

### P0-WORK-02 — 图片先下采样再进入 UI，内存同时只处理少量图片

**人话：** 一次最多选 10 张图，当前路径会先把每张图完整读成 `Data`，再解成完整 `UIImage`，最后把所有图片一起留在数组里。高像素照片可能短时间占用数百 MB，并挤占主线程。

**根因链：**

1. `InputPanel` 与 `CameraPage` 都调用 `loadTransferable(Data.self)`。
2. 随后直接 `UIImage(data:)`，没有明确像素上限，也没有明确的后台解码边界。
3. 循环把所有完整图片追加到 `[UIImage]`，直到整批完成后才交给上传路径。
4. 例如 12MP RGBA 图片仅解码像素就约 48MB；10 张的理论像素体量约 480MB，还不含压缩 `Data`、缓存和 UI 副本。

**恰当的技术颗粒：**

1. 建一个共享 `PhotoImportPipeline.prepare(item:maxPixel:)`，InputPanel 与 Camera 只走这一条入口。
2. 用 ImageIO 在后台按明确像素上限生成缩略/上传版本，禁止先构造原尺寸 `UIImage`。
3. 并发限制为 1–2 张；每张处理后立即释放原始 `Data`，支持取消。
4. UI 只持小缩略图和临时文件 URL；上传从文件读取，成功、取消或登出后删除临时文件。

**为什么新方案更好：** 峰值内存由“图片数量 × 原始分辨率”变为有上限的工作集；昂贵解码离开主线程；两个入口共用一套像素、取消和清理合同，避免以后再次分叉。

**源码锚点：** `InputPanel.swift#L206-L228`；`CameraPage.swift#L59-L81`；可复用对照为 `ImportDestinationSheet` 的 ImageIO thumbnail 路径。

**P0 验证门：** 真机 Release 下分别导入 1 / 5 / 10 张 HEIC/JPEG 和超大 panorama；记录峰值 resident memory、主线程 decode 栈、交互 hitch 与 memory termination。出现可复现终止、长时间无响应或核心发送不可完成时，事故严重度升为 P0。

### P0-WORK-03 — Calendar 单事件变化只重算受影响日期

**人话：** 现在改一个日历事件，也会把所有事件重新按天展开、全部分组、全部排序，然后再通知一次页面。数据越多、跨天事件越多，拖动和保存越容易把主线程堵住。

**根因链：**

1. `CalendarViewModel` 是 `@MainActor`。
2. `events` 的任何赋值或数组元素替换都会同步触发 `precomputeGroupedEvents()`。
3. 该函数遍历全部事件，把跨天事件逐日展开，再对每个日期桶排序。
4. 完成后又给 `@Published eventsByDate` 赋值，形成 canonical 数组与派生字典两次 observation fan-out。

**恰当的技术颗粒：**

1. 用 `[EventID: CalendarEvent]` 保存 canonical event，并定义 `CalendarEventDelta(old:new:)`。
2. delta 只计算旧事件与新事件覆盖的日期集合，删除旧桶记录、插入新桶记录并只排序这些桶。
3. 批量首次加载时在后台构造完整 projection，最后回主线程一次性提交 snapshot。
4. 删除 `events.didSet` 的隐式副作用；所有 create / update / delete / refresh 统一调用一个显式 `apply(delta:)` owner。

**为什么新方案更好：** 单事件操作的成本从“所有事件与所有日期”缩小为“这一个事件影响的日期”；只发布一次 snapshot，页面不会先因 `events`、再因 `eventsByDate` 连续失效；显式入口也更容易写性能测试和回滚。

**源码锚点：** `CalendarViewModel.swift#L18-L31,#L49-L51,#L170-L200,#L482-L508`。

**P0 验证门：** 使用生产上界 fixture，重复月/日切换、单事件拖动、跨天事件更新与后台同步；用 signpost + SwiftUI Instrument 记录 projection 时间、long update lane 和 hitch。只有核心日历交互被 trace 证明不可用，才把事故严重度升为 P0。

### P0-WORK-04 — 联系人字段草稿只更新编辑器，不重算背后的 Notes

**人话：** 用户停在 Notes tab 改姓名时，备注一个字都没变；当前每输入一个字符，Notes 仍会重新解析、拆分和按日期分组全部备注。

**已复现根因链：**

1. `editingText` 是 root `ContactDetailViewModel` 的 `@Published`。
2. `ContactNotesView` 观察同一个 root VM。
3. 12 次姓名输入稳定触发 12 次 Page body、12 次 Notes body、12 次全量解析和 12 次日期分组。
4. Debug Simulator 1,000 / 5,000 notes 的每次无关 Notes 投影中位数约 23.6 / 91.5ms；生产数量分布未知。

**恰当的技术颗粒：**

1. `ContactFieldEditor` 本地持有 draft，确认时向 root 发一次 typed commit。
2. Notes 只消费 `rawNotes + fallbackDay + baseline + sessionNewIndices` 的不可变 input/projection，不再观察 root VM。
3. 保留 `ContactNotesGrouping` full result 作为 oracle；先补 consumer test，再逐个删 child→root forwarding。
4. `LazyVStack` 是后续 view-construction 颗粒，不能代替 observation 修复；数组下标 identity 也必须先解决。

**P0 验证门：** 20 次姓名/company 输入的 Notes body/projection 为 0；真实 note 事务至多投影一次且与 full oracle 等价；最低支持真机 Release 的 1,000 notes 单次 app-owned 输入区间 p95 ≤8ms，无 >100ms hang。

### 本轮明确不做

P0 工作只处理上面四个机制。Home layout feedback、泛化 formatter、GeometryReader 和 `ForEach(id: \.self)` 仍不在本轮 P0 清单；没有具体 trace 前不顺手批量拆，也不把 Swift Observation 迁移当成性能目标本身。

## 静态表面

范围与可访问性审计一致，共 441 个生产/共享 Swift 文件。

| 信号 | 命中 / 文件 | 用途 |
|---|---:|---|
| `@Published` | 209 / 51 | 定位 broad ObservableObject 与 fan-out |
| `@StateObject` / `@ObservedObject` / `@EnvironmentObject` | 39 / 30；46 / 36；13 / 11 | 定位 observation ownership |
| `@Observable` | 0 / 0 | 只是现状，不等于必须迁移 Observation |
| ForEach `id: \.self` | 33 / 20 | 验证 identity 是否稳定；不逐条定罪 |
| formatter construction | 82 / 39 | 区分静态缓存与热路径构造 |
| `UIImage(data:)` | 7 / 6 | 定位全尺寸 decode/内存峰值 |
| GeometryReader | 29 / 26 | 定位 layout feedback；不等于昂贵 |
| PreferenceKey / preference | 41 / 10 | 定位多轮 layout 与向上传播 |
| `Task.sleep` / `asyncAfter` | 120 / 68 | 区分动画调度与 correctness timing workaround |
| `@MainActor` / `Task.detached` | 358 / 156；8 / 7 | 定位主线程工作与显式 offload |
| animation signals | 151 / 57 | 与更新频率和 Reduce Motion 一起审查 |

## 高价值验证候选

### PERF-001 — Contact detail observation fan-out

`ContactDetailViewModel` 约 36 个状态 wrapper，editor draft 与页面业务状态共用 root owner；`ContactNotesView` 观察整个 VM，四个 child state 还把 `objectWillChange` 转发给 root。页面实验已证明姓名输入会实际触发 Notes 全量投影，不再只是“可能”。

- 证据：`main@6922f359` 的 3×3 Debug 页面运行；1,000 / 5,000 notes 每次无关投影中位数约 23.6 / 91.5ms；完整链见[联系人字段更新基线](current-main-contact-detail-update-performance-baseline-2026-08-28.md)。
- 反证：新 revision 的 20 次无关字段输入使 Notes body/projection 为 0，或真机 Release trace 证明 current 路径在生产上界内无用户影响。
- 候选修复：editor-local draft + 窄 Notes input/projection；consumer test 后逐个删除 child forwarding，不一次拆完 36 个状态。
- 验收：相同 fixture（打开详情→编辑姓名/company→取消/确认→新增 note）before/after，记录 update count、long lane、总时间、hitch，并比较 full oracle。

### PERF-002 — Calendar 派生计算与二次发布

`events.didSet` 同步调用 `precomputeGroupedEvents()`，遍历事件的日期跨度并对每日数组排序，再给另一个 `@Published eventsByDate` 赋值。大范围同步或频繁单事件更新可能放大计算和 view invalidation。

- 证据：`CalendarViewModel.swift#L27-L30,#L51,#L173`。
- 反证：真实最大数据集下 signpost 与 SwiftUI trace 始终低于预算。
- 候选修复：按 month/range keyed projection；更新单事件时只 invalidate 受影响日期；避免 derived state 双发布。
- 验收：小/中/生产上界 fixture，月/日切换、拖拽与后台同步。

### PERF-003 — PhotosPicker 全量 Data 解码

InputPanel 与 Camera 在 SwiftUI `Task` 中 `loadTransferable(Data.self)` 后直接 `UIImage(data:)`。这可能在主 actor 上解码大图并造成峰值；`ImportDestinationSheet` 已使用 ImageIO thumbnail，是可复用对照。

- 证据：`InputPanel.swift#L217-L218`；`CameraPage.swift#L65-L66`。
- 反证：Time Profiler/Memory 证明 decode 不在主 actor 且峰值受控。
- 候选修复：后台 ImageIO downsample + 明确像素上限；只在发布 UI 状态时回主 actor。
- 验收：1/5/20 张 HEIC/JPEG、超大 panorama、低内存模拟。

### PERF-004 — Chat V2 长会话；Release 迁移债另行裁决

`main@6922f359` 已 V2-only；当前性能风险是完整 projection + 完整 renderer preparation，而不是 main 仍双 renderer。Release 是否仍双跑、何时收敛属于迁移 gate，不能与 current-main 热点混成一个问题。

- 证据：current main 的 `TaskChatViewModel.swift`、`ChatRowItem.swift`、`ChatCollectionView.swift` 与[长对话单行更新基线](current-main-chat-update-performance-baseline-2026-08-28.md)。
- 验收：100 / 1,000 / 5,000 rows；冷启动、首消息、连续 streaming、图片 / 卡片、键盘、mention deep link；同设备 Release build 记录 hitch / hang / memory。
- 结构门槛：main 旧页 / flag consumer=0 持续为绿；Release rollout、回退与删除 owner 另有明确收据。

### PERF-005 — Home layout/animation feedback

Home 组合 preference、safe-area/bottom-bar 更新、动画和 NotificationCenter refresh。注释明确存在用 published update 迫使外层重新计算底栏的路径；这是结构性 invalidation 候选，仍需 trace 才能定级。

- 证据：`HomePage.swift#L314-L338`。
- 候选修复：把 bottom chrome state 与 task list state 分离；layout state 不借业务 VM 的 `objectWillChange` 传播。

## 不应机械优化的项目

- Formatter 82 个命中包含 static/cache；只有出现在 body/row 热路径且 trace 支持时才改。
- GeometryReader/PreferenceKey 是合法布局工具；只修重复 layout loop 或高频向上传播。
- `ForEach(id: \.self)` 只有在元素 identity 会变化或 hash 成本高时才有问题。
- 1904 行文件不自动慢；拆分目标是 owner/effect 清晰，而非追求行数 KPI。
- Swift Observation 宏不是目标本身；窄订阅与可测试 state owner 才是目标。

## 运行验证计划

| 阶段 | 工具与配置 | 脚本 | 输出 |
|---|---|---|---|
| Baseline | 真机、Release、相同后端/fixture；SwiftUI Instrument | Home scroll/refresh、Contact edit、Calendar month/day、Chat 1k rows | trace + signpost + build/commit/device 元数据 |
| CPU | Time Profiler | Calendar sync/grouping、image import、Markdown/Chat insert | main actor stacks、self/total time |
| Responsiveness | Hangs/Hitches | 深链 replace stack、键盘、sheet、连续 ARPC | hitch duration/count、hang stacks |
| Memory | Allocations/Memory Graph | 20 图导入、Chat 长会话、账户切换 | peak/resident、leaked owner、scope deinit |
| Compare | 同一脚本 before/after | 每个 feature slice | 差异、置信区间/重复次数、是否回归 |

## 预算建议（需团队确认）

不要把建议预算冒充现有 SLO。可先以交互帧预算、无主线程长任务、账户 scope 可释放作为临时开发门槛，再用基线数据校准。每个优化 PR 必须记录：信号、设备/build、场景、before、after、用户可感知结果、回滚方式。
