authors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.
Paul’s been a developer for nearly three decades, working with many tech stacks but with a focus on Apple. He loves solving hard problems.
PREVIOUSLY AT
These days, modern mobile application development requires a well thought-out plan for keeping user data in sync across various devices. This is a thorny problem with many gotchas and pitfalls, but users expect the feature, and expect it to work well.
For iOS and macOS, Apple provides a robust toolkit used to sync data. CloudKit API allows developers targeting Apple platforms to solve this synchronization problem.
In this article, I’ll demonstrate how to use CloudKit to match up data across two different devices. It’s intended for experienced iOS developers who are already familiar with Apple’s frameworks and with Swift. I’m going to take a fairly deep technical dive into the CloudKit API to explore ways you can leverage this technology to make awesome multi-device apps. I’ll focus on an iOS application, but the same approach can be used for macOS clients as well.
Our example use case is a simple note application with just a single note, for illustration purposes. Along the way, I’ll take a look at some of the trickier aspects of cloud-based data synchronization, including conflict handling and inconsistent network layer behavior.
CloudKit is built on top of Apple’s iCloud service. It’s fair to say iCloud got off to a bit of a rocky start. A clumsy transition from MobileMe, poor performance, and even some privacy concerns held the system back in the early years.
For app developers, the situation was even worse. Before CloudKit, inconsistent behavior and weak debugging tools made it almost impossible to deliver a top quality product using the first generation iCloud APIs.
Over time, however, Apple has addressed these issues. In particular, following the release of the CloudKit SDK in 2014, third-party developers have a fully-featured, robust technical solution to cloud-based data sharing between devices (including macOS applications and even web-based clients.)
Since CloudKit is deeply tied to Apple’s operating systems and devices, it’s not suitable for applications that require a broader range of device support, such as Android or Windows clients. For apps that are targeted to Apple’s user base, however, CloudKit/iCloud provides a deeply powerful mechanism for user authentication and data synchronization.
CloudKit organizes data via a hierarchy of classes: CKContainer
, CKDatabase
, CKRecordZone
, and CKRecord
.
At the top level is CKContainer
, which encapsulates a set of related CloudKit data. Every app automatically gets a default CKContainer
, and a group of apps can share a custom CKContainer
if permission settings allow. That can enable some interesting cross-application workflows.
Within each CKContainer
are multiple instances of CKDatabase
. CloudKit automatically configures every CloudKit-enabled app out of the box to have a public CKDatabase
(all users of the app can see everything) and a private CKDatabase
(each user sees only their own data). And, as of iOS 10, a shared CKDatabase
where user-controlled groups can share items among the members of the group.
Within a CKDatabase
are CKRecordZone
s, and within zones CKRecord
s. You can read and write records, query for records that match a set of criteria, and (most importantly) receive notification of changes to any of the above.
For your Note app, you can use the default container. Within this container, you’re going to use the private database (because you want the user’s note to be seen only by that user) and within the private database, you’re going to use a custom record zone, which enables notification of specific record changes.
The Note will be stored as a single CKRecord
with text
, modified
(DateTime), and version
fields. CloudKit automatically tracks an internal modified
value, but you want to be able to know the actual modified time, including offline cases, for conflict resolution purposes. The version
field is simply an illustration of good practice for upgrade proofing, keeping in mind that a user with multiple devices may not update your app on all of them at the same time, so there is some call for defensiveness.
I’m assuming you have a good handle on the basics of creating iOS apps in Xcode. If you wish, you can download and examine the example Note App Xcode project created for this CloudKit tutorial.
For our purposes, a single view application containing a UITextView
with the ViewController
as its delegate will suffice. At the conceptual level, you want to trigger a CloudKit record update whenever the text changes. However, as a practical matter, it makes sense to use some sort of change coalescing mechanism, such as a background Timer that fires periodically, to avoid spamming the iCloud sync servers with too many tiny changes.
CloudKit app require a few items to be enabled on the Capabilities Pane of the Xcode Target: iCloud (naturally), including the CloudKit checkbox, Push Notifications, and Background Modes (specifically, remote notifications).
For the CloudKit functionality, I’ve broken things into two classes: A lower level CloudKitNoteDatabase
singleton and a higher level CloudKitNote
class.
But first, a quick discussion of CloudKit Errors.
Careful error handling is absolutely essential for a CloudKit client.
Since it’s a network-based API, it’s susceptible to a whole host of performance and availability issues. Also, the service itself must protect against a range of potential issues, such as unauthorized requests, conflicting changes, and the like.
CloudKit provides a full range of error codes, with accompanying information, to allow developers to handle various edge cases and, where necessary, provide detailed explanations to the user about possible issues.
Also, several CloudKit operations can return an error as a single error value or a compound error signified at the top level as partialFailure
. It comes with a Dictionary of contained CKError
s that deserve more careful inspection to figure out what exactly happened during a compound operation.
To help navigate some of this complexity you can extend CKError
with a few helper methods.
Please note all the code has explanatory comments at the key points.
import CloudKit
extension CKError {
public func isRecordNotFound() -> Bool {
return isZoneNotFound() || isUnknownItem()
}
public func isZoneNotFound() -> Bool {
return isSpecificErrorCode(code: .zoneNotFound)
}
public func isUnknownItem() -> Bool {
return isSpecificErrorCode(code: .unknownItem)
}
public func isConflict() -> Bool {
return isSpecificErrorCode(code: .serverRecordChanged)
}
public func isSpecificErrorCode(code: CKError.Code) -> Bool {
var match = false
if self.code == code {
match = true
}
else if self.code == .partialFailure {
// This is a multiple-issue error. Check the underlying array
// of errors to see if it contains a match for the error in question.
guard let errors = partialErrorsByItemID else {
return false
}
for (_, error) in errors {
if let cke = error as? CKError {
if cke.code == code {
match = true
break
}
}
}
}
return match
}
// ServerRecordChanged errors contain the CKRecord information
// for the change that failed, allowing the client to decide
// upon the best course of action in performing a merge.
public func getMergeRecords() -> (CKRecord?, CKRecord?) {
if code == .serverRecordChanged {
// This is the direct case of a simple serverRecordChanged Error.
return (clientRecord, serverRecord)
}
guard code == .partialFailure else {
return (nil, nil)
}
guard let errors = partialErrorsByItemID else {
return (nil, nil)
}
for (_, error) in errors {
if let cke = error as? CKError {
if cke.code == .serverRecordChanged {
// This is the case of a serverRecordChanged Error
// contained within a multi-error PartialFailure Error.
return cke.getMergeRecords()
}
}
}
return (nil, nil)
}
}
CloudKitNoteDatabase
SingletonApple provides two levels of functionality in the CloudKit SDK: High level “convenience” functions, such as fetch()
, save()
, and delete()
, and lower level operation constructs with cumbersome names, such as CKModifyRecordsOperation
.
The convenience API is much more accessible, while the operation approach can be a bit intimidating. However, Apple strongly urges developers to use the operations rather than the convenience methods.
CloudKit operations provide superior control over the details of how CloudKit does its work and, perhaps more importantly, really force the developer to think carefully about network behaviors central to everything CloudKit does. For these reasons, I am using the operations in these code examples.
Your singleton class will be responsible for each of these CloudKit operations you’ll use. In fact, in a sense, you’re recreating the convenience APIs. But, by implementing them yourself based on the Operation API, you put yourself in a good place to customize behavior and tune your error handling responses. For example, if you want to extend this app to handle multiple Notes rather than just one, you could do so more readily (and with higher resulting performance) than if you’d just used Apple’s convenience APIs.
import CloudKit
public protocol CloudKitNoteDatabaseDelegate {
func cloudKitNoteRecordChanged(record: CKRecord)
}
public class CloudKitNoteDatabase {
static let shared = CloudKitNoteDatabase()
private init() {
let zone = CKRecordZone(zoneName: "note-zone")
zoneID = zone.zoneID
}
public var delegate: CloudKitNoteDatabaseDelegate?
public var zoneID: CKRecordZoneID?
// ...
}
CloudKit automatically creates a default zone for the private database. However, you can get more functionality if you use a custom zone, most notably, support for fetching incremental record changes.
Since this is a first example of using an operation, here are a couple of general observations:
First, all CloudKit operations have custom completion closures (and many have intermediate closures, depending on the operation). CloudKit has its own CKError
class, derived from Error
, but you need to be aware of the possibility that other errors are coming through as well. Finally, one of the most important aspects of any operation is the qualityOfService
value. Due to network latency, airplane mode, and such, CloudKit will internally handle retries and such for operations at a qualityOfService
of “utility” or lower. Depending on the context, you may wish to assign a higher qualityOfService
and handle these situations yourself.
Once set up, operations are passed to the CKDatabase
object, where they’ll be executed on a background thread.
// Create a custom zone to contain our note records. We only have to do this once.
private func createZone(completion: @escaping (Error?) -> Void) {
let recordZone = CKRecordZone(zoneID: self.zoneID!)
let operation = CKModifyRecordZonesOperation(recordZonesToSave: [recordZone], recordZoneIDsToDelete: [])
operation.modifyRecordZonesCompletionBlock = { _, _, error in
guard error == nil else {
completion(error)
return
}
completion(nil)
}
operation.qualityOfService = .utility
let container = CKContainer.default()
let db = container.privateCloudDatabase
db.add(operation)
}
Subscriptions are one of the most valuable CloudKit features. They build on Apple’s notification infrastructure to allow various clients to get push notifications when certain CloudKit changes occur. These can be normal push notifications familiar to iOS users (such as sound, banner, or badge), or in CloudKit, they can be a special class of notification called silent pushes. These silent pushes happen entirely without user visibility or interaction, and as a result, don’t require the user to enable push notification for your app, saving you many potential user-experience headaches as an app developer.
The way to enable these silent notifications is to set the shouldSendContentAvailable
property on the CKNotificationInfo
instance, while leaving all of the traditional notification settings (shouldBadge
, soundName
, and so on) unset.
Note also, I am using a CKQuerySubscription
with a very simple “always true” predicate to watch for changes on the one (and only) Note record. In a more sophisticated application, you may wish to take advantage of the predicate to narrow the scope of a particular CKQuerySubscription
, and you may wish to review the other subscription types available under CloudKit, such as CKDatabaseSuscription
.
Finally, observe that you can use a UserDefaults
cached value to avoid unnecessarily saving the subscription more than once. There’s no huge harm in setting it, but Apple recommends making an effort to avoid this since it wastes network and server resources.
// Create the CloudKit subscription we’ll use to receive notification of changes.
// The SubscriptionID lets us identify when an incoming notification is associated
// with the query we created.
public let subscriptionID = "cloudkit-note-changes"
private let subscriptionSavedKey = "ckSubscriptionSaved"
public func saveSubscription() {
// Use a local flag to avoid saving the subscription more than once.
let alreadySaved = UserDefaults.standard.bool(forKey: subscriptionSavedKey)
guard !alreadySaved else {
return
}
// If you wanted to have a subscription fire only for particular
// records you can specify a more interesting NSPredicate here.
// For our purposes we’ll be notified of all changes.
let predicate = NSPredicate(value: true)
let subscription = CKQuerySubscription(recordType: "note",
predicate: predicate,
subscriptionID: subscriptionID,
options: [.firesOnRecordCreation, .firesOnRecordDeletion, .firesOnRecordUpdate])
// We set shouldSendContentAvailable to true to indicate we want CloudKit
// to use silent pushes, which won’t bother the user (and which don’t require
// user permission.)
let notificationInfo = CKNotificationInfo()
notificationInfo.shouldSendContentAvailable = true
subscription.notificationInfo = notificationInfo
let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: [])
operation.modifySubscriptionsCompletionBlock = { (_, _, error) in
guard error == nil else {
return
}
UserDefaults.standard.set(true, forKey: self.subscriptionSavedKey)
}
operation.qualityOfService = .utility
let container = CKContainer.default()
let db = container.privateCloudDatabase
db.add(operation)
}
Fetching a record by name is very straightforward. You can think of the name as the primary key of the record in a simple database sense (names must be unique, for example). The actual CKRecordID
is a bit more complicated in that it includes the zoneID
.
The CKFetchRecordsOperation
operates on one or more records at a time. In this example, there’s just the one record, but for future expandability, this is a great potential performance benefit.
// Fetch a record from the iCloud database
public func loadRecord(name: String, completion: @escaping (CKRecord?, Error?) -> Void) {
let recordID = CKRecordID(recordName: name, zoneID: self.zoneID!)
let operation = CKFetchRecordsOperation(recordIDs: [recordID])
operation.fetchRecordsCompletionBlock = { records, error in
guard error == nil else {
completion(nil, error)
return
}
guard let noteRecord = records?[recordID] else {
// Didn't get the record we asked about?
// This shouldn’t happen but we’ll be defensive.
completion(nil, CKError.unknownItem as? Error)
return
}
completion(noteRecord, nil)
}
operation.qualityOfService = .utility
let container = CKContainer.default()
let db = container.privateCloudDatabase
db.add(operation)
}
Saving records is, perhaps, the most complicated operation. The simple act of writing a record to the database is straightforward enough, but in my example, with multiple clients, this is where you’ll face the potential issue of handling a conflict when multiple clients attempt to write to the server concurrently. Thankfully, CloudKit is explicitly designed to handle this condition. It rejects specific requests with enough error context in the response to allow each client to make a local, enlightened decision about how to resolve the conflict.
Although this adds complexity to the client, it’s ultimately a far better solution than having Apple come up with one of a few server-side mechanisms for conflict resolution.
The app designer is always in the best position to define rules for these situations, which can include everything from context-aware automatic merging to user-directed resolution instructions. I am not going to get very fancy in my example; I am using the modified
field to declare that the most recent update wins. This might not always be the best outcome for professional apps, but it’s not bad for a first rule and, for this purpose, serves to illustrate the mechanism by which CloudKit passes conflict information back to the client.
Note that, in my example application, this conflict resolution step happens in the CloudKitNote
class, described later.
// Save a record to the iCloud database
public func saveRecord(record: CKRecord, completion: @escaping (Error?) -> Void) {
let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: [])
operation.modifyRecordsCompletionBlock = { _, _, error in
guard error == nil else {
guard let ckerror = error as? CKError else {
completion(error)
return
}
guard ckerror.isZoneNotFound() else {
completion(error)
return
}
// ZoneNotFound is the one error we can reasonably expect & handle here, since
// the zone isn't created automatically for us until we've saved one record.
// create the zone and, if successful, try again
self.createZone() { error in
guard error == nil else {
completion(error)
return
}
self.saveRecord(record: record, completion: completion)
}
return
}
// Lazy save the subscription upon first record write
// (saveSubscription is internally defensive against trying to save it more than once)
self.saveSubscription()
completion(nil)
}
operation.qualityOfService = .utility
let container = CKContainer.default()
let db = container.privateCloudDatabase
db.add(operation)
}
CloudKit Notifications provide the means to find out when records have been updated by another client. However, network conditions and performance constraints can cause individual notifications to be dropped, or multiple notifications to intentionally coalesce into a single client notification. Since CloudKit’s notifications are built on top of the iOS notification system, you have to be on the lookout for these conditions.
However, CloudKit gives you the tools you need for this.
Rather than relying on individual notifications to give you detailed knowledge of what change an individual notification represents, you use a notification to simply indicate that something has changed, and then you can ask CloudKit what’s changed since the last time you asked. In my example, I do this by using CKFetchRecordZoneChangesOperation
and CKServerChangeTokens
. Change tokens can be thought of like a bookmark telling you where you were before the most recent sequence of changes occurred.
// Handle receipt of an incoming CloudKit push notification that something has changed.
private let serverChangeTokenKey = "ckServerChangeToken"
public func handleNotification() {
// Use the ChangeToken to fetch only whatever changes have occurred since the last
// time we asked, since intermediate push notifications might have been dropped.
var changeToken: CKServerChangeToken? = nil
let changeTokenData = UserDefaults.standard.data(forKey: serverChangeTokenKey)
if changeTokenData != nil {
changeToken = NSKeyedUnarchiver.unarchiveObject(with: changeTokenData!) as! CKServerChangeToken?
}
let options = CKFetchRecordZoneChangesOptions()
options.previousServerChangeToken = changeToken
let optionsMap = [zoneID!: options]
let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zoneID!], optionsByRecordZoneID: optionsMap)
operation.fetchAllChanges = true
operation.recordChangedBlock = { record in
self.delegate?.cloudKitNoteRecordChanged(record: record)
}
operation.recordZoneChangeTokensUpdatedBlock = { zoneID, changeToken, data in
guard let changeToken = changeToken else {
return
}
let changeTokenData = NSKeyedArchiver.archivedData(withRootObject: changeToken)
UserDefaults.standard.set(changeTokenData, forKey: self.serverChangeTokenKey)
}
operation.recordZoneFetchCompletionBlock = { zoneID, changeToken, data, more, error in
guard error == nil else {
return
}
guard let changeToken = changeToken else {
return
}
let changeTokenData = NSKeyedArchiver.archivedData(withRootObject: changeToken)
UserDefaults.standard.set(changeTokenData, forKey: self.serverChangeTokenKey)
}
operation.fetchRecordZoneChangesCompletionBlock = { error in
guard error == nil else {
return
}
}
operation.qualityOfService = .utility
let container = CKContainer.default()
let db = container.privateCloudDatabase
db.add(operation)
}
You now have the low-level building blocks in place to read and write records, and to handle notifications of record changes.
Let’s look now at a layer built on top of that to manage these operations in the context of a specific Note.
CloudKitNote
ClassFor starters, a few custom errors can be defined to shield the client from the internals of CloudKit, and a simple delegate protocol can inform the client of remote updates to the underlying Note data.
import CloudKit
enum CloudKitNoteError : Error {
case noteNotFound
case newerVersionAvailable
case unexpected
}
public protocol CloudKitNoteDelegate {
func cloudKitNoteChanged(note: CloudKitNote)
}
public class CloudKitNote : CloudKitNoteDatabaseDelegate {
public var delegate: CloudKitNoteDelegate?
private(set) var text: String?
private(set) var modified: Date?
private let recordName = "note"
private let version = 1
private var noteRecord: CKRecord?
public init() {
CloudKitNoteDatabase.shared.delegate = self
}
// CloudKitNoteDatabaseDelegate call:
public func cloudKitNoteRecordChanged(record: CKRecord) {
// will be filled in below...
}
// …
}
CKRecord
to NoteIn Swift, individual fields on a CKRecord
can be accessed via the subscript operator. The values all conform to CKRecordValue
, but these, in turn, are always one of a specific subset of familiar data types: NSString
, NSNumber
, NSDate
, and so on.
Also, CloudKit provides a specific record type for “large” binary objects. No specific cutoff point is specified (a maximum of 1MB total is recommended for each CKRecord
), but as a rule of thumb, just about anything that feels like an independent item (an image, a sound, a blob of text) rather than as a database field should probably be stored as a CKAsset
. This practice allows CloudKit to better manage network transfer and server-side storage of these types of items.
For this example, you’ll use CKAsset
to store the note text. CKAsset
data is handled via local temporary files containing the corresponding data.
// Map from CKRecord to our native data fields
private func syncToRecord(record: CKRecord) -> (String?, Date?, Error?) {
let version = record["version"] as? NSNumber
guard version != nil else {
return (nil, nil, CloudKitNoteError.unexpected)
}
guard version!.intValue <= self.version else {
// Simple example of a version check, in case the user has
// has updated the client on another device but not this one.
// A possible response might be to prompt the user to see
// if the update is available on this device as well.
return (nil, nil, CloudKitNoteError.newerVersionAvailable)
}
let textAsset = record["text"] as? CKAsset
guard textAsset != nil else {
return (nil, nil, CloudKitNoteError.noteNotFound)
}
// CKAsset data is stored as a local temporary file. Read it
// into a String here.
let modified = record["modified"] as? Date
do {
let text = try String(contentsOf: textAsset!.fileURL)
return (text, modified, nil)
}
catch {
return (nil, nil, error)
}
}
Loading a note is very straightforward. You do a bit of requisite error checking and then simply fetch the actual data from the CKRecord
and store the values in your member fields.
// Load a Note from iCloud
public func load(completion: @escaping (String?, Date?, Error?) -> Void) {
let noteDB = CloudKitNoteDatabase.shared
noteDB.loadRecord(name: recordName) { (record, error) in
guard error == nil else {
guard let ckerror = error as? CKError else {
completion(nil, nil, error)
return
}
if ckerror.isRecordNotFound() {
// This typically means we just haven’t saved it yet,
// for example the first time the user runs the app.
completion(nil, nil, CloudKitNoteError.noteNotFound)
return
}
completion(nil, nil, error)
return
}
guard let record = record else {
completion(nil, nil, CloudKitNoteError.unexpected)
return
}
let (text, modified, error) = self.syncToRecord(record: record)
self.noteRecord = record
self.text = text
self.modified = modified
completion(text, modified, error)
}
}
There are a couple of special situations to be aware of when you save a note.
First off, you need to make sure you’re starting from a valid CKRecord
. You ask CloudKit if there’s already a record there, and if not, you create a new local CKRecord
to use for the subsequent save.
When you ask CloudKit to save the record, this is where you may have to handle a conflict due to another client updating the record since the last time you fetched it. In anticipation of this, split the save function into two steps. The first step does a one-time setup in preparation for writing the record, and the second step passes the assembled record down to the singleton CloudKitNoteDatabase
class. This second step may be repeated in the case of a conflict.
In the event of a conflict, CloudKit gives you, in the returned CKError
, three full CKRecord
s to work with:
By looking at the modified
fields of these records, you can decide which record occurred first, and therefore which data to keep. If necessary, you then pass the updated server record to CloudKit to write the new record. Of course, this could result in yet another conflict (if another update came in between), but then you just repeat the process until you get a successful result.
In this simple Note application, with a single user switching between devices, you’re not likely to see too many conflicts in a “live concurrency” sense. However, such conflicts can arise from other circumstances. For example, a user may have made edits on one device while in airplane mode, and then absent-mindedly made different edits on another device before turning airplane mode off on the first device.
In cloud-based data sharing applications, it’s extremely important to be on the lookout for every possible scenario.
// Save a Note to iCloud. If necessary, handle the case of a conflicting change.
public func save(text: String, modified: Date, completion: @escaping (Error?) -> Void) {
guard let record = self.noteRecord else {
// We don’t already have a record. See if there’s one up on iCloud
let noteDB = CloudKitNoteDatabase.shared
noteDB.loadRecord(name: recordName) { record, error in
if let error = error {
guard let ckerror = error as? CKError else {
completion(error)
return
}
guard ckerror.isRecordNotFound() else {
completion(error)
return
}
// No record up on iCloud, so we’ll start with a
// brand new record.
let recordID = CKRecordID(recordName: self.recordName, zoneID: noteDB.zoneID!)
self.noteRecord = CKRecord(recordType: "note", recordID: recordID)
self.noteRecord?["version"] = NSNumber(value:self.version)
}
else {
guard record != nil else {
completion(CloudKitNoteError.unexpected)
return
}
self.noteRecord = record
}
// Repeat the save attempt now that we’ve either fetched
// the record from iCloud or created a new one.
self.save(text: text, modified: modified, completion: completion)
}
return
}
// Save the note text as a temp file to use as the CKAsset data.
let tempDirectory = NSTemporaryDirectory()
let tempFileName = NSUUID().uuidString
let tempFileURL = NSURL.fileURL(withPathComponents: [tempDirectory, tempFileName])
do {
try text.write(to: tempFileURL!, atomically: true, encoding: .utf8)
}
catch {
completion(error)
return
}
let textAsset = CKAsset(fileURL: tempFileURL!)
record["text"] = textAsset
record["modified"] = modified as NSDate
saveRecord(record: record) { updated, error in
defer {
try? FileManager.default.removeItem(at: tempFileURL!)
}
guard error == nil else {
completion(error)
return
}
guard !updated else {
// During the save we found another version on the server side and
// the merging logic determined we should update our local data to match
// what was in the iCloud database.
let (text, modified, syncError) = self.syncToRecord(record: self.noteRecord!)
guard syncError == nil else {
completion(syncError)
return
}
self.text = text
self.modified = modified
// Let the UI know the Note has been updated.
self.delegate?.cloudKitNoteChanged(note: self)
completion(nil)
return
}
self.text = text
self.modified = modified
completion(nil)
}
}
// This internal saveRecord method will repeatedly be called if needed in the case
// of a merge. In those cases, we don’t have to repeat the CKRecord setup.
private func saveRecord(record: CKRecord, completion: @escaping (Bool, Error?) -> Void) {
let noteDB = CloudKitNoteDatabase.shared
noteDB.saveRecord(record: record) { error in
guard error == nil else {
guard let ckerror = error as? CKError else {
completion(false, error)
return
}
let (clientRec, serverRec) = ckerror.getMergeRecords()
guard let clientRecord = clientRec, let serverRecord = serverRec else {
completion(false, error)
return
}
// This is the merge case. Check the modified dates and choose
// the most-recently modified one as the winner. This is just a very
// basic example of conflict handling, more sophisticated data models
// will likely require more nuance here.
let clientModified = clientRecord["modified"] as? Date
let serverModified = serverRecord["modified"] as? Date
if (clientModified?.compare(serverModified!) == .orderedDescending) {
// We’ve decided ours is the winner, so do the update again
// using the current iCloud ServerRecord as the base CKRecord.
serverRecord["text"] = clientRecord["text"]
serverRecord["modified"] = clientModified! as NSDate
self.saveRecord(record: serverRecord) { modified, error in
self.noteRecord = serverRecord
completion(true, error)
}
}
else {
// We’ve decided the iCloud version is the winner.
// No need to overwrite it there but we’ll update our
// local information to match to stay in sync.
self.noteRecord = serverRecord
completion(true, nil)
}
return
}
completion(false, nil)
}
}
When a notification comes in that a record has changed, CloudKitNoteDatabase
will do the heavy lifting of fetching the changes from CloudKit. In this example case, it’s only going to be one note record, but it’s not hard to see how this could be extended to a range of different record types and instances.
For example purposes, I included a basic sanity check to make sure I am updating the correct record, and then update the fields and notify the delegate that we have new data.
// CloudKitNoteDatabaseDelegate call:
public func cloudKitNoteRecordChanged(record: CKRecord) {
if record.recordID == self.noteRecord?.recordID {
let (text, modified, error) = self.syncToRecord(record: record)
guard error == nil else {
return
}
self.noteRecord = record
self.text = text
self.modified = modified
self.delegate?.cloudKitNoteChanged(note: self)
}
}
CloudKit notifications arrive via the standard iOS notification mechanism. Thus, your AppDelegate
should call application.registerForRemoteNotifications
in didFinishLaunchingWithOptions
and implement didReceiveRemoteNotification
. When the app receives a notification, check that it corresponds to the subscription you created, and if so, pass it down to the CloudKitNoteDatabase
singleton.
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
let dict = userInfo as! [String: NSObject]
let notification = CKNotification(fromRemoteNotificationDictionary: dict)
let db = CloudKitNoteDatabase.shared
if notification.subscriptionID == db.subscriptionID {
db.handleNotification()
completionHandler(.newData)
}
else {
completionHandler(.noData)
}
}
Tip: Since push notifications aren’t fully supported in the iOS simulator, you will want to work with physical iOS devices during development and testing of the CloudKit notification feature. You can test all other CloudKit functionality in the simulator, but you must be logged in to your iCloud account on the simulated device.
There you go! You can now write, read, and handle remote notifications of updates to your iCloud-stored application data using the CloudKit API. More importantly, you have a foundation for adding more advanced CloudKit functionality.
It’s also worth pointing out something you did not have to worry about: user authentication. Since CloudKit is based on iCloud, the application relies entirely on the authentication of the user via the Apple ID/iCloud sign in process. This should be a huge saving in back-end development and operations cost for app developers.
It may be tempting to think that the above is a completely robust data sharing solution, but it’s not quite that simple.
Implicit in all of this is that CloudKit may not always be available. Users may not be signed in, they may have disabled CloudKit for the app, they may be in airplane mode—the list of exceptions goes on. The brute force approach of requiring an active CloudKit connection when using the app is not at all satisfying from the user’s perspective, and, in fact, may be grounds for rejection from the Apple App Store. So, an offline mode must be carefully considered.
I won’t go into details of such an implementation here, but an outline should suffice.
The same note fields for text and modified datetime can be stored locally in a file via NSKeyedArchiver
or the like, and the UI can provide near full functionality based on this local copy. It is also possible to serialize CKRecords
directly to and from local storage. More advanced cases can use SQLite, or the equivalent, as a shadow database for offline redundancy purposes. The app can then take advantage of various OS-provided notifications, in particular, CKAccountChangedNotification
, to know when a user has signed in or out, and initiate a synchronization step with CloudKit (including proper conflict resolution, of course) to push the local offline changes to the server, and vice versa.
Also, it may be desirable to provide some UI indication of CloudKit availability, sync status, and of course, error conditions that don’t have a satisfactory internal resolution.
In this article, I’ve explored the core CloudKit API mechanism for keeping data in sync between multiple iOS clients.
Note that the same code will work for macOS clients as well, with slight adjustments for differences in how notifications work on that platform.
CloudKit provides much more functionality on top of this, especially for sophisticated data models, public sharing, advanced user notification features, and more.
Although iCloud sync is only available to Apple customers, CloudKit provides an incredibly powerful platform upon which to build really interesting and user-friendly, multi-client applications with a truly minimal server-side investment.
To dig deeper into CloudKit, I strongly recommend taking the time to view the various CloudKit presentations from each of the last few WWDCs and follow along with the examples they provide.
San Francisco, CA, United States
August 1, 2016
Paul’s been a developer for nearly three decades, working with many tech stacks but with a focus on Apple. He loves solving hard problems.
PREVIOUSLY AT
World-class articles, delivered weekly.
World-class articles, delivered weekly.
Join the Toptal® community.