Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save msuzoagu/900ecbd6f83b2baed46e0fdb4827947f to your computer and use it in GitHub Desktop.
Save msuzoagu/900ecbd6f83b2baed46e0fdb4827947f to your computer and use it in GitHub Desktop.
See:
The CloudKitNote Class
For 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...
}
// …
}
Mapping From CKRecord to Note
In 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
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)
}
}
Saving a Note and Resolving Potential Conflict
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 CKRecords to work with:
The prior version of the record you tried to save,
The exact version of the record you tried to save,
The version held by the server at the time you submitted the request.
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)
}
}
Handling Notification of a Remotely Changed Note
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.
Handling the Offline Case
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment