首页
/ 用 Swift Actor 构建线程安全持久化层:ECC 中 swift-actor-persistence 技能的核心模式与实战拆解

用 Swift Actor 构建线程安全持久化层:ECC 中 swift-actor-persistence 技能的核心模式与实战拆解

2026-09-06 18:30:17作者:沈韬淼Beryl

本篇文章以 .kiro/skills/swift-actor-persistence/SKILL.md(仓库 skills/ 目录下另有一份等价副本 skills/swift-actor-persistence/SKILL.md,且已被翻译为多语言,如 docs/zh-CN/skills/swift-actor-persistence/SKILL.md)为骨架展开。它描述的是 ECC(The agent harness performance optimization system,面向 Claude Code、Codex、Opencode、Cursor 等 harness 的开源工程化体系)中沉淀下来的 Swift 并发编程规范:用 actor 取代手动同步原语,构建"内存缓存 + 文件落盘"双层的线程安全数据持久化层

读完本文,你将掌握:如何编写一个编译期即可保证无数据竞争(data-race)的泛型 actor 仓储 LocalRepository<T>;如何在 iOS / macOS 的离线优先(offline-first)应用中将它与 @Observable ViewModel 组合出响应式读写链路;以及该模式在 ECC 仓库中与 Swift 规则、协议式依赖注入测试技能、SwiftUI 状态管理技能之间的协作关系,和值得规避的反模式清单。

何时启用该模式:适用场景判定

技能文档在 "When to Activate" 一节给出了四个判定条件,满足其一即适合引入 actor 持久化模式:

  • 正在 Swift 5.9+(对应 iOS 17+ / macOS 14+)环境中构建数据持久化层——actor 自 Swift 5.5 引入,而仓库示例中依赖的 @Observable 宏与 Observation 框架、URL.documentsDirectory 等 API 则要求更新的 SDK;
  • 需要对共享可变状态(shared mutable state)做线程安全访问;
  • 希望彻底告别手写同步代码(NSLockDispatchQueue、信号量等);
  • 正在构建以本地存储为基础的离线优先应用。

仓库中的 swift-apple 技能模块将本技能与 swift-concurrency-6-2swift-protocol-di-testingswiftui-patterns 并列打包,注册于 manifests/install-modules.jsonswift-applekind: skills)分组中;同时 rules/swift/patterns.mdskills/swiftui-patterns/SKILL.md 均在 References 小节显式链接 swift-actor-persistence,说明它是 ECC 中 Swift 生态"并发 + 存储 + 界面"三层技能栈的基础设施之一。

为什么是 actor:编译期消灭数据竞争的核心原理

要真正理解本技能,先要明白它反对什么、拥护什么。文档将其概括为一句话:The actor model guarantees serialized access — no data races, enforced by the compiler.(actor 模型保证串行化访问——没有数据竞争,且由编译器强制保证。)

这是 Swift 并发(Swift Concurrency)的核心设计取舍:

  • 隔离与串行化:actor 的存储属性和方法默认处于"actor 隔离"(actor-isolated)域内。无论多少个任务(task)同时向同一个 actor 实例发起调用,Swift 运行时会把这些调用串行排队执行,同一时刻只有一段代码在访问 actor 内部状态;
  • 编译期检查而非运行时锁:这与 NSLockDispatchQueue 的根本差异在于——漏加锁导致的竞争在运行时才暴露,而 actor 的隔离性违反会在编译阶段报错(例如 "actor-isolated property cannot be referenced from non-isolated context");
  • await 即边界声明:跨 actor 的调用必须书写 await,调用方被强制意识到"我在与一个隔离域对话,可能需要挂起等待"。

仓库中 agents/swift-build-resolver.md(Swift 构建错误解析专家 agent)维护了一张与并发相关的编译错误对照表,恰好可作为本模式的"负向知识"参考:如 expression is 'async' but is not marked with 'await' 提示缺失 awaitactor-isolated property cannot be referenced from non-isolated context 提示隔离域不匹配(应补 await、将调用方标记为 async,或对只读常量使用 nonisolated);non-sendable type passed in implicitly asynchronous call 则提示 Sendable 违例。这些错误信息正是 actor 隔离模型在编译期"工作"的直接证据。

同时,rules/swift/patterns.md 从规则层面为并发边界补充了约束——跨 actor 传递的类型需满足 Sendable

actor Cache<Key: Hashable & Sendable, Value: Sendable> {
    private var storage: [Key: Value] = [:]

    func get(_ key: Key) -> Value? { storage[key] }
    func set(_ key: Key, value: Value) { storage[key] = value }
}

LocalRepository<T> 正是这一抽象在"持久化"领域的特化:把 "actor 保护的字典"升级为 "actor 保护的字典 + 原子文件落盘"。

核心模式:Actor 化仓储 LocalRepository

技能文档给出了完整可复用的核心实现。LocalRepository 是一个对任意满足 Codable & Identifiable(且 ID == String)的模型泛化的 actor:

public actor LocalRepository<T: Codable & Identifiable> where T.ID == String {
    private var cache: [String: T] = [:]
    private let fileURL: URL

    public init(directory: URL = .documentsDirectory, filename: String = "data.json") {
        self.fileURL = directory.appendingPathComponent(filename)
        // Synchronous load during init (actor isolation not yet active)
        self.cache = Self.loadSynchronously(from: fileURL)
    }

    // MARK: - Public API

    public func save(_ item: T) throws {
        cache[item.id] = item
        try persistToFile()
    }

    public func delete(_ id: String) throws {
        cache[id] = nil
        try persistToFile()
    }

    public func find(by id: String) -> T? {
        cache[id]
    }

    public func loadAll() -> [T] {
        Array(cache.values)
    }

    // MARK: - Private

    private func persistToFile() throws {
        let data = try JSONEncoder().encode(Array(cache.values))
        try data.write(to: fileURL, options: .atomic)
    }

    private static func loadSynchronously(from url: URL) -> [String: T] {
        guard let data = try? Data(contentsOf: url),
              let items = try? JSONDecoder().decode([T].self, from: data) else {
            return [:]
        }
        return Dictionary(items.map { ($0.id, $0) }, uniquingKeysWith: { _, latest in latest })
    }
}

逐段拆解其中蕴含的设计:

  • public actor 声明:一旦声明为 actor,cache 字典即获得整个类型级别的串行保护,任何对 save / delete / find / loadAll 的并发访问都会被安全排队。这是全模式消除数据竞争的根基,后续无需再写任何一行加锁代码;
  • T: Codable & Identifiable where T.ID == StringCodable 保证模型可被 JSONEncoder/JSONDecoder 序列化;Identifiable 提供统一的 id 语义,使字典键、JSON 数组元素、UI 层 ForEach 三者共用同一标识概念;
  • 构造器中的同步加载init 内直接调用静态的 loadSynchronously 完成一次性文件读取。代码注释点明了关键动机——actor 隔离在 init 阶段尚未激活,此时可以(也最适合)做同步加载;.kiro 版技能文档甚至强调 "Synchronous init loading — Avoids async initialization complexity",即避免 async 初始化带来的复杂度,本地小文件的同步读取收益远大于异步化的开销;
  • loadSynchronously 的容错策略:读取与解码均使用 try?,任何一步失败(文件不存在、JSON 损坏)都回退为空字典,保证 App 冷启动不因历史数据损坏而崩溃;解码得到的数组再以 uniquingKeysWith: { _, latest in latest } 折叠为 [String: T] 字典,重复 id 时"后者胜出",行为确定;
  • 写路径全量重写persistToFile 每次把整个 cache.values 编码为 JSON 后整体写入。从代码结构可以推断,这一设计优先保证了简单与一致性(任意时刻磁盘文件都是完整快照,无增量合并逻辑),适合中小规模数据集;若数据量级增长到需要增量写入,则应在此处引入分段或分 key 存储策略;
  • .atomic 原子写data.write(to:options:.atomic) 先将数据写入临时文件再整体替换目标文件。若 App 在写盘中途崩溃,磁盘上要么是旧文件、要么是新文件,而不会出现半个文件,从根本上防止数据损坏(详见后文设计决策表)。

使用方式:actor 隔离带来的隐式异步

因为 actor 的每个方法都被隔离,外部调用一律自动变为异步,必须书写 await

let repository = LocalRepository<Question>()

// Read — fast O(1) lookup from in-memory cache
let question = await repository.find(by: "q-001")
let allQuestions = await repository.loadAll()

// Write — updates cache and persists to file atomically
try await repository.save(newQuestion)
try await repository.delete("q-001")

需要特别注意的读写语义:

  • find(by:)loadAll() 走内存缓存,单条读取是 O(1) 字典查找,loadAll 遍历构造数组;
  • save / delete 都遵循 "先改内存,再落盘" 的顺序——只要落盘抛错,内存状态与磁盘即不一致,调用方应在 try await 处捕获并决定是否回滚;
  • 对调用者而言,"所有方法都 await" 不是性能惩罚而是协议承诺:它显式宣告了"你可能要等待其他任务完成写操作",避免开发者无意中并发读改写。

与 @Observable ViewModel 组合:离线优先界面的标准姿势

纯仓储只能解决"存取安全",要驱动界面刷新还需状态管理层配合。技能文档给出的方案是 Observation 框架下的 @Observable 宏 + actor 仓储:

@Observable
final class QuestionListViewModel {
    private(set) var questions: [Question] = []
    private let repository: LocalRepository<Question>

    init(repository: LocalRepository<Question> = LocalRepository()) {
        self.repository = repository
    }

    func load() async {
        questions = await repository.loadAll()
    }

    func add(_ question: Question) async throws {
        try await repository.save(question)
        questions = await repository.loadAll()
    }
}

这段代码的架构含义:

  • 仓储通过构造器默认参数注入:生产代码直接 LocalRepository(),测试或 SwiftUI Preview 时可以传入 mock(注入点已就位)——这与 skills/swift-protocol-di-testing/SKILL.md 提倡的"默认参数 + 测试注入 mock"一致,也与 rules/swift/patterns.md 的依赖注入规范同构;
  • ViewModel 是唯一与 actor 对话的角色load / add 都是 async,内部以 await 跨越 actor 边界;UI 层只读写 questions 数组;
  • private(set) 收窄写入权限:只有 ViewModel 自己能改 questions,外部(视图)只能读取,配合 @Observable 的属性级追踪,SwiftUI 仅在读取了变更属性的视图上触发重绘(详见 skills/swiftui-patterns/SKILL.md 中 "Use @Observable (not ObservableObject) — it tracks property-level changes" 的说明)。

一个值得注意的细节:addsave 成功后重新 await repository.loadAll() 全量刷新。当 actor 的写方法与读方法之间存在 await 边界时,actor 允许重入(reentrancy),即写盘挂起期间其他任务可能插入执行;"写后重读"能保证 ViewModel 拿到的始终是事后一致的最新快照,是 actor 并发语义下的稳妥写法。

关键设计决策:一张决策表看懂取舍

技能文档用一张决策表浓缩了整个模式的取舍依据,下表为完整原文,并补充了背后的推理:

决策 理由
Actor(而非 class + lock) 线程安全由编译器强制保证,无任何手动同步代码
内存缓存 + 文件持久化 读走缓存(快),写落磁盘(持久)
init 同步加载 规避 async 初始化的复杂度
以 id 为键的字典 按标识符 O(1) 查找
泛型约束 Codable & Identifiable 任意模型类型均可复用
原子文件写(.atomic 崩溃时防止半写(partial writes)

逐条展开背后的权衡:

  1. Actor vs class + lock:锁方案需要开发者自己保证"每个可变访问路径都被同一把锁保护",漏一处即埋下数据竞争;actor 将隔离规则内建为语言语义,违反即编译失败。这是本模式最核心的价值主张;
  2. 缓存 + 落盘双层结构:若只做内存缓存,App 退出即丢数据,无法支撑离线优先;若每次读都走磁盘,Data(contentsOf:) 的 I/O 成本与解码开销将拖慢主流程。折中是"内存为热路径、磁盘为持久化路径";
  3. 同步 init:异步初始化要求 init 抛出或返回可选、调用点全部 async,复杂度与收益不成比例,尤其本地文件场景——本仓库技能在两份副本中均坚持这一点;
  4. 字典按 id 索引:相比数组的线性查找,字典将读路径稳定在 O(1)Identifiableid 恰好兼任字典键;
  5. 泛型复用:仓储不与任何具体业务模型耦合,QuestionUserCachedArticle 均可用同一实现;
  6. 原子写:崩溃一致性是离线数据最重要的防线,详见下文 Best Practices。

工程化延伸:可测试性、规则文件与技能生态

单看一份 SKILL.md 只能得到模式本身;ECC 仓库的价值在于把模式放进了一套相互印证的工程规范里。

测试与依赖注入的配合

.kiro 版技能强调 actor 方法天然适合被 await 测试。可测试性的关键不在仓储内部,而在如何把文件系统这个外部依赖抽走skills/swift-protocol-di-testing/SKILL.md 提供了配套方案:把文件读写抽象为 FileAccessorProviding 等小型协议,生产实现写真实磁盘,测试用 MockFileAccessor(内部是 [URL: Data] 字典,并可注入 readError/writeError 来模拟失败路径):

public actor SyncManager {
    private let fileAccessor: FileAccessorProviding

    public init(fileAccessor: FileAccessorProviding = DefaultFileAccessor()) {
        self.fileAccessor = fileAccessor
    }
    // ...
}

测试则用 Swift Testing 的 @Test + #expect(throws:) 验证错误处理路径(对应 rules/swift/testing.md 规定的"新测试一律使用 Swift Testing")。若你的仓储按本技能文档仅暴露 find/loadAll/save/delete 这类领域操作,配合协议注入即可在完全不触碰真实文件系统的情况下对 LocalRepository 的增删查改与异常分支做确定性验证。

技能生态中的定位

在 ECC 中,这份技能并非孤立存在:

  • rules/swift/patterns.md 定义了 Repository: Sendable 协议与泛型 actor Cache,并把 swift-actor-persistence 列为"actor 持久化模式"的权威出处(References 小节);
  • skills/swiftui-patterns/SKILL.md 在状态管理、性能与 References 多处引用本技能,说明 UI 层规范默认"状态源是 actor 仓储";
  • manifests/install-modules.jsonswift-apple 模块(kind: skills)把 swift-actor-persistenceswift-concurrency-6-2swift-protocol-di-testingswiftui-patterns 一起打包,形成"并发 → 存储 → DI/测试 → UI"的完整 Swift 能力面;
  • agents/swift-build-resolver.md 负责在代码违反这些并发规则时给出最小化修复(例如补 await、补 Sendable),并明令禁止用 @unchecked Sendable 掩盖线程安全问题。

最佳实践清单

技能文档的 Best Practices 每一条都直接映射到前面的设计决策,整理并展开如下:

  • 所有跨 actor 边界的数据都使用 Sendable 类型LocalRepository<T>Tawait repository.save(...) 时会跨越隔离域,非 Sendable 模型会导致编译错误("non-sendable type ... passed in implicitly asynchronous call")。因此模型(如 Question)应满足 Sendable(通常值类型 struct + Codable & Sendable 即可);
  • 保持 actor 公共 API 最小化:只暴露领域操作(save / delete / find / loadAll),不暴露持久化细节(文件路径、编解码、字典本身)。这样未来无论把存储换为 SQLite、CloudKit 还是 iCloud,调用方零改动——这正是 skills/swift-protocol-di-testing/SKILL.md 中"Mock external dependencies, not internal types"原则的另一面;
  • 写盘一律使用 .atomic:防止崩溃导致文件处于中间状态;
  • init 中同步加载:避免 async 初始化复杂度;文件较小或需冷启动即用的场景收益明显;
  • @Observable ViewModel 组合:通过属性级变更追踪获得响应式 UI 更新,同时把异步边界收敛在 ViewModel 层。

需要规避的反模式

技能文档明确了五类反模式,理解"为什么禁止"比记住"不要做"更重要:

  • 新 Swift 并发代码仍用 DispatchQueue / NSLock:手动同步无法获得编译期保证,且与 actor 混用时容易破坏隔离语义;
  • 把内部缓存字典暴露给外部调用者:一旦调用方拿到 cache(或其副本),actor 的封装即被击穿,外部代码可能基于过期快照做决策;
  • fileURL 可配置却不做校验:文档明确警告 "Making the file URL configurable without validation" 是反模式——应当把目录/文件名作为构造参数合理约束,或在写入前校验目录存在性;
  • 忘记所有 actor 调用都需要 await:调用方必须处于 async 上下文,遗漏 await 的编译错误(见 agents/swift-build-resolver.md 的错误对照表)往往意味着调用链上游也要补 async
  • nonisolated 绕过 actor 隔离:这会破坏隔离模型的核心价值。隔离之外只应放纯函数(如本模式中静态的编解码辅助),而绝非那些读写共享可变状态的方法。

何时使用、何时不用

技能文档收尾给出四条使用建议,可作为架构评审时的 checklist:

  • iOS/macOS 应用的本地数据存储(用户数据、设置项、缓存内容);
  • 离线优先(offline-first)架构——本地先行写入,稍后与服务器同步;
  • 被 App 多个部分并发访问的共享可变状态;
  • 将遗留 DispatchQueue 线程安全代码迁移到现代 Swift 并发。

反之,若数据量庞大需要流式/增量 I/O、需要事务与复杂查询,或模型无法满足 Codable & Identifiable & Sendable,则 Actor 单文件 JSON 仓储并非最优解,应评估数据库方案——决策时以"是否需要 actor 的编译期隔离保护 + 快照式文件持久化"为准绳。

延伸阅读:仓库内相关路径

若要亲手验证上述模式,可结合仓库提供的 swift-protocol-di-testing 技能,以 Swift Testing 编写针对 save/delete/loadAll@Test 用例,把 mock 文件访问器注入仓储,逐一验证正常读写与文件损坏、写入失败等异常路径的行为。

登录后查看全文
热门项目推荐
相关项目推荐