CloudSyncSession is a Swift library that builds on top of the CloudKit framework to make it easier to write sync-enabled, offline-capable apps.
Similar to NSPersistentCloudKitContainer, CloudSyncSession works for apps that need to sync all records in a zone between iCloud and the client. Unlike NSPersistentCloudKitContainer, which offers local persistence, CloudSyncSession does not persist state to disk in any way. As such, it can be used in conjunction with any local persistence solution (e.g. GRDB, Core Data, user defaults and file storage, etc.).
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
}
// 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
}
cloudSyncSession.start()
// Obtain the change token from disk
let changeToken: CKServerChangeToken?
// Queue a fetch operation
cloudSyncSession.fetch(FetchOperation(changeToken: changeToken))
let records: [CKRecord]
let recordIDsToDelete = [CKRecord.ID]
let checkpointID = UUID()
let operation = ModifyOperation(
records: records,
recordIDsToDelete: recordIDsToDelete,
checkpointID: checkpointID,
userInfo: nil
)
cloudSyncSession.modify(operation)
// 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
}
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
}
To use CloudSyncSession with Swift Package Manager, add a dependency to https://github.com/ryanashcraft/CloudSyncSession
CloudSyncSession is event-based. Events describe what has occurred, or what has been requested. The current state of the session is a result of the previous events up to that point.
CloudSyncSession uses the concept of event middleware in an effort to decouple and modularize independent behaviors. Middleware are ordered; they can transform events before they are passed along to the following middleware. They can also trigger side effects.
By default, the following middleware are initialized:
SplittingMiddleware
: Handles splitting up large work.ErrorMiddleware
: Transforms CloudKit errors into a new event (e.g. retry
, halt
, resolveConflict
, etc.).RetryMiddleware
: Handles how to handle work that was marked to be retried.WorkMiddleware
: Handles translating events into calls on the operation handler, and dispatches new events based on the result.SubjectMiddleware
: Sends values to the various Combine subjects on the CloudSyncSession instance.LoggerMiddleware
: Logs all events with os_log.ZoneMiddleware
: On session start, dispatches events to queue work to create the zone and associated subscription.In addition, the AccountStatusMiddleware
is required, but not initialized by default. This middleware checks the account status on session start.
CloudSyncSession state transitions between different "operation modes" to determine which sort of work is to be handled: none (represented by nil
), createZone
, createSubscription
, modify
, and fetch
. Work is queued up into separate queues, one for each operation mode. The state only operates in one operation mode at a time. Operation modes are made eligible or ineligible by certain events that occur (e.g. account status changes and errors). Operation modes are ordered, so work to create a zone always precedes work modification work, which likewise precedes fetch work.
In an effort to make as much of the logic and behavior testable, most CloudKit-specific code is decoupled and/or mockable via protocols.
OperationHandler
is a protocol that abstracts the handling all of the various operations: FetchOperation
, ModifyOperation
, CreateZoneOperation
, and CreateSubscriptionOperation
. The main implementation of this protocol, CloudKitOperationHandler
, handles these operations using the standard CloudKit APIs.
There are two test suites: CloudSyncSessionTests
and SyncStateTests
. CloudSyncSessionTests
uses custom OperationHandler
instances to simulate different scenarios and test end-to-end behaviors, including retries, splitting up work, handling success and failure, etc.
SyncStateTests
asserts that the state is correctly updated based on certain events.
CloudSyncSession is not intended to be a drop-in solution to integrating CloudKit into your app. You need to correctly persist metadata and records to disk. In addition, you must use the appropriate hooks to convert your data models to and from CKRecords.
These CloudKit features are not supported:
Perhaps these features work in some capacity, but they are untested. If you are interested in these features and want to verify that they work, please do so and report back your learnings by filing an issue on GitHub.
* I intentionally opted to not use references as it come with limited benefits and much more overhead for the sort of use case this library was designed for (mirroring data between iCloud and multiple clients).
This library was heavily influenced by Cirrus. Portions of this library were taken and modified by Cirrus.
I developed CloudSyncSession because there were a few things I was looking for in a CloudKit syncing library that Cirrus didn't offer.
These are all tradeoffs. Cirrus is a great library and likely a better fit for many apps.
The event-based architecture was heavily influenced by Redux.
I hope that you find this library helpful, either as a reference or as a solution for your app. In an effort to keep maintenance low and minimize risk, I am not interested in large refactors or expanding the greatly scope of the capabilities currently offered. Please feel free to fork (see LICENSE).
If you'd like to submit a bug fix or enhancement, please submit a pull request. Please include some context, your motivation, add tests if appropriate.
See LICENSE.
Portions of this library were taken and modified from Cirrus, an MIT-licensed library, Copyright (c) 2020 Jay Hickey. The code has been modified for use in this project.
link |
Stars: 296 |
Last commit: 15 weeks ago |
Swiftpack is being maintained by Petr Pavlik | @ptrpavlik | @swiftpackco | API | Analytics