iOS- Offline- First- Framework
一个让 Swift 应用离线也能继续工作、并与服务器同步的本地存储。
许可证: MIT
测试: 375测量日期:
测量方法
swift test at tag v2.1.0 (205c546) on macOS 27.0.1 with Swift 6.4: 375 tests in 75 suites passed; run again with OFFLINE_FIRST_NODE set so the three reference-server suites ran, with the same result; the two end-to-end suites that need OFFLINE_FIRST_SERVER_URL were skipped. npm test of Examples/SyncServer on Node 26.11.1: 28 tests passed. The README states 372 tests on an iPhone simulator, which was not re-run here.
摘要
- 一个将记录保存在设备上文件中、把每次改动加入队列,并在有连接时与任意服务器同步的 Swift package。
- 2.1.0 修复了 2.0.0 的两次审计及其后续各轮发现的 data-integrity 问题,每项修复都有一个此前会失败的测试。
- 只有 store 读取过的 push 应答才会结算一次变更;应答丢失或无法读取后,该变更会保持未决,直到下一次 push。
- 新的服务器 epoch 只有在同一次 sync 的两个请求都报告它时才会计数,且 store 绝不会回退到更早的 epoch。
- 来自同一 URL 背后另一个 deployment 的应答只能被部分覆盖;八个未解决的 issue 描述了每种情况及恢复方式。
一个 Swift package,将应用的记录保存在设备上的一个文件中,把每一次改动加入队列,并在有连接时与服务器同步。保存操作从不等待网络。当同一条记录在两台设备上都发生变化时,由你选择的 conflict policy 决定保留哪一个。它适配任意 backend:带参考服务器的小型 HTTP 协议、通过双函数 protocol 对接的你自己的 API,或是用于测试与预览的内存服务器。该 package 没有任何依赖。
功能特性
- 绝不等待的写入:save 和 delete 会写入一个以 atomic 方式写出的文件,以及一个持久化的待处理变更队列;失败的 push 会以 exponential backoff 和 jitter 重试,最后一次尝试后会被搁置,而不是丢失
- 冲突策略(conflict policy):基于 hybrid logical clock 时间戳的 last write wins、server wins、client wins、你自己的 merge,或是无冲突字段(LWWRegister、LWWMap、GCounter、PNCounter、ORSet)
- 适配任意 backend:HTTPSyncTransport 使用一个有文档说明的双 endpoint JSON 协议,由 Examples/SyncServer 中的 Node 参考服务器实现;其他任何 API 都可以置于 SyncTransport protocol 之后
- SwiftUI:SyncedCollection 是 store 的一个 @Observable 模型;自动 sync 会在设备上线时以及本地编辑之后不久运行
- 静态数据:使用保存在 Keychain 中的密钥进行 AES-GCM 文件加密、记录 schema 的 migration,以及针对损坏文件的策略
- 针对异常服务器和损坏文件的边界:outbox 大小、pull 分页数、每条记录的 push 次数、response、revision 与 change token 的大小限制,以及不能脱离其目录的文件名
- 明确声明的保证:服务器已接受的写入绝不会被一份过期的副本替换,只有被读取过的应答才会结算一次变更,新的服务器 epoch 只有在同一次 sync 的两个请求都报告它时才会计数;Guarantees and limits 一文说明了服务器为此必须做到什么
- 使用 Swift Testing 测试:在 macOS 上有 375 个测试,在 iPhone 模拟器上有 372 个,其中包括让每种 conflict policy 都经历丢失请求、重启、token 重置、过期页面和崩溃的 property test,以及在 CI 中针对参考服务器运行的 end-to-end 测试套件,而该参考服务器自身有 28 个 Node 测试
快速开始
1import OfflineFirstFramework2import SwiftUI3 4/// A to-do item. Any `Codable` struct with an `id` can be a record.5struct Todo: SyncableRecord, Hashable {6 var id = UUID()7 var title: String8 var isDone = false9}10 11enum TodoStore {12 // InMemorySyncServer stands in for a backend; swap in HTTPSyncTransport or your own13 // SyncTransport when you have one. The initializer throws only for an invalid name or a14 // store file it cannot read.15 static let shared = try! OfflineStore<Todo>(name: "todos", transport: InMemorySyncServer<Todo>())16}17 18struct ContentView: View {19 @State private var todos = SyncedCollection(store: TodoStore.shared)20 @State private var newTitle = ""21 22 var body: some View {23 NavigationStack {24 List {25 TextField("New to-do", text: $newTitle)26 .onSubmit {27 let todo = Todo(title: newTitle)28 newTitle = ""29 Task { await todos.save(todo) }30 }31 ForEach(todos.records) { todo in32 Text(todo.title)33 }34 }35 .navigationTitle("To-dos")36 .toolbar {37 Text("\(todos.pendingCount) to sync")38 }39 .refreshable { await todos.sync() }40 }41 }42}安装
Xcode (File › Add Package Dependencies…)
1https://github.com/muhittincamdali/iOS-Offline-First-Framework.gitSwift Package Manager
1dependencies: [2 .package(url: "https://github.com/muhittincamdali/iOS-Offline-First-Framework.git", from: "2.1.0")3],4targets: [5 .target(name: "YourApp", dependencies: [6 .product(name: "OfflineFirstFramework", package: "iOS-Offline-First-Framework")7 ])8]截图




时间线
仓库以一组 manager 类的形式开放;sync manager 只是模拟结果,从未真正联系过服务器
v1.0.0 被打上标签;其 manifest 声明了一个不存在的 Resources 文件夹和不安全的编译器 flags,因此无法按版本号添加为 dependency
精简:RxSwift 和 CocoaLumberjack 被 Combine 取代,manager 被缩短,sync 仍只是一个 placeholder
作为带有 sync engine 的离线存储从零重建,并以 2.0.0 发布;SwiftPersistence package 被合并进来并归档,1.0.0 的 API 一个都没有保留。同一天对 2.0.0 进行的两次审计列出了十一项必须修复的 data-integrity 问题
v2.1.0:经过十五轮修复与检查后的 data-integrity 发布;第 14 和第 15 轮因出现 regression 被放弃,发布分支回退到第 13 轮的最后一次 commit,并将八项已知限制记录为 issue