把整个 ViewModel 标记为 @MainActor 是合理起点,因为它发布给界面的状态应在同一个隔离域更新。问题出现在 ViewModel 同时承担缓存、网络去重、文件写入和解析时:所有职责都被拖到主执行器,测试也必须围绕一个过重对象展开。

更清楚的边界是:MainActor 负责 UI 状态;actor 负责有独立生命周期的共享可变资源;无状态转换使用可发送值和普通函数。

Actor 保护资源,而不是包装每个类

下面的仓库拥有缓存,因此使用 actor。网络客户端通过小协议注入,测试可以替换。

struct Article: Sendable, Identifiable {
    let id: Int
    let title: String
}

protocol ArticleClient: Sendable {
    func fetchArticles() async throws -> [Article]
}

actor ArticleRepository {
    private let client: any ArticleClient
    private var cache: [Article]?

    init(client: any ArticleClient) {
        self.client = client
    }

    func articles(forceRefresh: Bool = false) async throws -> [Article] {
        if !forceRefresh, let cache {
            return cache
        }

        let values = try await client.fetchArticles()
        cache = values
        return values
    }
}

actor 的理由是 cache 可能被多个任务访问,而不是“仓库类看起来适合 actor”。如果仓库完全无状态,一个 Sendable 客户端或普通函数就足够。

ViewModel 只翻译成页面状态

@MainActor
final class ArticlesViewModel: ObservableObject {
    enum State {
        case idle, loading, loaded([Article]), failed(String)
    }

    @Published private(set) var state: State = .idle
    private let repository: ArticleRepository
    private var requestID = UUID()

    init(repository: ArticleRepository) {
        self.repository = repository
    }

    func load(forceRefresh: Bool = false) async {
        let current = UUID()
        requestID = current
        state = .loading

        do {
            let articles = try await repository.articles(forceRefresh: forceRefresh)
            guard requestID == current else { return }
            state = .loaded(articles)
        } catch is CancellationError {
            guard requestID == current else { return }
            state = .idle
        } catch {
            guard requestID == current else { return }
            state = .failed("Unable to load articles")
        }
    }
}

请求身份解决一个常见竞态:用户触发刷新后,较早请求晚到,不应覆盖新请求的结果。它不能替代取消,但能作为结果提交前的最后一道验证。视图应从 .task.refreshable 调用 load,让结构化任务生命周期负责大部分取消传播。

避免无意创建非结构化任务

ViewModel 方法内部如果直接 Task { ... },调用者无法等待完成,也难以知道谁负责取消。优先让方法本身是 async,由视图任务调用。只有对象确实拥有长生命周期工作时,才保存 Task 句柄,并在新请求或销毁时取消。

同样,不要用 Task.detached 逃开 actor 隔离。它会丢失任务优先级、局部值和结构化关系,只有明确需要完全独立执行上下文时才适用。CPU 密集工作应进入专门服务,并用 Instruments 验证主执行器阻塞,而不是凭直觉到处 detached。

Actor 还存在重入:方法在 await 暂停时,其他任务可以进入并改变 actor 状态。因此不能假设 await 前检查过的条件在恢复后仍然成立。仓库如果实现请求去重,可以把进行中的 Task 存为状态;恢复执行后再次核对它是否仍是当前任务,再提交缓存并清理句柄。把整个长请求放进一个看似“原子”的 actor 方法,并不会让网络等待期间禁止其他调用。

错误也应在边界翻译。仓库保留可用于重试和诊断的领域错误,ViewModel 再把它转换成页面可展示的状态。不要让 UI 根据 URLError 数字决定按钮文案,也不要在仓库里直接构造本地化提示。两层职责分开后,同一个仓库才能被命令行工具、Widget 或其他页面复用。

测试可以分别覆盖仓库缓存和 ViewModel 状态转换。给客户端一个可控制的延迟,先完成第二个请求,再完成第一个,断言旧结果未覆盖新状态。这样的测试验证产品真正关心的顺序规则。

Actor 边界的目标不是让所有类型都“并发化”,而是让共享可变状态拥有明确负责人。ViewModel 保持轻薄之后,UI 隔离、资源序列化和异步工作各自都有可解释的生命周期。