Isolating CloudKit from your Controllers

Episode #274 | 16 minutes | published on June 2, 2017 | Uses iOS-10.3, Xcode-8.3
Subscribers Only
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

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.

blog comments powered by Disqus