
This video is only available to subscribers. Start a subscription today to get access to this and 469 other videos.
Isolating CloudKit from your Controllers
This episode is part of a series: Hello CloudKit.
1. Hello Cloud Kit - Part 1 10 min |
2. Hello Cloud Kit - Part 2 10 min |
3. CloudKit Querying 12 min |
4. CloudKit References 7 min |
5. Fetching and Saving References 15 min |
6. Working with Images 15 min |
7. Fetching Paged Images 8 min |
8. CKRecord Upload Progress 6 min |
9. Isolating CloudKit from your Controllers 16 min |
10. Extracting CKRecordWrapper 7 min |
11. CloudKit Notes Manager 11 min |
12. CloudKit Notes Manager Continued 14 min |
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 CKRecord
s, 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.