用 Swift Actor 构建线程安全持久化层:ECC 中 swift-actor-persistence 技能的核心模式与实战拆解
本篇文章以 .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)做线程安全访问;
- 希望彻底告别手写同步代码(
NSLock、DispatchQueue、信号量等); - 正在构建以本地存储为基础的离线优先应用。
仓库中的 swift-apple 技能模块将本技能与 swift-concurrency-6-2、swift-protocol-di-testing、swiftui-patterns 并列打包,注册于 manifests/install-modules.json 的 swift-apple(kind: skills)分组中;同时 rules/swift/patterns.md 与 skills/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 内部状态;
- 编译期检查而非运行时锁:这与
NSLock、DispatchQueue的根本差异在于——漏加锁导致的竞争在运行时才暴露,而 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' 提示缺失 await;actor-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 == String:Codable保证模型可被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(notObservableObject) — it tracks property-level changes" 的说明)。
一个值得注意的细节:add 在 save 成功后重新 await repository.loadAll() 全量刷新。当 actor 的写方法与读方法之间存在 await 边界时,actor 允许重入(reentrancy),即写盘挂起期间其他任务可能插入执行;"写后重读"能保证 ViewModel 拿到的始终是事后一致的最新快照,是 actor 并发语义下的稳妥写法。
关键设计决策:一张决策表看懂取舍
技能文档用一张决策表浓缩了整个模式的取舍依据,下表为完整原文,并补充了背后的推理:
| 决策 | 理由 |
|---|---|
| Actor(而非 class + lock) | 线程安全由编译器强制保证,无任何手动同步代码 |
| 内存缓存 + 文件持久化 | 读走缓存(快),写落磁盘(持久) |
| init 同步加载 | 规避 async 初始化的复杂度 |
| 以 id 为键的字典 | 按标识符 O(1) 查找 |
泛型约束 Codable & Identifiable |
任意模型类型均可复用 |
原子文件写(.atomic) |
崩溃时防止半写(partial writes) |
逐条展开背后的权衡:
- Actor vs class + lock:锁方案需要开发者自己保证"每个可变访问路径都被同一把锁保护",漏一处即埋下数据竞争;actor 将隔离规则内建为语言语义,违反即编译失败。这是本模式最核心的价值主张;
- 缓存 + 落盘双层结构:若只做内存缓存,App 退出即丢数据,无法支撑离线优先;若每次读都走磁盘,
Data(contentsOf:)的 I/O 成本与解码开销将拖慢主流程。折中是"内存为热路径、磁盘为持久化路径"; - 同步 init:异步初始化要求
init抛出或返回可选、调用点全部async,复杂度与收益不成比例,尤其本地文件场景——本仓库技能在两份副本中均坚持这一点; - 字典按 id 索引:相比数组的线性查找,字典将读路径稳定在
O(1);Identifiable的id恰好兼任字典键; - 泛型复用:仓储不与任何具体业务模型耦合,
Question、User、CachedArticle均可用同一实现; - 原子写:崩溃一致性是离线数据最重要的防线,详见下文 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协议与泛型 actorCache,并把swift-actor-persistence列为"actor 持久化模式"的权威出处(References 小节); - skills/swiftui-patterns/SKILL.md 在状态管理、性能与 References 多处引用本技能,说明 UI 层规范默认"状态源是 actor 仓储";
- manifests/install-modules.json 的
swift-apple模块(kind: skills)把swift-actor-persistence与swift-concurrency-6-2、swift-protocol-di-testing、swiftui-patterns一起打包,形成"并发 → 存储 → DI/测试 → UI"的完整 Swift 能力面; - agents/swift-build-resolver.md 负责在代码违反这些并发规则时给出最小化修复(例如补
await、补Sendable),并明令禁止用@unchecked Sendable掩盖线程安全问题。
最佳实践清单
技能文档的 Best Practices 每一条都直接映射到前面的设计决策,整理并展开如下:
- 所有跨 actor 边界的数据都使用
Sendable类型:LocalRepository<T>的T在await 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 初始化复杂度;文件较小或需冷启动即用的场景收益明显; - 与
@ObservableViewModel 组合:通过属性级变更追踪获得响应式 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 的编译期隔离保护 + 快照式文件持久化"为准绳。
延伸阅读:仓库内相关路径
- 本技能主文档:.kiro/skills/swift-actor-persistence/SKILL.md 与 skills/swift-actor-persistence/SKILL.md(两副本内容等价)
- Swift 通用规则(actor
Cache、Repository: Sendable协议):rules/swift/patterns.md - Swift 测试框架约定:rules/swift/testing.md
- 配套技能:协议式依赖注入与 mock 测试 skills/swift-protocol-di-testing/SKILL.md、SwiftUI
@Observable状态管理 skills/swiftui-patterns/SKILL.md - Swift 构建/并发错误修复 agent:agents/swift-build-resolver.md
- 技能分发清单(
swift-apple模块):manifests/install-modules.json - 中文翻译副本:docs/zh-CN/skills/swift-actor-persistence/SKILL.md
若要亲手验证上述模式,可结合仓库提供的 swift-protocol-di-testing 技能,以 Swift Testing 编写针对 save/delete/loadAll 的 @Test 用例,把 mock 文件访问器注入仓储,逐一验证正常读写与文件损坏、写入失败等异常路径的行为。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00