swift-actor-persistence

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Swift Actors for Thread-Safe Persistence

基于Swift Actors的线程安全持久化

Lifecycle Position

生命周期定位

Phase 3 (Implement). Load when building local data storage, offline-first patterns, or caching layers. Related:
swift-concurrency
for general actor patterns,
swift-networking
for server sync.
阶段3(实现)。在构建本地数据存储、离线优先模式或缓存层时加载。相关内容:
swift-concurrency
(通用Actor模式)、
swift-networking
(服务器同步)。

When to Use

使用场景

  • Building a local data persistence layer
  • Need thread-safe access to shared mutable state with disk backing
  • Want compile-time data race elimination (no manual locks or queues)
  • Building offline-first apps with local storage
  • Replacing legacy
    DispatchQueue
    -based file managers
  • 构建本地数据持久化层
  • 需要对带磁盘存储的共享可变状态进行线程安全访问
  • 希望通过编译时消除数据竞争(无需手动锁或队列)
  • 构建带本地存储的离线优先应用
  • 替换基于
    DispatchQueue
    的传统文件管理器

Core Pattern: Actor-Based Repository

核心模式:基于Actor的仓库

The actor model guarantees serialized access — no data races, enforced by the compiler.
swift
public actor LocalRepository<T: Codable & Identifiable> where T.ID: Hashable & Codable {
    private var cache: [T.ID: T] = [:]
    private let fileURL: URL

    public init(directory: URL = .documentsDirectory, filename: String = "data.json") {
        self.fileURL = directory.appendingPathComponent(filename)
        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: T.ID) throws {
        cache[id] = nil
        try persistToFile()
    }

    public func find(by id: T.ID) -> 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) -> [T.ID: T] {
        guard let data = try? Data(contentsOf: url),
              let items = try? JSONDecoder().decode([T].self, from: data) else {
            return [:]
        }
        return Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
    }
}
All calls are automatically async due to actor isolation:
swift
let repo = LocalRepository<Question>(filename: "questions.json")

let question = await repo.find(by: questionID)   // O(1) cache lookup
let all = await repo.loadAll()
try await repo.save(newQuestion)                  // updates cache + persists
try await repo.delete(questionID)
Actor模型保证序列化访问——由编译器强制执行,不会出现数据竞争。
swift
public actor LocalRepository<T: Codable & Identifiable> where T.ID: Hashable & Codable {
    private var cache: [T.ID: T] = [:]
    private let fileURL: URL

    public init(directory: URL = .documentsDirectory, filename: String = "data.json") {
        self.fileURL = directory.appendingPathComponent(filename)
        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: T.ID) throws {
        cache[id] = nil
        try persistToFile()
    }

    public func find(by id: T.ID) -> 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) -> [T.ID: T] {
        guard let data = try? Data(contentsOf: url),
              let items = try? JSONDecoder().decode([T].self, from: data) else {
            return [:]
        }
        return Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
    }
}
所有调用因Actor隔离自动变为异步:
swift
let repo = LocalRepository<Question>(filename: "questions.json")

let question = await repo.find(by: questionID)   // O(1) 缓存查找
let all = await repo.loadAll()
try await repo.save(newQuestion)                  // 更新缓存 + 持久化
try await repo.delete(questionID)

Combining with @Observable ViewModel

与@Observable ViewModel结合

swift
@Observable @MainActor
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()
    }

    func remove(_ id: Question.ID) async throws {
        try await repository.delete(id)
        questions = await repository.loadAll()
    }
}
swift
@Observable @MainActor
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()
    }

    func remove(_ id: Question.ID) async throws {
        try await repository.delete(id)
        questions = await repository.loadAll()
    }
}

Design Decisions

设计决策

DecisionRationale
Actor (not class + lock)Compiler-enforced thread safety, zero manual synchronization
In-memory cache + file persistenceFast reads from cache, durable writes to disk
Synchronous init loadingAvoids async initialization complexity; actor isolation not yet active during
init
Dictionary keyed by IDO(1) lookups by identifier
Generic over
Codable & Identifiable
Reusable across any model type
Atomic file writes (
.atomic
)
Prevents partial writes on crash
决策理由
使用Actor(而非类+锁)编译器强制执行线程安全,无需手动同步
内存缓存+文件持久化从缓存快速读取,写入磁盘保证持久化
同步初始化加载避免异步初始化复杂度;初始化期间Actor隔离尚未生效
按ID作为字典键通过标识符实现O(1)查找
泛型约束
Codable & Identifiable
可在任何模型类型中复用
原子文件写入(
.atomic
崩溃时避免部分写入

Swift 6.2+ Considerations

Swift 6.2+ 注意事项

What Works Well

适用场景

  • Actor isolation is fully enforced at compile time — Swift 6 strict concurrency mode catches all data race violations. This pattern is the recommended replacement for
    DispatchQueue
    -based synchronization.
  • Synchronous init is safe — Actor designated initializers can access stored properties synchronously because no other code can reference
    self
    until init completes. The
    loadSynchronously
    static method pattern is valid.
  • Generic actors are fully supported.
  • Actor隔离在编译时完全强制执行——Swift 6严格并发模式可捕获所有数据竞争违规。该模式是基于
    DispatchQueue
    同步方案的推荐替代方案。
  • 同步初始化安全——Actor指定初始化器可同步访问存储属性,因为初始化完成前其他代码无法引用
    self
    loadSynchronously
    静态方法模式是有效的。
  • 泛型Actor完全支持。

Adaptations Made (vs. Original)

适配调整(与原始版本对比)

  1. @Observable @MainActor
    on ViewModels
    — The original used
    @Observable final class
    without
    @MainActor
    . In Swift 6 strict concurrency, ViewModels accessed from SwiftUI views must be
    @MainActor
    -isolated to avoid actor-boundary crossing errors.
  2. T.ID: Hashable & Codable
    instead of
    T.ID == String
    — The original constrained ID to
    String
    . Relaxing to
    Hashable & Codable
    supports
    UUID
    ,
    Int
    , and custom ID types used in real-world Swift apps.
  1. ViewModel使用
    @Observable @MainActor
    ——原始版本使用
    @Observable final class
    但未添加
    @MainActor
    。在Swift 6严格并发模式下,SwiftUI视图访问的ViewModel必须处于
    @MainActor
    隔离状态,以避免跨Actor边界错误。
  2. **
    T.ID: Hashable & Codable
    **替代
    T.ID == String
    ——原始版本将ID约束为
    String
    。放宽为
    Hashable & Codable
    支持
    UUID
    Int
    以及实际Swift应用中使用的自定义ID类型。

Known Trade-offs

已知权衡

Synchronous file I/O inside actors:
Data.write(to:)
and
Data(contentsOf:)
perform blocking disk I/O on the cooperative thread pool. For small files (< 1 MB) this is acceptable. For large datasets, consider:
swift
// Option 1: Offload to a non-cooperative thread
private func persistToFile() async throws {
    let data = try JSONEncoder().encode(Array(cache.values))
    let url = fileURL
    try await Task.detached {
        try data.write(to: url, options: .atomic)
    }.value
}

// Option 2: Use SwiftData or Core Data for large datasets
// This pattern is best suited for lightweight local storage
No migration support: This is raw JSON file storage. If your model evolves, consider adding a version field:
swift
private struct VersionedStore<T: Codable>: Codable {
    let version: Int
    let items: [T]
}
Actor内部的同步文件I/O
Data.write(to:)
Data(contentsOf:)
在协作线程池上执行阻塞磁盘I/O。对于小文件(<1MB),这是可接受的。对于大型数据集,可考虑:
swift
// 选项1:卸载到非协作线程
private func persistToFile() async throws {
    let data = try JSONEncoder().encode(Array(cache.values))
    let url = fileURL
    try await Task.detached {
        try data.write(to: url, options: .atomic)
    }.value
}

// 选项2:对大型数据集使用SwiftData或Core Data
// 该模式最适合轻量级本地存储
无迁移支持:这是原始JSON文件存储。如果模型演进,考虑添加版本字段:
swift
private struct VersionedStore<T: Codable>: Codable {
    let version: Int
    let items: [T]
}

Anti-Patterns

反模式

Don'tDo Instead
DispatchQueue
or
NSLock
for thread safety
Use actors — compiler-enforced, zero runtime overhead
Expose the internal cache dictionaryOnly expose domain operations (
save
,
find
,
loadAll
)
nonisolated
to bypass actor isolation
Defeats the purpose — rethink the design
Call actor methods in a tight loopBatch operations into a single actor method
Use for datasets > 10 MBSwitch to SwiftData, Core Data, or SQLite
不要做替代方案
使用
DispatchQueue
NSLock
实现线程安全
使用Actors——编译器强制执行,无运行时开销
暴露内部缓存字典仅暴露领域操作(
save
find
loadAll
使用
nonisolated
绕过Actor隔离
违背设计初衷——重新考虑设计
在循环中频繁调用Actor方法将操作批量处理为单个Actor方法
用于大于10MB的数据集切换到SwiftData、Core Data或SQLite

Cross-References

交叉引用

  • swift-concurrency
    — General actor patterns, reentrancy,
    @MainActor
    , GCD migration
  • swift-networking
    — Async network calls that feed data into actor-based repositories
  • swift-app-lifecycle
    — Scene phase changes for triggering persistence saves
  • swift-concurrency
    ——通用Actor模式、可重入性、
    @MainActor
    、GCD迁移
  • swift-networking
    ——将数据输入基于Actor的仓库的异步网络调用
  • swift-app-lifecycle
    ——触发持久化保存的场景阶段变化

Templates

模板

SwiftData persistence layer in
templates/
— copy and adapt:
  • Repository.swift
    — Generic
    Repository
    protocol for CRUD operations
  • SwiftDataRepository.swift
    @MainActor
    SwiftData implementation with batch operations and pagination
  • ExampleModel.swift
    — Sample
    @Model
    entity showing SwiftData patterns
  • PersistenceController.swift
    ModelContainer
    setup with migration support
templates/
目录下的SwiftData持久化层——可复制并适配:
  • Repository.swift
    ——用于CRUD操作的通用
    Repository
    协议
  • SwiftDataRepository.swift
    ——带批量操作和分页的
    @MainActor
    SwiftData实现
  • ExampleModel.swift
    ——展示SwiftData模式的示例
    @Model
    实体
  • PersistenceController.swift
    ——带迁移支持的
    ModelContainer
    设置