---
description: Ailoha iOS current main 多图选择到上传的内存放大链、三进程 Simulator 机制实验、一次 prepare 候选与真机验收合同
status: request_changes
updated: 2026-08-28
sources:
  - repo://ailoha-agent-iOS@6922f359ad08da70242e4ec0c5c4a1486bc2908d
  - brain://reports/ios-frontend-refactor-map/evidence/current-main-photo-import-2026-08-28/photo-import-three-run-summary.json
  - brain://reports/ios-frontend-refactor-map/evidence/current-main-photo-import-2026-08-28/photo-import-mechanism-evidence.svg
  - https://app.notion.com/p/3c9a444a6c0081d5a99ee67c58b55c4e
confidence: high
sensitivity: internal
---

<!-- brain-capture:ios-frontend-current-main-photo-import-memory -->

# 一次选 10 张照片，内存为什么会冲高：图片导入基线与重构合同

> 一句话：当前 `main` 会先把整批照片交成 `[UIImage]`，再为每张图并行做一次完整压缩预检，真正上传前还会再压一次；10×48MP 的独立 Simulator 机制实验里，current-style 峰值增量中位数约 785MB，一次 prepare 的文件化候选约 82MB。这个结果证明了放大机制，不证明线上已经 OOM。

观察时间：2026-08-28。代码快照：`main@6922f359`。当前状态：**源码因果与 Simulator 机制已闭合；Ailoha 实现、真机 Release、真实 HEIC/JPEG、视觉视频和生产影响均未验证。**

## 用户会在什么场景碰到它

用户在首页输入框或相机页一次选 10 张高像素照片，想把它们交给 Ailoha 理解。

应该发生的是：第一张预览先出现，其余逐张准备；页面只保存小预览和可上传文件；用户删除、退出或换一批时，后台工作马上停。

当前代码做的是：等整批都变成 `UIImage` 后才回调；附件状态先保存原图；随后 10 张图各自启动预检压缩。用户未必马上看见错误，但内存峰值会由“同时有多少原图和压缩任务”决定。

这影响的是 Ailoha 的第一轮价值：截图和照片本来是用户给幕僚的上下文入口。如果输入阶段因为大图变慢、被系统终止，或者用户取消后还继续烧 CPU，后面的理解、确认和执行都不会开始。

## 先记住三个工程颗粒

1. **输入边界错了**：UI 收到的是整批 `[UIImage]`，不是逐张、受控、可恢复的准备结果。
2. **工作预算缺失**：每张先压缩一次预检，再压一次上传；预检可以多张同时跑。
3. **取消没有穿透**：外层任务会收到取消信号，但 detached 压缩和后续阶段没有在关键边界主动停。

它们属于同一个用户场景，但可以独立修改、回滚和验收，所以不能只写成“优化图片性能”。

## 问题一｜第一张图也要等整批原图先交到 UI

### 场景和看见的问题

用户选择 10 张图。当前两个入口都逐张执行 `loadTransferable(Data.self) → UIImage(data:) → append`，等数组完整后才调用 `onImageSelected(images)`。这让“第一张可用”和“整批收齐”绑在一起，也让页面 API 天然要求持有整批图片对象。

### 为什么会这样

```text
PhotosPicker 最多 10 张
  → 每张读成 Data
  → 每张建立 UIImage
  → 全部追加进 [UIImage]
  → 整批回调给页面
  → AttachmentData.inMemoryImage 继续保存每张原图
```

`UIImage(data:)` 可能延迟真正的像素解码，所以不能把“建了 10 个 UIImage”直接写成“已经同时解出 10 份完整 RGBA”。本轮实验也支持这个区分：10×48MP 在 prepare 后约增加 389MB，接近 10 份 38.5MiB 的合成 JPEG；预览和压缩开始后才出现更高峰值。

### 怎么改

把输入合同改成逐张事件，而不是整批图片：

```swift
struct PreparedPhoto {
    let id: UUID
    let preview: UIImage          // 最长边 600px，只给 UI
    let uploadFileURL: URL        // 最长边 2048px，已按上传策略编码一次
    let pixelSize: CGSize
    let byteCount: Int
}

enum PhotoPreparationEvent {
    case preparing(id: UUID, order: Int)
    case ready(PreparedPhoto)
    case failed(id: UUID, reason: PhotoPreparationFailure)
}
```

Home 和 Camera 都只把 picker item 交给一个 `PhotoImportPipeline`。pipeline 每准备好一张就发布一张；页面不再接收原图数组。

### 怎样算完成

- 10 张图不需要等整批准备完才出现第一张预览。
- UI 状态中没有原尺寸整批 `[UIImage]`；只保留 600px 预览和上传文件 URL。
- 图片顺序与用户选择顺序一致；逐张完成不能导致乱序或重复。
- Home 与 Camera 共用同一 pipeline 和 policy，不保留两份选择逻辑。

## 问题二｜每张图压两遍，第一遍还可能 10 张一起跑

### 场景和看见的问题

整批图片进入 `handleImageSelectionPipeline` 后，每张都会创建独立任务。预检阶段用 `Task.detached` 调 `FileUploader.compressImage`；通过后，`FileUploader` actor 在上传前再次调用同一个压缩函数。

这不是“网络同时上传 10 张”。上传 actor 会串行处理自己的同步压缩段；真正制造不稳定峰值的是多张 detached 预检可能重叠，并且所有任务都捕获并持有原图，直到各自结束。

### 最小因果链

```text
10 个 UIImage 常驻附件状态
  → 为 10 个 attachment 建 10 个外层任务
  → 10 个 detached 预检各自 resize + jpegData
  → 预检只取 byte count，压缩产物丢弃
  → FileUploader actor 再 resize + jpegData
  → 上传成功后才把原图替换成 600px 缩略图
```

### 机制实验说了什么

固定合成 JPEG，iOS 26.5 Simulator，优化编译，1/5/10 张 × 12/48MP，每个场景 3 个独立进程。内存每 5ms 采一次 `physical footprint`。

fixture 由仓库内 `FixtureGenerator.swift` 确定性重建，不提交大体积二进制：12MP SHA-256 `c156d9734e2ff13d14c7c2dc979f28b7e3b4e63277eb0ac2b25bd03471694729`；48MP SHA-256 `57d666e76f8ac4dcf03616b952dd32b96d4b801e34edc4de0948371c754cce3b`。

| 场景 | current-style 峰值增量中位数（范围） | 一次 prepare 候选中位数（范围） | current prepare 后 | 候选 prepare 后 |
|---|---:|---:|---:|---:|
| 1×12MP | 93.1MB（90.6–93.6） | 74.8MB（74.5–76.1） | 11.2MB | 9.3MB |
| 5×12MP | 420.7MB（420.5–420.7） | 83.1MB（81.9–83.2） | 50.8MB | 14.6MB |
| 10×12MP | 310.8MB（207.8–335.1） | 83.1MB（82.3–83.7） | 98.3MB | 14.7MB |
| 1×48MP | 126.3MB（126.0–126.4） | 82.0MB（81.7–82.0） | 40.4MB | 14.3MB |
| 5×48MP | 564.6MB（562.2–564.8） | 81.8MB（81.8–82.0） | 194.8MB | 14.3MB |
| 10×48MP | **785.1MB（570.5–912.2）** | **82.0MB（82.0–82.2）** | **389.1MB** | **14.5MB** |

10×48MP 中，候选峰值中位数比 current-style 低约 **89.6%**。current-style 的范围很宽，说明并行调度会改变瞬时重叠；因此本页只报中位数和范围，不把三次运行叫 p95。

同一 10×48MP 场景的本地阶段总时长：current-style 约 3.57s，一次 prepare 候选约 3.63s，候选没有靠显著拉长总处理时间换内存。候选第一张已编码上传文件和预览约 0.38s 可产出，其余可以逐张发布；current 的真实首张上屏仍需在 Ailoha 真机里测，不能由这个 harness 代替。

两条路径结束后都回落到基线约 +18–19MB。当前证据支持“瞬时工作集过大”，不支持“对象永久泄漏”；没有必要为了好看而声称发现 retain cycle。

### 推荐改法：一次 prepare，之后只传文件

每张图只进入一次受控准备：

1. picker 提供的内容先落到该批次拥有的临时目录；输入是文件 URL，不是原尺寸 `UIImage`。
2. ImageIO 从文件生成最长边 2048px 的上传图，遵循 EXIF 方向。
3. 从这份受控图生成最长边 600px 的 UI 预览。
4. 按现有 4MB/质量策略编码一次并写成上传文件。
5. 预检直接读文件 byte count；上传层接 URL，不再二次 resize/jpegData。
6. 初始并发上限设为 1。真机证明 2 个并发仍在预算内后，才允许调到 2；不能默认等于选择上限 10。

为什么这层合适：它同时恢复三个不变量——UI 不持原图、同一上传资产只编码一次、峰值由 policy 决定。只在 `FileUploader` 里加缓存，解决不了前面整批 `[UIImage]` 的常驻；只改 picker，解决不了重复压缩。

### 不解决什么

- 不改变后端文件协议或 Agent 如何理解图片。
- 不顺便重写 Task/Chat store。
- 不证明线上已经发生 OOM。
- 不把 600/2048 当永久产品真理；它们是与当前聊天预览和上传策略对应的初始 policy，画质 gate 可调整。

## 问题三｜用户删掉图片，压缩工作不一定马上停

### 场景和看见的问题

用户选了 10 张图后立刻删除附件、清空输入框或离开页面。`TaskLifecycleCoordinator` 会取消 registry 中的外层任务，这是正确起点；但预检使用 detached task，取消不会自动从父任务传播，而且后续没有 `Task.checkCancellation()`。

结果是：页面上的附件可以消失，但正在跑的预检仍可能继续；等待返回后，外层流程也没有显式的取消 guard，可能继续进入上传调用。具体网络请求是否最终中止还取决于 API 层，本轮未验证；预检压缩不会因父任务取消自动停止，是 Swift 并发合同和源码共同支持的事实。

### 怎么改

- 一个 `PhotoImportBatch` 明确拥有结构化 child tasks、临时目录和 UI commit 权限。
- acquire 前、ImageIO 前、编码前、写盘后、发布 UI 前、开始上传前都检查取消。
- 对无法中途打断的同步 ImageIO/编码段，至少把单项工作控制在 2048px 与并发 1，完成后发现取消就不提交并立即清文件。
- 删除附件只取消对应 item；清空、换批次、登出和页面 owner 销毁取消整个 batch。
- late result 用 `batchID + itemID + generation` 拒绝，不允许旧批次写回新输入框。

### 怎样算完成

- 在第 3 张 prepare 中取消：第 4–10 张不再启动。
- 取消后 250ms 内不再有 UI 状态提交；1s 内 CPU/文件 I/O 回到空闲或能解释的系统尾声。
- 取消、失败、成功、强退恢复后，临时目录都只有当前 manifest 声明的文件。
- 删除一张只影响该 item，不能误取消同批其他已完成图片。

## 为什么不是另外三个解释

1. **不是网络慢造成的峰值。** harness 没有网络，current-style 仍出现 570–912MB 的 10×48MP 峰值。
2. **目前不是内存泄漏结论。** 场景释放后两条路径都回落到相近水平；如果真机反复 10 次不回落，再抓 memgraph 找 owner path。
3. **也不能说所有内存都在 `UIImage(data:)` 当下发生。** prepare、preview、compress 是不同阶段；真正的像素解码可能延迟。必须分别打 signpost。

## 完整验收：一个新 Agent 可以照着做

### Gate 0｜代码合同

- [ ] `InputPanel` 与 `CameraPage` 不再出现 `loadTransferable(Data.self) → UIImage(data:) → [UIImage]` 整批回调。
- [ ] `AttachmentData` 的 composer 表示只保存受控 preview 与 upload URL，不保存原尺寸图片。
- [ ] 预检读准备文件的 byte count；同一 item 的 resize/encode 计数为 1。
- [ ] decoder spy 断言同时活跃 prepare 数不超过 policy。
- [ ] 结构化取消和 generation guard 有单元测试。

### Gate 1｜正确性与格式

- [ ] 1/5/10 张 JPEG、HEIC、PNG；12MP、48MP、超长 panorama、超大像素小文件。
- [ ] EXIF orientation 1–8；P3/sRGB；透明 PNG 转 JPEG 的背景策略明确。
- [ ] 损坏数据、iCloud 尚未下载、权限撤销分别给出可读错误，不用 `try?` 静默吞掉。
- [ ] 图片顺序、缩略图、上传结果与旧路径相同或符合书面 policy。
- [ ] 2048px 与 4MB gate 有边界 fixture；超过质量梯度后的行为明确。

### Gate 2｜真机 Release 性能

同一台“最低支持设备”和一台近期主流设备，各跑 5 个独立冷进程；保存 device、OS、configuration、commit、fixture hash。

- [ ] 10×48MP 峰值 physical memory 相对 current baseline 至少下降 60%。
- [ ] 10 张峰值不超过 1 张峰值的 2.5 倍；若超过，说明工作集仍随批次失控。
- [ ] 10×48MP “全部上传文件 ready”不比 current baseline 慢 20% 以上。
- [ ] 第一张 preview ready ≤500ms；若 iCloud 下载占时，单列 acquire 时间，不能算进 decode。
- [ ] Main Thread 没有 app-owned 原图 decode/encode 长任务；没有 >100ms 的 app hang。
- [ ] 取消/离开后 3s 内回到选择前 +30MB 以内；连续 10 轮后不逐轮爬升。
- [ ] 若内存不回落，再抓 before/after memgraph；不能只凭总 footprint 宣称 leak。

### Gate 3｜状态截图与一镜到底视频

每张截图右下角都显示 `caseID · commit · device · OS`，状态不是靠文件名猜。

1. `01-picker-10-selected.png`：系统选择器已选 10 张。
2. `02-first-preview.png`：第一张已出现，其余显示逐张 preparing，不是空白等整批。
3. `03-mixed-ready-failed.png`：一张损坏时，成功图片不消失，错误可定位到单项。
4. `04-all-ready-order.png`：10 张 ready，顺序与 picker 一致。
5. `05-cancel-at-third.png`：第 3 张时取消，后续不继续冒出。
6. `06-remove-one.png`：删除一张只取消该 item。
7. `07-retry-one.png`：重试不重新处理整批，也不重复附件。
8. `08-background-return.png`：退后台再回来，批次状态可解释，无孤儿 spinner。
9. `09-memory-trace.png`：与本次视频同 case 的 peak、release 与 signpost 区间。
10. `photo-import-proof.mp4`：一镜到底展示选择 10 张、第一张渐进出现、全部完成、删除/取消、重新选择。

截图证明状态和构图；trace 证明内存和线程；两者必须共享 case ID，不能互相代替。

### Gate 4｜发布与恢复

- [ ] feature flag 可在不改后端的情况下切回 current pipeline。
- [ ] rollout 记录 prepare failure、cancel latency、peak memory diagnostic、upload retry 和 orphan cleanup。
- [ ] 线上发现方向/色彩/上传失败回归时，先关新 pipeline；临时文件 manifest 能在下次启动清理。
- [ ] 当前只完成 Gate 0 的调查与独立 harness；Gate 0 实现、Gate 1–4 均未开始。

## Talent Signal 能先验证什么

Talent Signal 可以先用“5/10 张候选人主页截图”复用同一 pipeline，录一条完整视频验证：逐张预览是否让用户更安心、单项失败/重试是否清楚、取消后是否真的停止，以及 `caseID → 截图 → trace → JSON` 的交付格式能否被另一个 Agent 冷读。

它不能替 Ailoha 验证 Task attachment、当前 uploader、真实账号、后端文件合同或 Ailoha 的真机内存门。它适合验证交互语言和通用媒体 pipeline，不是 Ailoha 发布证据。

## 事实、推断和待验证

### 已证实

- 两个 PhotosPicker 入口都把整批 `Data → UIImage` 后才回调。
- 附件状态在上传完成前保存原图。
- 每张图先 detached 压缩预检，上传 actor 再压一次。
- 10 个 attachment 使用 10 个不同 registry key，因此可以同时进入预检。
- remove/clear 会取消外层 task；预检 detached task 没有自动取消传播，pipeline 没有显式取消检查。
- 固定 Simulator 机制实验中，10×48MP current-style 峰值显著高于一次 prepare 候选，释放后回落。

### 由证据推断

- 真机导入高像素图片存在 memory pressure、hitch 或被系统终止的高风险；严重度取决于设备、真实格式和并发调度。
- 统一 file-backed pipeline 能同时减少内存峰值、重复压缩和取消后隐形工作。

### 待验证

- 线上是否已经发生可归因的 OOM/jetsam。
- PhotosPicker continuation、预览 decode 与状态提交在当前 App/编译模式下的精确线程分布。
- 真实 HEIC、P3、HDR、iCloud asset 与 panorama 的内存/色彩/方向。
- 真机上并发 1 与 2 的最佳吞吐/峰值平衡。

因此本项仍写作 **P0 工程工作与发布门**，不能写成“已证实线上 P0 事故”。

## 可重放证据

工具目录：`brain://reports/ios-frontend-refactor-map/tools/photo-import-harness/`

跨电脑实施与冷读页面：[Ailoha 多图导入｜内存重构合同](https://app.notion.com/p/3c9a444a6c0081d5a99ee67c58b55c4e)。

```bash
reports/ios-frontend-refactor-map/tools/photo-import-harness/run_matrix.sh
xcrun swiftc -O \
  reports/ios-frontend-refactor-map/tools/photo-import-harness/SummarizeResults.swift \
  -o /tmp/ailoha-photo-import-summarize
/tmp/ailoha-photo-import-summarize \
  reports/ios-frontend-refactor-map/evidence/current-main-photo-import-2026-08-28 \
  reports/ios-frontend-refactor-map/evidence/current-main-photo-import-2026-08-28/photo-import-three-run-summary.json
```

证据边界：iOS 26.5 iPhone 17 Pro Simulator；合成 4032×3024 与 8064×6048 JPEG；文件约 9.66MiB 与 38.49MiB；不含 PhotosPicker/iCloud、真实 Ailoha UI、网络、真机 jetsam 或生产数据。

源码锚点：

- `github://Ailoha-ai/ailoha-agent-iOS@6922f359/Ailoha/Components/InputPanel.swift#L299-L321`
- `github://Ailoha-ai/ailoha-agent-iOS@6922f359/Ailoha/Pages/Home/CameraPage.swift#L59-L81`
- `github://Ailoha-ai/ailoha-agent-iOS@6922f359/Ailoha/GlobalService/Task/TaskLifecycleCoordinator.swift#L1058-L1079`
- `github://Ailoha-ai/ailoha-agent-iOS@6922f359/Ailoha/GlobalService/Task/TaskAttachmentManager.swift#L163-L193,#L389-L437`
- `github://Ailoha-ai/ailoha-agent-iOS@6922f359/Ailoha/GlobalService/File/FileUploader.swift#L16-L59,#L128-L155`
- `github://Ailoha-ai/ailoha-agent-iOS@6922f359/Ailoha/GlobalService/Import/ImportRetryTaskRegistry.swift#L35-L61`

Apple 一手依据：

- [iOS Memory Deep Dive：图片内存由像素尺寸而不是压缩文件大小决定；ImageIO 下采样避免完整解码峰值](https://developer.apple.com/videos/play/wwdc2018/416/)
- [CGImageSource：从 URL/Data 高效读取并创建 thumbnail](https://developer.apple.com/documentation/imageio/cgimagesource)
- [XCTMemoryMetric：记录 performance test 的 physical memory](https://developer.apple.com/documentation/xctest/xctmemorymetric)
- [Preventing memory-use regressions：用 XCTest 建峰值与增长基线](https://developer.apple.com/documentation/xcode/preventing-memory-use-regressions)
- [Swift Task：取消是协作式的，执行代码负责检查并停止](https://developer.apple.com/documentation/swift/task/)
- [Task.detached：取消传播与 task-local 等必须手工处理](https://developer.apple.com/documentation/swift/task/detached(name:priority:operation:)-9xki7)

## 当前下一步

由 iOS feature owner 先实现一个不接业务网络的 `PhotoImportPipeline` package/test target：同一 fixture 产出 preview + upload file，证明尺寸、方向、一次编码、并发 1、取消和清理。通过后再接 Home/Camera 和现有 uploader，并在真机 Release 跑 Gate 2。不要先改后端，也不要同时重写 Task store。

内部阅读合同通过；团队冷读与真实上手体验待验证。
