CloudSyncSession

main branch CI status

CloudSyncSession 是一个 Swift 库,它构建于 CloudKit 框架之上,旨在使编写启用同步、支持离线的应用程序更加容易。

NSPersistentCloudKitContainer 类似,CloudSyncSession 适用于需要在 iCloud 和客户端之间同步区域中所有记录的应用程序。与提供本地持久化的 NSPersistentCloudKitContainer 不同,CloudSyncSession 不以任何方式将状态持久化到磁盘。因此,它可以与任何本地持久化解决方案(例如 GRDB、Core Data、用户默认设置和文件存储等)结合使用。

设计原则

  1. 无持久化。数据不会持久化到磁盘。
  2. 可测试。代码结构旨在最大限度地提高可测试的行为。
  3. 模块化。在合理的范围内,不同的行为由不同的组件分别处理。
  4. 基于事件。状态是可预测的,因为它基于先前发生的一系列事件进行更新。
  5. 弹性。可恢复的错误会使用重试和退避优雅地处理。不可恢复的错误会暂停进一步执行,直到应用程序发出信号表明应恢复工作。
  6. 可检查。可以评估会话的当前状态以进行故障排除和诊断。
  7. 专注。本项目旨在解决一个特定的用例并做好它。

用法

  1. 初始化会话。
static let storeIdentifier: String
static let zoneID: CKRecordZone.ID
static let subscriptionID: String
static let log: OSLog

static func makeSharedSession() -> CloudSyncSession {
    let container = CKContainer(identifier: Self.storeIdentifier)
    let database = container.privateCloudDatabase

    let session = CloudSyncSession(
        operationHandler: CloudKitOperationHandler(
            database: database,
            zoneID: Self.zoneID,
            subscriptionID: Self.subscriptionID,
            log: Self.log
        ),
        zoneID: Self.zoneID,
        resolveConflict: resolveConflict,
        resolveExpiredChangeToken: resolveExpiredChangeToken
    )

    session.appendMiddleware(
        AccountStatusMiddleware(
            session: session,
            ckContainer: container
        )
    )

    return session
}

static func resolveConflict(clientCkRecord: CKRecord, serverCkRecord: CKRecord) -> CKRecord? {
    // Implement your own conflict resolution logic

    if let clientDate = clientCkRecord.cloudKitLastModifiedDate,
        let serverDate = serverCkRecord.cloudKitLastModifiedDate
    {
        return clientDate > serverDate ? clientCkRecord : serverCkRecord
    }

    return clientCkRecord
}

static func resolveExpiredChangeToken() -> CKServerChangeToken? {
    // Update persisted store to reset the change token to nil

    return nil
}
  1. 监听更改。
// Listen for fetch work that has been completed
cloudSyncSession.fetchWorkCompletedSubject
    .map { _, response in
        (response.changeToken, response.changedRecords, response.deletedRecordIDs)
    }
    .sink { changeToken, ckRecords, recordIDsToDelete in
        // Process new and deleted records

        if let changeToken = changeToken {
            var newChangeTokenData: Data? = try NSKeyedArchiver.archivedData(
                withRootObject: changeToken as Any,
                requiringSecureCoding: true
            )

            // Save change token data to disk
        }
    }
// Listen for modification work that has been completed
cloudSyncSession.modifyWorkCompletedSubject
    .map { _, response in
        (response.changedRecords, response.deletedRecordIDs, userInfo)
    }
    .sink { ckRecords, recordIDsToDelete, userInfo in
        // Process new and deleted records
    }
  1. 启动会话。
cloudSyncSession.start()
  1. 发起获取。
// Obtain the change token from disk
let changeToken: CKServerChangeToken?

// Queue a fetch operation
cloudSyncSession.fetch(FetchOperation(changeToken: changeToken))
  1. 发起修改。
let records: [CKRecord]
let recordIDsToDelete = [CKRecord.ID]
let checkpointID = UUID()
let operation = ModifyOperation(
    records: records,
    recordIDsToDelete: recordIDsToDelete,
    checkpointID: checkpointID,
    userInfo: nil
)

cloudSyncSession.modify(operation)
  1. 处理 CloudKit 推送通知以获取实时更新。
// AppDelegate.swift
func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    if let notification = CKNotification(fromRemoteNotificationDictionary: userInfo),
        notification.subscriptionID == Self.subscriptionID {
        // Initiate a fetch request with the most recent change token
        // Wait some time to see if that fetch operation finishes in time
        // Call completionHandler with the appropriate value

        return
    }

    // Handle other kinds of notifications
}
  1. 观察更改和错误以进行用户界面诊断。
cloudSyncSession.haltedSubject
    .sink { error in
        // Update UI based on most recent error
    }

cloudSyncSession.accountStatusSubject
    .sink { accountStatus in
        // Update UI with new account status
    }

cloudSyncSession.$state
    .sink { state in
        // Update UI with new sync state
    }

cloudSyncSession.eventsPublisher
    .sink { [weak self] event in
        // Update UI with most recent event
    }

安装

要将 CloudSyncSession 与 Swift Package Manager 一起使用,请添加对 https://github.com/ryanashcraft/CloudSyncSession 的依赖。

底层原理

CloudSyncSession 是基于事件的。事件描述了已发生或已请求的内容。会话的当前状态是截至那一刻之前事件的结果。

CloudSyncSession 使用事件中间件的概念,努力解耦和模块化独立的行为。中间件是有序的;它们可以在事件传递给后续中间件之前转换事件。它们也可以触发副作用。

默认情况下,初始化以下中间件

此外,AccountStatusMiddleware 是必需的,但默认情况下未初始化。此中间件在会话启动时检查帐户状态。

CloudSyncSession 状态在不同的“操作模式”之间转换,以确定要处理哪种类型的工作:无(由 nil 表示)、createZonecreateSubscriptionmodifyfetch。工作被排队到单独的队列中,每个操作模式一个队列。状态一次仅在一个操作模式下运行。操作模式通过发生的某些事件(例如帐户状态更改和错误)变得符合条件或不符合条件。操作模式是有序的,因此创建区域的工作始终先于修改工作,修改工作同样先于获取工作。

测试

为了使尽可能多的逻辑和行为可测试,大多数 CloudKit 特定代码都通过协议进行了解耦和/或可模拟化。

OperationHandler 是一个协议,它抽象了所有各种操作的处理:FetchOperationModifyOperationCreateZoneOperationCreateSubscriptionOperation。此协议的主要实现 CloudKitOperationHandler 使用标准 CloudKit API 处理这些操作。

有两个测试套件:CloudSyncSessionTestsSyncStateTestsCloudSyncSessionTests 使用自定义 OperationHandler 实例来模拟不同的场景并测试端到端行为,包括重试、拆分工作、处理成功和失败等。

SyncStateTests 断言状态会根据某些事件正确更新。

局限性

CloudSyncSession 并非旨在成为将 CloudKit 集成到您的应用程序中的即插即用解决方案。您需要正确地将元数据和记录持久化到磁盘。此外,您必须使用适当的钩子来在您的数据模型与 CKRecord 之间进行转换。

不支持以下 CloudKit 功能

也许这些功能在某种程度上有效,但它们未经测试。如果您对这些功能感兴趣并想验证它们是否有效,请这样做并通过在 GitHub 上提交 issue 来报告您的学习成果。

* 我有意选择不使用引用,因为对于此库设计的用例(在 iCloud 和多个客户端之间镜像数据),它带来的好处有限,但开销却大得多。

影响

此库深受 Cirrus 的影响。此库的部分内容取自 Cirrus 并进行了修改。

我开发 CloudSyncSession 是因为我正在寻找 Cirrus 没有提供的一些 CloudKit 同步库的功能。

这些都是权衡。Cirrus 是一个很棒的库,可能更适合许多应用程序。

基于事件的架构深受 Redux 的影响。

贡献

我希望您发现此库对您有所帮助,无论是作为参考还是作为您应用程序的解决方案。为了保持低维护成本并最大限度地降低风险,我没有兴趣进行大型重构或大幅扩展当前提供的功能范围。请随意 fork(参见 LICENSE)。

如果您想提交错误修复或增强功能,请提交 pull request。请包括一些背景信息、您的动机,并在适当的时候添加测试。

许可证

请参阅 LICENSE

此库的部分内容取自 MIT 许可的库 Cirrus 并进行了修改,版权 (c) 2020 Jay Hickey。代码已为此项目的使用进行了修改。