任何试图在真实网络环境下构建一个功能丰富的 SwiftUI 应用的工程师,都会迅速撞上一堵墙:UI 响应性与数据一致性的矛盾。传统的 MVVM 模式,在视图模型中直接调用网络请求 (await api.updateTask(task)),会立刻让 UI 陷入等待。即使用户界面没有被完全阻塞,一个旋转的加载指示器也足以破坏流畅的体验。更糟糕的是,一旦设备离线,整个应用就瘫痪了。
这种紧耦合的设计在生产环境中是脆弱的。我们需要一种架构,让 UI 操作的响应与网络通信的延迟和不确定性彻底解耦。这里的核心思路不是优化网络请求,而是从根本上改变应用与数据交互的方式:用户的意图(写操作)应该被立即接受并反馈,而数据的同步(读/写同步)则是一个独立的、可容错的后台过程。
这正是命令查询职责分离(CQRS)模式的用武之地。我们将用户操作建模为不可变的“命令”(Commands),这些命令被发送到一个本地队列中。UI 则从一个独立的、为快速读取而优化的本地“读模型”(Read Model)中获取数据。这种分离为构建一个真正的离线优先、具备弹性且高度响应的 SwiftUI 应用奠定了基础。
架构决策:为何选择 CQRS 与事件溯源 API
在确定采用 CQRS 模式后,我们面临两个关键的技术选型:如何实现命令的处理与持久化,以及如何同步读模型。
方案 A:传统的 RESTful API + 本地状态管理
- 写路径(Command): 客户端将命令(如
UpdateTaskTitleCommand)发送到PUT /tasks/{id}端点。成功后,客户端“猜测”服务器状态已更新,并手动更新本地的读模型。 - 读路径(Query): 客户端定期轮询
GET /tasks或通过 WebSocket 接收“状态已变更”的通知,然后重新获取整个数据集或特定对象来更新本地读模型。 - 劣势:
- 竞态条件: 如果多个客户端同时更新,客户端的“猜测”很容易出错,导致本地状态与服务器不一致。
- 同步复杂性: “状态已变更”的通知粒度太粗,客户端为了同步一个字段的变更可能需要拉取整个对象,浪费带宽。
- 离线处理困难: 离线时提交的命令在重新上线后需要复杂的逻辑来处理冲突,因为服务器的状态可能已经被其他用户改变。
方案 B:CQRS + 事件溯源(Event Sourcing)API
- 写路径(Command): 客户端将命令(
UpdateTaskTitleCommand)发送到统一的POST /commands端点。服务器验证命令后,将其转化为一个或多个“事件”(Events),如TaskTitleUpdatedEvent,并将这些事件持久化到事件日志中。API 立即返回成功,表示命令已被接受。 - 读路径(Query): 客户端不直接查询当前状态。相反,它订阅一个事件流(通过 WebSocket 或 Server-Sent Events)。当服务器持久化新事件时,会通过这个流广播给所有客户端。客户端接收到
TaskTitleUpdatedEvent后,应用这个事件来更新自己的本地读模型。 - 优势:
- 单一事实来源: 事件日志成为系统中所有状态变更的唯一、不可变的事实来源。这极大地简化了调试和审计。
- 确定性状态重建: 任何客户端都可以通过重放事件流来重建其本地读模型的任何历史状态。
- 天然的同步机制: 事件本身就是状态变更的最小单元,客户端只需要应用接收到的事件即可,同步过程精确且高效。
- 简化的离线逻辑: 离线时,命令被缓存在本地。上线后,按顺序提交。即使服务器状态已变,只要命令还能被服务器的业务规则接受,它就会生成新的事件,最终所有客户端都会通过消费这些事件达到一致状态。
最终我们选择了方案 B。它虽然在后端引入了事件溯源的复杂性,但为客户端架构带来了无与伦g’b’i的简洁性和鲁棒性。API 的设计不再是关于资源(Resource)的 CRUD,而是关于意图(Command)和事实(Event)的流动。
核心实现:命令与查询的分离
让我们通过一个协作任务管理应用的例子来逐步实现这个架构。
1. 定义命令(Commands)
命令是用户意图的体现,它们是简单的、只包含必要数据的数据结构。一个好的实践是使用协议来定义命令的共性。
// MARK: - Core Command Protocol
import Foundation
// 所有命令都必须遵循此协议
// 'id' 用于追踪和去重,'timestamp' 用于排序和审计
protocol Command: Codable, Identifiable where ID == UUID {
var id: UUID { get }
var timestamp: Date { get }
// 用于日志和调试的元数据
var metadata: CommandMetadata { get }
}
struct CommandMetadata: Codable {
let userId: String
// 可以包含设备ID、会话ID等
}
// MARK: - Concrete Task Commands
struct CreateTaskCommand: Command {
let id: UUID
let timestamp: Date
let metadata: CommandMetadata
// Command-specific payload
let projectId: UUID
let initialTitle: String
init(projectId: UUID, title: String, userId: String) {
self.id = UUID()
self.timestamp = Date()
self.metadata = CommandMetadata(userId: userId)
self.projectId = projectId
self.initialTitle = title
}
}
struct UpdateTaskTitleCommand: Command {
let id: UUID
let timestamp: Date
let metadata: CommandMetadata
let taskId: UUID
let newTitle: String
init(taskId: UUID, newTitle: String, userId: String) {
self.id = UUID()
self.timestamp = Date()
self.metadata = CommandMetadata(userId: userId)
self.taskId = taskId
self.newTitle = newTitle
}
}
// 还可以有 CompleteTaskCommand, AssignTaskCommand 等
这里的关键是,命令本身不包含任何业务逻辑。它们是纯粹的数据载体,描述了“什么被请求了”。
2. 命令调度器与持久化队列
当用户在 SwiftUI 视图中点击按钮时,我们不直接调用网络服务,而是将一个命令分发给 CommandDispatcher。调度器的职责是:
- 对命令进行初步的客户端验证。
- 将命令持久化到一个本地队列,以防应用崩溃或被终止。
- 触发一个后台任务来同步队列中的命令。
// MARK: - Command Envelope for Persistence
// 为了存储不同类型的命令,我们需要一个包装器
// 'commandType' 字段用于在解码时确定具体的命令类型
struct CommandEnvelope: Codable, Identifiable {
let id: UUID
let commandType: String
let commandPayload: Data // 序列化后的具体 Command
let timestamp: Date
let status: Status
enum Status: String, Codable {
case pending
case inFlight
case failed
}
}
// MARK: - Command Dispatcher
@MainActor
class CommandDispatcher {
private let commandQueue: CommandQueue
private let commandSyncService: CommandSyncService
init(queue: CommandQueue, syncService: CommandSyncService) {
self.commandQueue = queue
self.commandSyncService = syncService
}
func dispatch<C: Command>(_ command: C) async {
do {
// 1. 序列化并存储命令
let commandType = String(describing: C.self)
let payload = try JSONEncoder().encode(command)
let envelope = CommandEnvelope(
id: command.id,
commandType: commandType,
commandPayload: payload,
timestamp: command.timestamp,
status: .pending
)
try await commandQueue.enqueue(envelope)
// 2. 触发后台同步
// 在真实项目中,这里应该使用更健壮的后台任务调度机制
Task.detached(priority: .background) {
await self.commandSyncService.synchronizeCommands()
}
} catch {
// 这里的错误是严重的本地存储错误,需要记录日志
print("FATAL: Failed to dispatch command \(command.id): \(error)")
}
}
}
// MARK: - Command Queue (Persistence Layer)
// 一个基于文件系统的简单队列实现
// 在生产环境中,推荐使用 CoreData, SwiftData 或 Realm
class FileSystemCommandQueue: CommandQueue {
private let queueDirectory: URL
private let lock = NSLock()
init() {
// ... 初始化文件目录 ...
}
func enqueue(_ envelope: CommandEnvelope) async throws {
lock.lock()
defer { lock.unlock() }
let fileURL = queueDirectory.appendingPathComponent("\(envelope.id).json")
let data = try JSONEncoder().encode(envelope)
try data.write(to: fileURL)
}
func nextPending() async -> CommandEnvelope? {
// ... 扫描目录,找到最旧的 pending 命令 ...
return nil // 简化实现
}
func updateStatus(id: UUID, newStatus: CommandEnvelope.Status) async {
// ... 更新命令文件的状态 ...
}
func remove(id: UUID) async {
// ... 删除已成功同步的命令文件 ...
}
}
3. API 设计:命令端点与事件流
后端的 API 设计必须与这种模式相匹配。
命令提交端点: POST /api/v1/commands
请求体是一个包含具体命令的信封:
{
"commandType": "CreateTaskCommand",
"commandPayload": {
"id": "A7E2-...",
"timestamp": "2023-10-27T10:30:00Z",
"metadata": { "userId": "user-123" },
"projectId": "PROJ-456",
"initialTitle": "Implement CQRS architecture"
}
}
服务器的响应非常简单。它只表示命令已被接受处理,而不是已经完成。
- 成功 (202 Accepted):
{ "commandId": "A7E2-...", "status": "accepted" } - 失败 (400 Bad Request or 422 Unprocessable Entity):
如果命令格式错误或违反了业务规则(例如,尝试在不存在的项目中创建任务)。
事件流端点: GET /api/v1/events (使用 WebSocket 或 SSE)
客户端连接到此端点后,服务器会流式地推送事件。每个事件都代表一个已发生且不可更改的事实。
{
"eventId": "EVT-XYZ-1",
"eventType": "TaskCreated",
"timestamp": "2023-10-27T10:30:05Z",
"payload": {
"taskId": "TASK-789",
"projectId": "PROJ-456",
"title": "Implement CQRS architecture",
"createdBy": "user-123"
}
}
---
{
"eventId": "EVT-XYZ-2",
"eventType": "TaskTitleUpdated",
"timestamp": "2023-10-27T10:32:10Z",
"payload": {
"taskId": "TASK-789",
"newTitle": "Implement CQRS client architecture"
}
}
4. 命令同步服务
CommandSyncService 负责从本地队列中取出命令,发送到服务器,并处理结果。
class CommandSyncService {
private let commandQueue: CommandQueue
private let apiClient: APIClient
// 使用 actor 确保同一时间只有一个同步任务在运行
private actor SyncActor {
var isSyncing = false
func startSync() -> Bool {
if isSyncing { return false }
isSyncing = true
return true
}
func endSync() {
isSyncing = false
}
}
private let syncActor = SyncActor()
// ... init ...
func synchronizeCommands() async {
guard await syncActor.startSync() else {
// 另一个同步任务已经在运行,直接返回
return
}
defer {
Task { await syncActor.endSync() }
}
while let envelope = await commandQueue.nextPending() {
do {
await commandQueue.updateStatus(id: envelope.id, newStatus: .inFlight)
// 这里的 apiClient 负责将 envelope 发送到 /commands 端点
try await apiClient.submitCommand(envelope)
// 成功后从队列中删除
await commandQueue.remove(id: envelope.id)
} catch let error as APIError {
// 处理可恢复和不可恢复的错误
if error.isRecoverable {
await commandQueue.updateStatus(id: envelope.id, newStatus: .failed)
// 可以实现重试策略,例如指数退避
} else {
// 不可恢复的错误 (如 400),命令无效,应将其移至死信队列并通知用户
print("Unrecoverable error for command \(envelope.id). Moving to dead-letter queue.")
await commandQueue.remove(id: envelope.id) // Or move
}
break // 暂停本次同步,等待下次触发
} catch {
// 网络错误等,标记为失败等待重试
await commandQueue.updateStatus(id: envelope.id, newStatus: .failed)
break
}
}
}
}
5. 查询端:构建本地读模型
现在是 CQRS 的另一半:查询。我们使用 SwiftData 来构建一个为 UI 优化的本地只读数据库。EventProcessor 负责监听来自服务器的事件流,并将这些事件应用到 SwiftData 模型上。
// MARK: - SwiftData Read Model
import SwiftData
@Model
final class TaskItem {
@Attribute(.unique) var id: UUID
var title: String
var isCompleted: Bool
var projectId: UUID
init(id: UUID, title: String, isCompleted: Bool, projectId: UUID) {
self.id = id
self.title = title
self.isCompleted = isCompleted
self.projectId = projectId
}
}
// MARK: - Event Processor
// 负责将事件应用到本地读模型
class EventProcessor {
private let modelContext: ModelContext // SwiftData context
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
// 这个方法会被 EventStreamListener 调用
func process(eventData: Data) {
// 1. 解码事件信封,确定事件类型
guard let genericEvent = try? JSONDecoder().decode(GenericEvent.self, from: eventData) else {
return
}
// 2. 根据事件类型,解码具体的 payload 并应用变更
modelContext.perform { // 确保在正确的 actor 上下文中修改 SwiftData
do {
switch genericEvent.eventType {
case "TaskCreated":
let event = try JSONDecoder().decode(TaskCreatedEvent.self, from: eventData)
let newTask = TaskItem(
id: event.payload.taskId,
title: event.payload.title,
isCompleted: false,
projectId: event.payload.projectId
)
self.modelContext.insert(newTask)
case "TaskTitleUpdated":
let event = try JSONDecoder().decode(TaskTitleUpdatedEvent.self, from: eventData)
let taskId = event.payload.taskId
let fetchDescriptor = FetchDescriptor<TaskItem>(predicate: #Predicate { $0.id == taskId })
if let taskToUpdate = try self.modelContext.fetch(fetchDescriptor).first {
taskToUpdate.title = event.payload.newTitle
}
// ... 处理其他事件类型 ...
default:
print("Unhandled event type: \(genericEvent.eventType)")
}
// 3. 保存变更
try self.modelContext.save()
} catch {
// 错误处理,可能需要回滚或记录
print("Failed to process event: \(error)")
}
}
}
}
6. 整合 SwiftUI
现在,SwiftUI 视图的实现变得异常简单和干净。视图完全不知道网络的存在。它只是从本地的 SwiftData 数据库中读取数据,并将用户的操作意图转换为命令分发出去。
import SwiftUI
import SwiftData
struct TaskListView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject private var commandDispatcher: CommandDispatcher // 通过环境注入
@Query(sort: \TaskItem.title) private var tasks: [TaskItem]
@State private var newTaskTitle: String = ""
private let currentProjectId: UUID = UUID() // 示例 Project ID
private let currentUserId: String = "user-123" // 示例 User ID
var body: some View {
NavigationStack {
List {
ForEach(tasks) { task in
Text(task.title)
.strikethrough(task.isCompleted, color: .gray)
}
}
.navigationTitle("Tasks")
.safeAreaInset(edge: .bottom) {
HStack {
TextField("New Task Title", text: $newTaskTitle)
.textFieldStyle(.roundedBorder)
Button("Add") {
addTask()
}
.disabled(newTaskTitle.isEmpty)
}
.padding()
.background(.bar)
}
}
}
private func addTask() {
let title = newTaskTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { return }
// 分发一个创建任务的命令
// UI 无需等待任何网络响应
let command = CreateTaskCommand(
projectId: currentProjectId,
title: title,
userId: currentUserId
)
Task {
await commandDispatcher.dispatch(command)
}
// 立即清空输入框,提供即时反馈
newTaskTitle = ""
}
}
整个流程的可视化如下:
sequenceDiagram
participant SwiftUI_View as SwiftUI View
participant CommandDispatcher as Command Dispatcher
participant CommandQueue as Local Command Queue
participant CommandSyncService as Command Sync Service
participant BackendAPI as Backend API
participant EventStream as Event Stream
participant EventProcessor as Event Processor
participant SwiftData as Local Read Model
Note over SwiftUI_View: User adds a new task
SwiftUI_View->>CommandDispatcher: dispatch(CreateTaskCommand)
CommandDispatcher->>CommandQueue: enqueue(command)
CommandDispatcher->>CommandSyncService: trigger synchronization
activate CommandSyncService
CommandSyncService->>CommandQueue: nextPending()
CommandSyncService->>BackendAPI: POST /commands (CreateTaskCommand)
BackendAPI-->>CommandSyncService: 202 Accepted
CommandSyncService->>CommandQueue: remove(command)
deactivate CommandSyncService
Note over BackendAPI: Backend processes command & creates event
BackendAPI-->>EventStream: broadcasts TaskCreatedEvent
activate EventProcessor
EventStream-->>EventProcessor: receives TaskCreatedEvent
EventProcessor->>SwiftData: insert(New TaskItem)
deactivate EventProcessor
SwiftData-->>SwiftUI_View: @Query automatically updates UI
局限性与未来展望
这套架构解决了 UI 响应性和离线操作的核心痛点,但在真实项目中,它也引入了新的挑战。
首先,乐观更新(Optimistic Updates)。在当前实现中,用户添加新任务后,UI 不会立即显示它。UI 的更新要等待命令被服务器处理、事件被广播回来、再被客户端处理。这个延迟可能只有几百毫秒,但对于追求极致体验的应用来说仍然不够。一个改进是在分发命令的同时,立即在本地读模型中创建一个“待定”状态的 TaskItem。当对应的事件回来时,再将这个 TaskItem 的状态更新为“已确认”。这个过程需要仔细处理命令 ID 和事件之间的关联,以避免重复创建。
其次,冲突解决。我们的事件溯源模型本质上是“后来者优先”。如果两个用户同时更新同一个任务的标题,最后被服务器处理的那个命令将覆盖前者。对于某些应用场景这是可以接受的,但对于协作编辑等场景则不行。更复杂的解决方案,如操作转换(Operational Transformation, OT)或无冲突复制数据类型(CRDTs),需要被引入到命令和事件的设计中,这是一个非常复杂的领域。
最后,读模型的演进。随着业务的发展,读模型(TaskItem)的结构可能会改变。由于我们的事实来源是事件日志,我们可以通过重放所有历史事件来构建一个全新结构的读模型,而无需进行复杂的数据迁移。这为架构的长期可维护性提供了极大的灵活性,但也要求事件本身的设计具有良好的向前和向后兼容性。例如,为事件 payload 增加版本号是一种常见的实践。