Administrator
发布于 2026-02-26 / 7 阅读
0
0

AIChats 进阶:Repository + AsyncStream + SwiftData 实时链路(可产品化级)

目标:

✅ 本地优先(秒开)

✅ streaming(边生成边显示)

✅ diff 友好(SwiftUI 列表动画稳定)

✅ Presenter 不依赖 SwiftData(不污染 Domain)

✅ SwiftData 仍可“实时”驱动 UI(通过流/通知桥接)


1️⃣ AIChats Repository 设计(本地优先 + streaming + diff)

1.1 Repository 的职责边界

Repository 只做三件事:

  1. 选择数据来源(Local / Remote / Mixed)

  2. 做映射(DTO ↔ Domain,Entity ↔ Domain)

  3. 输出 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。

做法:

  1. 你所有写入 SwiftData 都走 LocalStore

  2. LocalStore 在写入后 yield 一个 “变更信号”

  3. 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。

聊天最常见的两种变化:

  1. 新消息追加(append)

  2. 最后一条消息 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 的落地顺序(最省心)

  1. 先实现 ChatMessage Domain struct

  2. LocalStore:SwiftData 写入 + fetch

  3. Repository:observeMessages 输出 .snapshot + .append

  4. Presenter:apply(ChatUpdate)

  5. 最后补 diffEngine(upsert last message)

  6. 再加 TTL / cacheThenRefresh


如果你愿意,我下一步可以直接给你把 ChatRepository / ChatLocalStore / DiffEngine / ChatPresenter 写成一套可直接用的文件结构(包含路径、文件名、示例调用),并且和你现在的 AnyAppAlert、anyButton 风格完全兼容。


评论