So far in this series we've been using CloudKit directly from our controllers. This can be somewhat limiting. It requires you to be online or everything fails, we may want to add a caching layer, or we might want to use CloudKit as a network synchronization layer, rather than a primary data store. In this episode we'll examine an architecture that will allow you to decouple your view controllers from CloudKit as a first step to achieving more flexibility with your CloudKit implementation.
Episode Links Source Code A Protocol for our Models First we define a protocol for the Note: protocol Note : CustomStringConvertible { var identifier: String? { get } var title: String { get } var content: String { get set } var createdAt: Date? { get } var modifiedAt: Date? { get } var folderIdentifier: String? { get set } } extension Note { var title: String { let length = content.lengthOfBytes(using: .utf8) if length == 0 { return "New Note" } else { let previewLength = 20 // if it's short enough, just use the entire content if length < previewLength { return content } var index = content.index(content.startIndex, offsetBy: previewLength) var foundTitle = false if let firstNewLine = content.range(of: "\n") { if firstNewLine.upperBound < index { index = firstNewLine.upperBound foundTitle = true } } var preview = content.substring(to: index) if content.lengthOfBytes(using: .utf8) > previewLength && !foundTitle { preview.append("...") } return preview } } var description: String { return "Note > \(title) (\(identifier ?? "nil"))" } } Here we have a simple computed property for the title we can use so any Note implementation can get this for free. Next, we define our folder protocol: protocol Folder : CustomStringConvertible { var identifier: String? { get } var name: String { get set } var createdAt: Date? { get } var modifiedAt: Date? { get } init(name: String) } extension Folder { var description: String { return "Folder > \(name) (\(identifier ?? "nil"))" } } A Facade for all Data Access To fetch or save any of the objects in our application, we will use a façade that encapsulates this logic. Different implementations of this protocol can be used to swap out the implementation strategy of the app: extension Notification.Name { static let NoteWasUpdated = Notification.Name("NoteWasUpdated") } protocol NotesManager { typealias OperationCompletionBlock<T> = (Result<T>) -> Void static var hasCreatedDefaultFolder: Bool { get } static var sharedInstance: NotesManager { get } func newFolder(name: String) -> Folder func newNote(in folder: Folder) -> Note func createDefaultFolder(completion: @escaping OperationCompletionBlock<Folder>) func fetchFolders(completion: @escaping OperationCompletionBlock<[Folder]>) func save(folder: Folder, completion: @escaping OperationCompletionBlock<Folder>) func delete(folder: Folder, completion: @escaping OperationCompletionBlock<Bool>) func fetchNotes(for: Folder, completion: @escaping OperationCompletionBlock<[Note]>) func save(note: Note, completion: @escaping OperationCompletionBlock<Note>) } There are a lot of methods to implement, but each one should be straight-forward to implement. The In-Memory Implementation To get the app working without worrying about CloudKit yet, we can code our view controllers to only depend on the Note and Folder protocols as well as the NoteManager protocol. The actual implementation of these will be hidden. class InMemoryNote : Note { var identifier: String? var content: String = "" var createdAt: Date? var modifiedAt: Date? var folderIdentifier: String? init() { } init(note: Note) { identifier = note.identifier content = note.content createdAt = note.createdAt modifiedAt = note.modifiedAt folderIdentifier = note.folderIdentifier } } Here we have a very basic implementation of a Note that simply has properties for each of the fields. Folder is similar: class InMemoryFolder : Folder { var identifier: String? var name: String var createdAt: Date? var modifiedAt: Date? required init(name: String) { self.name = name } init(folder: Folder) { self.identifier = folder.identifier self.name = folder.name self.createdAt = folder.createdAt } } Our note manager will store the data in dictionaries, keyed by the identifier of the object. The fetch methods will simply filter these collections to return the appropriate data. class InMemoryNotesManager : NotesManager { static var hasCreatedDefaultFolder = false static var sharedInstance: NotesManager = InMemoryNotesManager() private var folders: [String : Folder] = [:] private var notes: [String : Note] = [:] private init() { } // MARK: - Folders public func createDefaultFolder(completion: @escaping (Result<Folder>) -> Void) { let folder = InMemoryFolder(name: "My Notes") folder.identifier = "default" save(folder: folder, completion: completion) } public func fetchFolders(completion: @escaping OperationCompletionBlock<[Folder]>) { let sortedFolders = folders.values.sorted { (a, b) in guard let createdA = a.createdAt, let createdB = b.createdAt else { fatalError("Saved folders must have non-nil createdAt dates.") } return createdA < createdB } completion(.success(sortedFolders)) } public func save(folder: Folder, completion: @escaping OperationCompletionBlock<Folder>) { guard let folder = folder as? InMemoryFolder else { return } folder.identifier ??= UUID().uuidString folder.createdAt ??= Date() folders[folder.identifier!] = folder completion(.success(folder)) } public func delete(folder: Folder, completion: @escaping OperationCompletionBlock<Bool>) { if let id = folder.identifier { folders.removeValue(forKey: id) } completion(.success(true)) } public func newFolder(name: String) -> Folder { return InMemoryFolder(name: name) } // MARK: - Notes public func newNote(in folder: Folder) -> Note { let note = InMemoryNote() note.folderIdentifier = folder.identifier return note } public func fetchNotes(for folder: Folder, completion: @escaping OperationCompletionBlock<[Note]>) { let folderNotes = notes.values .filter { $0.folderIdentifier == folder.identifier } .sorted { a, b in guard let modifiedA = a.modifiedAt, let modifiedB = b.modifiedAt else { fatalError("Saved notes must have a non-nil modified date.") } return modifiedA < modifiedB } completion(.success(folderNotes)) } public func save(note: Note, completion: @escaping OperationCompletionBlock<Note>) { let savedNote = InMemoryNote(note: note) savedNote.identifier ??= UUID().uuidString savedNote.createdAt ??= Date() savedNote.content = note.content savedNote.modifiedAt = Date() notes[savedNote.identifier!] = savedNote completion(.success(savedNote)) NotificationCenter.default.post(name: .NoteWasUpdated, object: note) } } Creating a CloudKit version Our models will be backed by CKRecords, so our properties will be computed properties that read the fields defined on the record: class CloudKitNote : Note { static let RecordType = "Note" let record: CKRecord enum FieldKey : String { case content case folder } var content: String { get { return getField(.content) ?? "" } set { setField(.content, value: newValue) } } var folderIdentifier: String? { get { return getReference(.folder) } set { setReference(.folder, referenceIdentifier: newValue) } } func getField<T>(_ key: FieldKey) -> T? { return record[key.rawValue] as? T } func setField<T>(_ key: FieldKey, value: T?) { record[key.rawValue] = value as? CKRecordValue } func getReference(_ key: FieldKey) -> String? { let ref: CKReference? = getField(key) return ref?.recordID.recordName } func setReference(_ key: FieldKey, referenceIdentifier: String?) { let ref = referenceIdentifier.flatMap { id -> CKReference in let rid = CKRecordID(recordName: id) return CKReference(recordID: rid, action: .deleteSelf) } setField(key, value: ref) } convenience init(zoneID: CKRecordZoneID? = nil) { let recordID = CKRecordID(recordName: UUID().uuidString, zoneID: zoneID ?? CKRecordZone.default().zoneID) let record = CKRecord(recordType: CloudKitNote.RecordType, recordID: recordID) self.init(record: record) } init(note: Note) { record = CKRecord(recordType: CloudKitNote.RecordType) content = note.content folderIdentifier = note.folderIdentifier } required init(record: CKRecord) { self.record = record } } There's some bits we can clean up here, but we'll save that for later.