目标:
✅ 本地优先(秒开)
✅ streaming(边生成边显示)
✅ diff 友好(SwiftUI 列表动画稳定)
✅ Presenter 不依赖 SwiftData(不污染 Domain)
✅ SwiftData 仍可“实时”驱动 UI(通过流/通知桥接)
1️⃣ AIChats Repository 设计(本地优先 + streaming + diff)
1.1 Repository 的职责边界
Repository 只做三件事:
选择数据来源(Local / Remote / Mixed)
做映射(DTO ↔ Domain,Entity ↔ Domain)
输出 Domain(struct)给上层(Interactor/Presenter)
关键:Repository 的输出永远是 Domain,不要直接输出 @Model。
1.2 建议的策略枚举(本地优先 / 网络优先 / 混合)
enum FetchPolicy {
case cacheFirst(ttl: TimeInterval)
case networkFirst
case cacheThenRefresh(ttl: TimeInterval)
}推荐(AIChats 场景)
列表/历史消息:cacheThenRefresh(秒开 + 后台刷新)
需要强一致:networkFirst
离线体验:cacheFirst
1.3 Repository 输出“事件流”而不是一次性数组(关键)
你要的是:
先发本地快照(历史记录)
再发网络增量(新消息/修正)
streaming 时不断增量发
所以 Repository 最好提供:
✅ AsyncStream<ChatUpdate> 或 AsyncThrowingStream<ChatUpdate>
enum ChatUpdate: Sendable {
case snapshot([ChatMessage]) // 一次性完整快照
case append([ChatMessage]) // 增量追加(最常见)
case upsert(ChatMessage) // 修正/替换某条
case delete(ids: [String]) // 删除
case status(ChatStatus) // loading/refreshing/error 等
}1.4 diff 友好:为什么“append/upsert”优于“全量替换”
SwiftUI 的 List/ForEach diff 是基于 id 的:
你每次全量 replace:动画、滚动定位、性能都更差
你用 append/upsert:动画稳定、滚动更稳、逻辑更可控
AIChats 的聊天列表尤其适合 append(新增)与 upsert(流式补全最后一条)。
2️⃣ AsyncStream 在数据层的最佳用法
2.1 最佳实践:stream 用 “Continuation” 控制
struct ChatMessage: Identifiable, Sendable, Hashable {
let id: String
let content: String
let createdAt: Date
}Repository 提供:
protocol ChatRepository {
func observeMessages(chatId: String, policy: FetchPolicy) -> AsyncThrowingStream<ChatUpdate, Error>
func sendMessage(chatId: String, content: String) -> AsyncThrowingStream<ChatUpdate, Error> // streaming reply
}2.2 “本地优先 + 后台刷新” 的流式实现骨架
func observeMessages(chatId: String, policy: FetchPolicy) -> AsyncThrowingStream<ChatUpdate, Error> {
AsyncThrowingStream { continuation in
let task = Task {
// 1) 先吐本地快照(秒开)
let local = try await localStore.fetchMessages(chatId: chatId)
continuation.yield(.snapshot(local))
// 2) 若需要刷新,再拉网络
if case .cacheThenRefresh(let ttl) = policy {
let isExpired = try await localStore.isExpired(chatId: chatId, ttl: ttl)
if isExpired {
continuation.yield(.status(.refreshing))
let remote = try await remoteAPI.fetchMessages(chatId: chatId)
// 写入本地(Entity)
try await localStore.upsert(remote)
// 再吐一次快照(或吐 diff)
continuation.yield(.snapshot(remote))
continuation.yield(.status(.idle))
}
}
}
continuation.onTermination = { _ in
task.cancel()
}
}
}上面逻辑中,你也可以把第二次 .snapshot(remote) 换成 append/upsert/delete 事件,性能更好。
2.3 streaming 的关键:最后一条消息用 upsert 不断补全
AI 回复一般是 token-stream:
UI 上你希望 “最后一条气泡不断变长”
那么更新策略是:
✅ append(placeholder) → 不断 upsert(lastMessage) → 最后 status(.done)
3️⃣ Presenter 不持有 SwiftData,也能实时更新 UI(关键思想)
你想要:
Presenter 只看 Domain struct
SwiftData 变化仍然能推动 UI
答案是:
Presenter 监听 Repository 的 AsyncStream
Repository 内部监听 SwiftData 的变化并转换成 Domain 更新事件
Presenter 不需要 @Query、不需要 ModelContext。
3.1 Presenter 侧写法(示例)
@MainActor
@Observable
final class ChatPresenter {
private(set) var messages: [ChatMessage] = []
private var observeTask: Task<Void, Never>?
func bind(chatId: String, repo: ChatRepository) {
observeTask?.cancel()
observeTask = Task {
do {
for try await update in repo.observeMessages(chatId: chatId, policy: .cacheThenRefresh(ttl: 120)) {
apply(update)
}
} catch {
// 这里统一走你 AnyAppAlert
}
}
}
private func apply(_ update: ChatUpdate) {
switch update {
case .snapshot(let all):
messages = all
case .append(let new):
messages.append(contentsOf: new)
case .upsert(let msg):
if let index = messages.firstIndex(where: { $0.id == msg.id }) {
messages[index] = msg
} else {
messages.append(msg)
}
case .delete(let ids):
messages.removeAll { ids.contains($0.id) }
case .status:
break
}
}
}Presenter 永远只处理 ChatUpdate(Domain 事件),完全不碰 SwiftData。
4️⃣ SwiftData + AsyncStream 的组合方案(高级)
4.1 SwiftData “实时”变化如何变成 AsyncStream?
SwiftData 本身并没有直接给你一个官方 AsyncSequence(至少体验上不如 Combine/NSFetchedResultsController)。
但你可以用两种稳定方案:
✅ 方案 A:Repository 内部“轮询+diff”(简单稳定)
适合消息量不大、业务轻(AIChats 初期完全够用)。
每次保存后 fetch 一次最新 domain
计算 diff(append/upsert/delete)
yield 出去
优点:实现快、稳定、无框架依赖
缺点:不是严格实时(但对聊天够用)
✅ 方案 B:SwiftData 通知桥接(更实时)
监听 ModelContext 保存事件(或你自己的 LocalStore 保存入口),然后触发 fetch + diff。
做法:
你所有写入 SwiftData 都走 LocalStore
LocalStore 在写入后 yield 一个 “变更信号”
Repository 收到信号后 fetch 最新消息并 diff 输出
这本质是事件驱动,而不是每次全量刷新 UI。
4.2 推荐:LocalStore 自带 AsyncStream 的变更信号
actor ChatLocalStore {
private var continuation: AsyncStream<Void>.Continuation?
func changes() -> AsyncStream<Void> {
AsyncStream { continuation in
self.continuation = continuation
}
}
func notifyChanged() {
continuation?.yield(())
}
func upsert(_ messages: [ChatMessage]) async throws {
// 写 SwiftData...
notifyChanged()
}
}Repository:
func observeMessages(...) -> AsyncThrowingStream<ChatUpdate, Error> {
AsyncThrowingStream { continuation in
let task = Task {
// 先出快照
continuation.yield(.snapshot(try await local.fetchMessages(...)))
// 监听本地变化
for await _ in await local.changes() {
let latest = try await local.fetchMessages(...)
let diff = diffEngine.makeUpdate(from: latest) // 生成 append/upsert/delete
continuation.yield(diff)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}这套是“产品级”方案:写入触发、读取更新、Presenter 无感知。
5️⃣ DiffEngine:把全量数组转成 append/upsert/delete(建议)
你不需要一开始就做很复杂的 diff。
聊天最常见的两种变化:
新消息追加(append)
最后一条消息 content 被补全(upsert)
所以初版 diffEngine 可以非常简单:
如果新数组比旧数组长:append(尾部新增)
如果长度相同但最后一条 content 变化:upsert(last)
其他情况:snapshot(全量替换) 兜底
6️⃣ 总结:这套体系为什么“可产品化”
你最终得到:
✅ 秒开(cache snapshot)
✅ 后台刷新(policy + ttl)
✅ streaming(append + upsert)
✅ UI 稳定(Domain struct + diff events)
✅ SwiftData 不污染(Entity 只在 LocalStore)
✅ Presenter 纯业务编排(不碰持久化)
7️⃣ 推荐你在 AIChats 的落地顺序(最省心)
先实现 ChatMessage Domain struct
LocalStore:SwiftData 写入 + fetch
Repository:observeMessages 输出 .snapshot + .append
Presenter:apply(ChatUpdate)
最后补 diffEngine(upsert last message)
再加 TTL / cacheThenRefresh
如果你愿意,我下一步可以直接给你把 ChatRepository / ChatLocalStore / DiffEngine / ChatPresenter 写成一套可直接用的文件结构(包含路径、文件名、示例调用),并且和你现在的 AnyAppAlert、anyButton 风格完全兼容。