---
description: Ailoha iOS current main 长对话单条更新的全量投影链、独立逻辑基线、Debug Simulator 页面实验与最小增量改造合同
status: request_changes
updated: 2026-08-28
sources:
  - repo://ailoha-agent-iOS@6922f359ad08da70242e4ec0c5c4a1486bc2908d
  - brain://reports/ios-frontend-refactor-map/evidence/current-main-chat-projection-2026-08-28/chat-projection-five-process-summary.json
  - brain://reports/ios-frontend-refactor-map/evidence/current-main-chat-update-2026-08-28/debug-simulator-chat-update-summary.json
  - https://app.notion.com/p/3c9a444a6c0081febd30cdd74a913ecb
confidence: high
sensitivity: internal
---

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

# 长对话只多一条消息，为什么整条时间线都要再算一遍

> 一句话：当前 `main` 已经把 Chat V1 删掉，也有稳定 ID、同轮合并、后台冻结和滚动锚点这些好保护；但只追加一条消息、点一次反馈或展开一张卡片，仍会在主线程从完整历史重建所有 `ChatRowItem`，随后列表层再全量扫描并生成 snapshot。5000 行 Debug Simulator 离线页面中，单行追加三次的“投影 + 同步列表路径”配对中位数约 81.9ms。它证明这条路径值得作为 P0 工程切片处理，不证明线上用户已经卡顿。

观察时间：2026-08-28。代码快照：`main@6922f359`。当前状态：**源码链、纯投影机制和 Debug Simulator 页面实验已闭合；真机 Release、真实 Markdown / widget 混合、生产历史长度和 Instruments trace 尚未取得。**

## 用户会在哪里感觉到它

用户打开一段很长的 Task Chat，正在看旧内容。此时可能发生四种很普通的动作：

1. Ailoha 在末尾补一条回复。
2. processing 状态变化。
3. 用户给某一轮点 👍 / 👎。
4. 用户展开某一条较长内容。

这些动作通常只改变一小块界面。当前路径却先把整段历史重新解释成 rows，再让列表从头检查每个 row。历史越长，单次小动作携带的无关工作越多；如果几次状态变化接连发生，ViewModel 会先各算一遍，列表层只能在下一 tick 合并最后一次提交，前面的投影 CPU 已经花掉。

这不是“Chat 太大”这么粗的问题。恰当的工程颗粒是：**一次明确的小变化，是否只触碰它必须影响的 row 和依赖边界。**

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

当前实现不是一无是处，下面几层应保留：

- `TaskStateManager.taskPublisher` 已用 `removeDuplicates()` 吞掉完全等价的 `TaskData`。
- `ChatRowItem.buildRows` 已把过去的 widget 回溯从 O(n²) 降到 O(n)。
- `ChatCollectionView.setRows` 会在同一 runloop 只提交最后一份 rows。
- 页面进入后台时不直接 apply，回前台只兑现最后状态。
- row ID 稳定，同 ID 内容变化走 reconfigure；完全一致会提前返回。
- prepend、贴底跟随、长文重测和重复 ID 防崩都已有明确保护。
- current main 只构造 Chat V2；这里没有“再删一次 V1”的任务。

因此建议不是重写聊天页，而是把“变化是什么”补进 ViewModel → projection → renderer 之间。

## 根因链：一条新消息怎样变成两次全量工作

```text
TaskStateManager 发布一份真的变过的 TaskData
  → TaskChatViewModel.apply(data)
  → 无论变化属于消息、processing、附件还是其他字段，都调用 rebuildChatRows()
  → ChatRowItem.buildRows(完整 conversationHistory)
      → selection 展示重排
      → id/index、latest widget、feedback、user action、widget anchors 全量预计算
      → 全历史生成全部 ChatRowItem
  → ChatCollectionView.setRows(整份 rows)
      → 同一 tick 合并最后一次提交（正确且应保留）
  → applyRowsNow(整份 rows)
      → 去重、ID 数组、reconfigure/insert、rowsById、diffable snapshot 全量生成
      → apply、布局、滚动保护
```

真正缺的合同是：上游只交“新的世界快照”，没有交“这次到底变了什么”；下游为了保证正确，只能重新解释全部内容。

## 四个可以独立理解、修改和验收的颗粒

### 颗粒一｜一次真实 TaskData 变化，默认重建全部 rows

`TaskChatViewModel.apply(_:)` 每次收到非等价快照都调用 `rebuildChatRows()`。这个守住了正确性，但把消息、processing、附件和 durable status 等不同变化都当成“整条时间线失效”。

**怎么改：** 在 ViewModel 内先加一个纯值的 `ChatTimelineChangeClassifier`。它比较旧、新投影输入，只输出有限几类变化：`initial/replaceAll`、`append`、`messageUpdate`、`processing`、`feedback`、`expansion`。无法安全解释时明确 fallback 到 full build。

**为什么这一层合适：** 不需要先重写 `TaskStateManager`，也不改变 REST / ARPC 合并权威；它只补上渲染投影所缺的变化语义。

### 颗粒二｜反馈和展开只影响一行，却走完整历史

`expandedMessageIds.didSet` 和 `optimisticFeedback.didSet` 都直接调用 `rebuildChatRows()`。这两个动作最适合作为第一批增量试点：它们的输入和影响范围最清楚。

**怎么改：** `ChatTimelineProjector` 缓存 `messageID → rowID`、`turnID → lastAgentTextRowID` 和 row 内容 revision。展开只重算对应 message row；反馈只重算对应 turn 最后一条 agent text row。若索引缺失或历史结构已变，走 full build。

**为什么先做它们：** 可以先证明增量合同、oracle 和 renderer patch 是可靠的，不必第一步就处理 widget 全局依赖。

### 颗粒三｜“只追加一条”不能天真地只 append 一个 row

新消息可能结束 status group、改变同一 response cid 内的 selection 顺序、让旧 widget 不再是 latest，或通过后缀条件改变之前某张 widget 的门禁。所以“数组最后加一项”不是可靠算法。

**怎么改：** projector 先识别追加是否满足安全条件；从最近一个明确的 turn / response 边界重算尾段，并重新计算会受 latest-widget / suffix-anchor 影响的已知 row。增量结果每次与现有 full build oracle 比较。任何未知 message shape、move 或边界不成立都回退 full build。

**为什么不是直接切片 `history.suffix(1)`：** 那会拿性能换错序、旧卡片可点或 status grouping 错误，属于不可接受的“优化”。

### 颗粒四｜列表知道稳定 ID，但提交前仍从头扫完整 rows

`applyRowsNow` 会重新去重、生成所有 ID、比较所有内容、重建 `rowsById` 和完整 diffable snapshot。稳定 ID 让 UIKit 少重建 cell，却没有让准备成本变成增量。

**怎么改：** renderer 接受 `ChatTimelinePatch`：

```swift
struct ChatTimelinePatch: Equatable {
    var inserted: [ChatRowItem]
    var updated: [ChatRowItem]
    var deletedIDs: [String]
    var moved: [(id: String, afterID: String?)]
    var fallbackRows: [ChatRowItem]?
}
```

内容更新只更新 `rowsById` 的对应 key 并 reconfigure 对应 ID；结构变化才修改 snapshot。保留重复 ID guard、冻结、coalescing、prepend anchor 和 pinned-follow。`fallbackRows` 是上线初期的恢复口，不是永久隐藏错误的垃圾桶；每次 fallback 要有 reason metric。

## 没有显式历史上限，是另一个边界问题

生成 API 已支持 `getTaskHistory(id:limit:)`，但 `TaskHistoryService` 当前调用没有传 `limit`。因此客户端没有声明首屏历史上限；服务端是否另有默认上限，本轮未知。

不能为了跑分快直接传一个小 `limit`：当前 API 只有 limit，没有本轮已验证的 cursor / offset 恢复合同，直接截断可能让用户丢上下文、widget 状态或 feedback anchor。正确顺序是先拿生产 `history_count` 分布，再单独设计“最近窗口 + 向前加载 + 滚动锚点 + 完整业务语义”的产品与 API 合同。

## 实验一：直接编译生产 projector，确认成本随历史增长

Harness 直接编译 current-main 的 `ChatRowItem.swift`，只用 text-only stubs 补齐模型；另用同形算法模拟 `applyRowsNow` 在 UIKit apply 前的全量扫描。优化编译，每个格子 5 个独立进程，单进程内部多次取中位数。

| 变化 | rows | production projection | renderer preparation | 合计 |
|---|---:|---:|---:|---:|
| append 1 | 100 | 0.058ms | 0.109ms | 0.166ms |
| append 1 | 1,000 | 0.577ms | 1.072ms | 1.668ms |
| append 1 | 5,000 | 2.943ms | 5.464ms | 8.490ms |
| feedback 1 | 100 | 0.059ms | 0.106ms | 0.166ms |
| feedback 1 | 1,000 | 0.592ms | 1.088ms | 1.681ms |
| feedback 1 | 5,000 | 3.044ms | 5.529ms | 8.519ms |
| equivalent snapshot | 5,000 | 2.914ms | 5.516ms | 8.410ms |

这里的 `equivalent` 是直接调用 projector 的机制对照；真实 publisher 已会用 `removeDuplicates` 拦住等价 `TaskData`，不能把这行冒充线上常见路径。

这个实验确认 O(n) 机制，也说明 5000 行时仅投影和 apply 前准备就已占用约半个 60Hz frame。它没有 UIKit snapshot、Markdown、cell 或滚动，所以不是产品总时长。

## 实验二：真实 App 离线页面追加一条消息

在不接真实账号和网络的 clean snapshot 中，只加 Debug 启动参数和计时日志；页面仍走生产 `TaskChatViewModel → ChatRowItem → ChatCollectionView`。iPhone 17 Pro Simulator、iOS 26.5、Debug，每个数据量 3 次独立启动。

| 初始 rows | append projection 三次 | 同步 renderer path 三次 | 配对合计中位数 | first content ready 中位数 |
|---:|---|---|---:|---:|
| 100 | 1.725 / 2.089 / 0.498ms | 49.449 / 23.093 / 8.201ms | 25.182ms | 292ms |
| 1,000 | 13.368 / 13.690 / 13.418ms | 25.159 / 24.559 / 21.403ms | 38.249ms | 192ms |
| 5,000 | 35.050 / 40.848 / 43.241ms | 41.519 / 41.023 / 43.254ms | **81.871ms** | 216ms |

三点解释：

1. `projection` 和 renderer 方法都在 main actor / 主线程调用链上；合计不是并行值。
2. Debug Simulator 的绝对数字不能当 iPhone Release SLO；100 行首跑也显示明显冷启动噪声，所以报告保留三次原值，不伪装成 p95。
3. 5000 行截图证明页面确实显示到末尾并追加成功；截图证明状态，不证明无 hitch。

一次 Time Profiler CLI 尝试没有按 6 秒限制结束，部分 trace 也无法导出，已移到废纸篓。本页因此明确把 Instruments 标为未取得。

## 推荐方案：先做窄增量，再由真机决定是否扩大

### L0｜先补尺子，不改变行为

- 给 full build、change classify、projection、renderer commit、first content ready 加统一 signpost。
- 记录 `history_count`、`changed_message_count`、change kind、fallback reason；不记录消息正文或实体 ID。
- 固定 100 / 1,000 / 5,000 行的 text、Markdown、widget、status 混合 fixture。

这是上线前必需的地板，不是最终优化。

### L1｜推荐先实现

1. 把现有 full `buildRows` 封装为纯 `ChatTimelineProjector.fullBuild`，行为一字不改，作为 oracle。
2. 加 `ChatTimelineChangeClassifier` 和状态化 projector cache。
3. 第一批只优化 `expansion`、`feedback`、`processing` 三类窄变化。
4. renderer 接 patch；保留 full fallback、现有 coalescing / freeze / scroll protections。
5. 再做 append-safe-boundary；随机操作序列每一步与 full oracle 等价后才打开。

为什么好：最先消掉的是最容易证明“只影响一行”的工作；复杂 widget / selection 规则仍由现有 full build 兜底，改动可回滚、可归因。

### L2｜只有真机仍超预算才做

- 上游 Task authority 发布 typed message delta，而不是让 ViewModel 比较两个大快照。
- 设计可分页的 chat window / cursor 合同，canonical history 与 viewport projection 分离。
- 对 Markdown / widget 内容建立 `rowID + contentRevision` 的受限缓存与内存淘汰。

这层会碰状态 owner、API 与恢复语义，不应由一组 Simulator 数字直接触发。

## 完整验收：另一个 Agent 可以直接照着做

### Gate 0｜固定输入和基线

- [ ] 固定 source SHA、Xcode、configuration、device、OS、fixture hash；结果 JSON 不允许缺字段。
- [ ] 100 / 1,000 / 5,000 行各有 text-only 和 mixed-content 两套 fixture。
- [ ] current full build 输出保存为 oracle；重复 ID、顺序、feedback、widget display state 和 status group 全部可比较。
- [ ] current main tree 中 Chat V1 / `ChatPageDebugFlags` consumer 继续为 0；不把本任务变成 renderer 重写。

### Gate 1｜正确性

- [ ] expansion 只更新目标 message row；feedback 只更新目标 turn 的最后 agent text row。
- [ ] processing 开始 / 结束、连续 status、同 cid selection、latest widget、用户 widget action、prepend、临时 ID→server ID 都与 full oracle 完全相同。
- [ ] 随机生成至少 1,000 条合法操作序列；每一步 `incremental == full`。
- [ ] append 无法证明安全边界时必须 full fallback，并记录明确 reason。
- [ ] 上滚阅读时追加不跳到底；原本贴底时继续跟随；prepend 后锚点不明显漂移。

### Gate 2｜真机 Release 性能

同一台最低支持设备和一台近期主流设备，各跑 5 个独立冷进程；报告中位数、p95 和原始样本。

- [ ] 5000 行单次 feedback / expansion：projection p95 ≤4ms，renderer 同步 commit p95 ≤8ms。
- [ ] 5000 行 append：app-owned 主线程区间 p95 ≤16.7ms；没有 >100ms app hang。
- [ ] 从 500 扩到 5000 行，feedback / expansion 单行更新的 p95 不超过 2 倍；不能继续接近 10 倍。
- [ ] 与 current baseline 同设备同 fixture 比，5000 行 append 的 app-owned 主线程时间至少下降 60%。
- [ ] first content ready：1000 / 5000 行 p95 ≤350ms；首屏只展示可见窗口时，不能以丢历史语义换成绩。
- [ ] 连续 20 个同 turn 更新在一个 16ms 合并窗内最多提交一次可见 snapshot；最终内容与 full oracle 一致。
- [ ] 反复进入 / 退出 5000 行会话 10 次，离开 3s 后内存回到首次稳定值 +30MB 内；若不回落再抓 memgraph。
- [ ] SwiftUI / Time Profiler / Hangs 三类证据至少拿到一种可符号化 CPU trace 和一份 hitch/hang 收据；普通日志计时不能替代。

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

每张图必须在画面内显示 `caseID · commit · device · OS · rows · action`；旁边保存同 case 的 timing JSON。

1. `01-open-100.png`：100 行首次打开，标题和末尾内容正确。
2. `02-open-5000.png`：5000 行首次打开，末尾两轮和输入栏都可见，无重叠 / 空白。
3. `03-append-bottom.png`：贴底追加一行，只出现一次且继续贴底。
4. `04-append-reading-old.png`：用户停在旧位置时追加，当前阅读行不跳走。
5. `05-feedback-old-turn.png`：给较早 turn 点反馈，只有目标反馈状态变化。
6. `06-expand-old-message.png`：展开较早长消息，内容和滚动锚点正确。
7. `07-prepend-history.png`：向前加载后，原可见首行保持在近似位置。
8. `08-processing-burst.png`：processing 连续变化后无重复 header、闪烁或孤儿 spinner。
9. `09-trace-linked.png`：同 case 的 Instruments 区间、p95 和 fallback count。
10. `chat-update-proof.mp4`：一镜到底展示打开 5000 行、上滚、追加、反馈、展开、prepend、回到底部；中间不能剪掉失败或等待。

截图证明状态；视频证明状态连续性；trace 证明时间花在哪里。三者共享 case ID，不能互相代替。

### Gate 4｜灰度和恢复

- [ ] 新 projector 可按内部 strategy flag 回到 current full projector；不恢复 Chat V1。
- [ ] 线上只上报低基数 change kind、history bucket、时长、fallback reason 和 hitch；不上传消息正文。
- [ ] correctness mismatch 在 Debug / dogfood 立即断言，在灰度自动 fallback 并计数。
- [ ] 任一 mixed-content fixture 不等价、真机 p95 超门或滚动锚点回归，都先关闭 incremental strategy。

## 给执行 Agent 的边界

**直接交付：** projector/change/patch 合同、oracle tests、100/1k/5k harness、signpost、真机证据包。

**不要做：** 不改后端消息协议；不顺手重写 Task store；不重新引入 Chat V1；不以 `limit` 静默截断历史；不在没有 trace 时扩大到 Markdown 缓存或分页架构。

**主要文件：**

- `TaskChatViewModel.swift`：变化分类、投影 owner 和 full fallback。
- `ChatRowItem.swift`：full oracle 与可复用的单 row / tail projection 规则。
- `ChatCollectionView.swift`：patch adapter；保留当前列表、滚动和后台保护。
- `TaskHistoryService.swift` / generated `TaskAPI.swift`：只记录“未显式 limit”的边界，本切片不改 API。
- 新纯逻辑 Package 或现有 `AilohaTaskCore`：放 projector/change/patch 和 property tests，具体位置由依赖审计决定，不能靠 `GlobalImports` 隐式泄漏依赖。

## Talent Signal 能验证什么，不能替什么

Talent Signal 可以复用两样东西：声明式状态 projector 的写法，以及 `caseID → 状态截图 → 连续视频 → timing JSON` 的验收格式。它很适合先证明“同一业务状态是否稳定投影到灵动岛不同尺寸”，也能训练 Agent 按证据交付。

它不能替 Ailoha 验证 ChatRowItem 的 selection、feedback、widget 门禁、prepend 和滚动锚点，更不能替代 Ailoha 真机 Release trace。这里可以迁移的是设计与验证方法，不是结论。

## 最终判断

### 已证实

- current main 的任意非等价 TaskData 推送都会 full rebuild chat rows。
- feedback、expansion 两个局部 UI 动作也会 full rebuild。
- full build 是 O(n)，列表提交前准备也是 O(n)；现有实现已有多项正确的下游保护。
- 当前客户端请求 history 时没有传 API 已支持的 `limit`。
- 独立逻辑实验和 Debug Simulator 页面实验都观察到成本随历史规模增加；5000 行单行追加页面实验的配对合计中位数约 81.9ms。

### 由证据推断

- 长会话中的局部更新有较高概率占用多个 frame budget，复杂 Markdown / widget 可能进一步放大；真实严重度仍由真机 Release 决定。
- 先做 feedback / expansion / processing 的窄增量，收益与正确性最容易闭合。

### 尚未知

- 生产 history_count 分布和用户是否已遭遇可归因 hitch / hang。
- 最低支持 iPhone 的 Release p95、CPU 栈和内存曲线。
- mixed-content 下 projection、Markdown、cell layout 各占多少。
- 服务端 history endpoint 是否有未体现在客户端合同里的默认上限。

因此本项是 **P0 工程切片，事故 P0 仍为 0**。下一步不是“大改 Chat”，而是实现 L0 + L1 的最窄闭环，用 full oracle 和真机门决定是否继续。
