Руководство по CloudKit: как синхронизировать пользовательские данные между устройствами iOS

Опубликовано: 2022-03-11

В наши дни для разработки современных мобильных приложений требуется хорошо продуманный план синхронизации пользовательских данных на различных устройствах. Это сложная проблема со множеством подводных камней и ловушек, но пользователи ожидают, что эта функция будет работать хорошо.

Для iOS и macOS Apple предоставляет надежный набор инструментов под названием CloudKit API, который позволяет разработчикам, ориентированным на платформы Apple, решить эту проблему синхронизации.

В этой статье я покажу, как использовать CloudKit для синхронизации данных пользователя между несколькими клиентами. Он предназначен для опытных разработчиков iOS, которые уже знакомы с платформами Apple и Swift. Я собираюсь совершить довольно глубокое техническое погружение в API CloudKit, чтобы изучить способы использования этой технологии для создания потрясающих приложений для нескольких устройств. Я сосредоточусь на приложении для iOS, но тот же подход можно использовать и для клиентов macOS.

Наш пример использования — это простое приложение для заметок с одной заметкой для иллюстрации. Попутно я рассмотрю некоторые из более сложных аспектов облачной синхронизации данных, включая обработку конфликтов и непоследовательное поведение сетевого уровня.

Использование CloudKit для синхронизации пользовательских данных между несколькими клиентами

Что такое CloudKit?

CloudKit построен на базе службы Apple iCloud. Справедливо сказать, что iCloud стартовал с трудом. Неуклюжий переход с MobileMe, низкая производительность и даже некоторые проблемы с конфиденциальностью сдерживали развитие системы в ранние годы.

Для разработчиков приложений ситуация была еще хуже. До появления CloudKit непоследовательное поведение и слабые инструменты отладки делали практически невозможным выпуск высококачественного продукта с использованием API-интерфейсов iCloud первого поколения.

Однако со временем Apple решила эти проблемы. В частности, после выпуска CloudKit SDK в 2014 году сторонние разработчики получили полнофункциональное надежное техническое решение для обмена облачными данными между устройствами (включая приложения macOS и даже веб-клиенты).

Поскольку CloudKit тесно связан с операционными системами и устройствами Apple, он не подходит для приложений, требующих более широкой поддержки устройств, таких как клиенты Android или Windows. Однако для приложений, ориентированных на пользовательскую базу Apple, он предоставляет очень мощный механизм аутентификации пользователей и синхронизации данных.

Базовая настройка CloudKit

CloudKit организует данные с помощью иерархии классов: CKContainer , CKDatabase , CKRecordZone и CKRecord .

На верхнем уровне находится CKContainer , который инкапсулирует набор связанных данных CloudKit. Каждое приложение автоматически получает CKContainer по умолчанию, и группа приложений может совместно использовать настраиваемый CKContainer , если позволяют настройки разрешений. Это может обеспечить некоторые интересные рабочие процессы между приложениями.

В каждом CKContainer есть несколько экземпляров CKDatabase . CloudKit автоматически настраивает каждое приложение с поддержкой CloudKit из коробки, чтобы иметь общедоступную CKDatabase (все пользователи приложения могут видеть все) и частную CKDatabase (каждый пользователь видит только свои собственные данные). И, начиная с iOS 10, общая база данных CKDatabase , в которой группы, контролируемые пользователями, могут обмениваться элементами между членами группы.

Внутри CKDatabase находятся CKRecordZone s, а внутри зон CKRecord s. Вы можете читать и записывать записи, запрашивать записи, соответствующие набору критериев, и (что наиболее важно) получать уведомления об изменениях любого из вышеперечисленных.

Для вашего приложения Note вы можете использовать контейнер по умолчанию. В этом контейнере вы будете использовать частную базу данных (поскольку вы хотите, чтобы заметки пользователя были видны только этому пользователю), а внутри частной базы данных вы собираетесь использовать настраиваемую зону записи, которая позволяет получать уведомления о конкретных событиях. записывать изменения.

Заметка будет храниться как одна CKRecord с text полями, modified (DateTime) и полями version . CloudKit автоматически отслеживает внутреннее modified значение, но вы хотите знать фактическое время изменения, включая автономные случаи, для целей разрешения конфликтов. Поле version — это просто иллюстрация хорошей практики проверки обновлений с учетом того, что пользователь с несколькими устройствами не может обновлять ваше приложение на всех из них одновременно, поэтому есть некоторый призыв к защите.

Создание приложения для заметок

Я предполагаю, что вы хорошо разбираетесь в основах создания приложений iOS в Xcode. При желании вы можете загрузить и изучить пример проекта Note App Xcode, созданного для этого руководства.

Для наших целей будет достаточно одного приложения представления, содержащего UITextView с ViewController в качестве его делегата. На концептуальном уровне вы хотите инициировать обновление записи CloudKit при каждом изменении текста. Однако с практической точки зрения имеет смысл использовать какой-то механизм объединения изменений, например фоновый таймер, который периодически срабатывает, чтобы не загружать серверы iCloud слишком большим количеством крошечных изменений.

Приложение CloudKit требует включения нескольких элементов на панели возможностей Xcode Target: iCloud (естественно), включая флажок CloudKit, push-уведомления и фоновые режимы (в частности, удаленные уведомления).

Для функциональности CloudKit я разбил вещи на два класса: синглтон CloudKitNoteDatabase более низкого уровня и класс CloudKitNote более высокого уровня.

Но сначала краткое обсуждение ошибок CloudKit.

Ошибки облачного набора

Тщательная обработка ошибок абсолютно необходима для клиента CloudKit.

Поскольку это сетевой API, он подвержен целому ряду проблем с производительностью и доступностью. Кроме того, сама служба должна защищать от ряда потенциальных проблем, таких как несанкционированные запросы, конфликтующие изменения и тому подобное.

CloudKit предоставляет полный набор кодов ошибок с сопутствующей информацией, что позволяет разработчикам обрабатывать различные крайние случаи и, при необходимости, предоставлять пользователю подробные объяснения возможных проблем.

Кроме того, несколько операций CloudKit могут возвращать ошибку в виде одиночного значения ошибки или составной ошибки, обозначаемой на верхнем уровне как partialFailure . Он поставляется со словарем CKError , которые заслуживают более тщательного изучения, чтобы выяснить, что именно произошло во время составной операции.

Чтобы помочь справиться с этой сложностью, вы можете расширить CKError несколькими вспомогательными методами.

Обратите внимание, что весь код имеет пояснительные комментарии в ключевых точках.

 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

Apple предоставляет два уровня функциональности в CloudKit SDK: «удобные» функции высокого уровня, такие как fetch() , save() и delete() , и конструкции операций более низкого уровня с громоздкими именами, такими как CKModifyRecordsOperation .

Удобный API гораздо более доступен, в то время как подход к работе может быть немного пугающим. Однако Apple настоятельно рекомендует разработчикам использовать операции, а не удобные методы.

Операции CloudKit обеспечивают превосходный контроль над деталями того, как CloudKit выполняет свою работу, и, что, возможно, более важно, действительно заставляют разработчика тщательно продумывать поведение сети, которое играет ключевую роль во всем, что делает CloudKit. По этим причинам я использую операции в этих примерах кода.

Ваш одноэлементный класс будет отвечать за каждую из этих операций CloudKit, которые вы будете использовать. Фактически, в некотором смысле вы воссоздаете удобные API. Но, внедрив их самостоятельно на основе Operation API, вы окажетесь в хорошем положении для настройки поведения и настройки ответов на обработку ошибок. Например, если вы хотите расширить это приложение для обработки нескольких заметок, а не только одной, вы можете сделать это с большей готовностью (и с более высокой производительностью), чем если бы вы просто использовали удобные API-интерфейсы Apple.

 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 автоматически создает зону по умолчанию для частной базы данных. Однако вы можете получить больше функциональных возможностей, если используете настраиваемую зону, в первую очередь, поддержку выборки добавочных изменений записи.

Поскольку это первый пример использования операции, вот несколько общих замечаний:

Во-первых, все операции CloudKit имеют настраиваемые закрытия завершения (и многие из них имеют промежуточные закрытия, в зависимости от операции). CloudKit имеет свой собственный класс CKError , производный от Error , но вы должны знать о возможности возникновения других ошибок. Наконец, одним из наиболее важных аспектов любой операции является значение qualityOfService . Из-за задержки в сети, режима полета и т. д. CloudKit будет внутренне обрабатывать повторные попытки и т. д. для операций с qualityOfService «полезность» или ниже. В зависимости от контекста вы можете захотеть назначить более высокое qualityOfService и самостоятельно обрабатывать такие ситуации.

После настройки операции передаются объекту CKDatabase , где они будут выполняться в фоновом потоке.

 // 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) }

Создание подписки

Подписки — одна из самых ценных функций CloudKit. Они основаны на инфраструктуре уведомлений Apple, чтобы позволить различным клиентам получать push-уведомления, когда происходят определенные изменения CloudKit. Это могут быть обычные push-уведомления, знакомые пользователям iOS (такие как звук, баннер или значок), или в CloudKit это может быть особый класс уведомлений, называемый тихими push-уведомлениями. Эти тихие push-уведомления происходят полностью незаметно для пользователя и не взаимодействуют с ним, и в результате пользователю не требуется включать push-уведомления для вашего приложения, что избавляет вас как разработчика от многих потенциальных головных болей, связанных с взаимодействием с пользователем.

Способ включить эти тихие уведомления — установить свойство shouldSendContentAvailable в экземпляре CKNotificationInfo , оставив все традиционные параметры уведомлений ( shouldBadge , soundName и т. д.) неустановленными.

Также обратите внимание, что я использую CKQuerySubscription с очень простым предикатом «всегда верно», чтобы следить за изменениями в одной (и единственной) записи Note. В более сложном приложении вы можете воспользоваться предикатом, чтобы сузить область действия определенного CKQuerySubscription , и вы можете просмотреть другие типы подписки, доступные в CloudKit, такие как CKDatabaseSuscription .

Наконец, обратите внимание, что вы можете использовать кэшированное значение UserDefaults , чтобы избежать ненужного сохранения подписки более одного раза. В его установке нет большого вреда, но Apple рекомендует избегать этого, поскольку это тратит ресурсы сети и сервера.

 // 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 subscription 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) }

Загрузка записей

Получить запись по имени очень просто. Вы можете думать об имени как о первичном ключе записи в простом смысле базы данных (например, имена должны быть уникальными). Фактический CKRecordID немного сложнее, так как он включает zoneID .

CKFetchRecordsOperation работает с одной или несколькими записями одновременно. В этом примере есть только одна запись, но для будущего расширения это большое потенциальное преимущество в производительности.

 // 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) }

Сохранение записей

Сохранение записей, пожалуй, самая сложная операция. Простой акт записи записи в базу данных достаточно прост, но в моем примере с несколькими клиентами именно здесь вы столкнетесь с потенциальной проблемой обработки конфликта, когда несколько клиентов пытаются одновременно писать на сервер. К счастью, CloudKit специально предназначен для обработки этого условия. Он отклоняет определенные запросы с достаточным контекстом ошибки в ответе, чтобы позволить каждому клиенту принять локальное, информированное решение о том, как разрешить конфликт.

Хотя это усложняет работу клиента, в конечном счете это гораздо лучшее решение, чем использование Apple одного из нескольких механизмов разрешения конфликтов на стороне сервера.

Разработчик приложения всегда лучше всех может определить правила для таких ситуаций, которые могут включать в себя все, от контекстно-зависимого автоматического слияния до указаний пользователя по разрешению. Я не буду сильно увлекаться своим примером; Я использую modified поле, чтобы объявить, что самое последнее обновление побеждает. Это не всегда может быть лучшим результатом для профессиональных приложений, но это неплохо для первого правила и служит для иллюстрации механизма, с помощью которого CloudKit передает информацию о конфликте обратно клиенту.

Обратите внимание, что в моем примерном приложении этот шаг разрешения конфликта происходит в классе CloudKitNote , описанном ниже.

 // 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 позволяют узнать, когда записи были обновлены другим клиентом. Однако условия сети и ограничения производительности могут привести к удалению отдельных уведомлений или намеренному объединению нескольких уведомлений в одно уведомление клиента. Поскольку уведомления CloudKit построены поверх системы уведомлений iOS, вы должны следить за этими условиями.

Однако CloudKit предоставляет вам необходимые для этого инструменты.

Вместо того, чтобы полагаться на отдельные уведомления, чтобы получить подробные сведения об изменении, которое представляет собой отдельное уведомление, вы используете уведомление, чтобы просто указать, что что- то изменилось, а затем вы можете спросить CloudKit, что изменилось с момента вашего последнего запроса. В моем примере я делаю это с помощью CKFetchRecordZoneChangesOperation и CKServerChangeTokens . Жетоны изменений можно рассматривать как закладки, говорящие вам, где вы были до того, как произошла самая последняя последовательность изменений.

 // Handle receipt of an incoming 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) }

Теперь у вас есть строительные блоки низкого уровня для чтения и записи записей, а также для обработки уведомлений об изменениях записей.

Давайте теперь посмотрим на слой, построенный поверх этого, для управления этими операциями в контексте конкретной заметки.

Класс CloudKitNote

Для начала можно определить несколько пользовательских ошибок, чтобы защитить клиента от внутренних компонентов CloudKit, а простой протокол делегирования может информировать клиента об удаленных обновлениях базовых данных Note.

 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 с заметкой

В Swift к отдельным полям в CKRecord можно получить доступ через оператор индекса. Все значения соответствуют CKRecordValue , но они, в свою очередь, всегда являются одним из определенного подмножества знакомых типов данных: NSString , NSNumber , NSDate и так далее.

Кроме того, CloudKit предоставляет специальный тип записи для «больших» двоичных объектов. Конкретная точка отсечки не указана (рекомендуется максимум 1 МБ для каждой CKRecord ), но, как правило, практически все, что кажется независимым элементом (изображение, звук, фрагмент текста), а не как поле базы данных, вероятно, должно храниться как CKAsset . Эта практика позволяет CloudKit лучше управлять сетевой передачей и хранением этих типов элементов на стороне сервера.

В этом примере вы будете использовать CKAsset для хранения текста заметки. Данные CKAsset обрабатываются через локальные временные файлы, содержащие соответствующие данные.

 // 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) } }

Загрузка заметки

Загрузка заметки очень проста. Вы выполняете необходимую проверку ошибок, а затем просто извлекаете фактические данные из CKRecord и сохраняете значения в своих полях-членах.

 // 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) } }

Сохранение заметки и разрешение потенциального конфликта

Есть несколько особых ситуаций, о которых следует помнить при сохранении заметки.

Во-первых, вам нужно убедиться, что вы начинаете с действительного CKRecord . Вы спрашиваете CloudKit, есть ли уже запись, и если нет, вы создаете новую локальную CKRecord для последующего сохранения.

Когда вы просите CloudKit сохранить запись, именно здесь вам, возможно, придется обрабатывать конфликт из-за того, что другой клиент обновил запись с момента последнего ее извлечения. Предвидя это, разделите функцию сохранения на два шага. На первом этапе выполняется однократная настройка при подготовке к записи записи, а на втором шаге собранная запись передается одноэлементному классу CloudKitNoteDatabase . Этот второй шаг может быть повторен в случае конфликта.

В случае конфликта CloudKit предоставляет вам в возвращаемом CKError три полных CKRecord для работы:

  1. Предыдущая версия записи, которую вы пытались сохранить,
  2. Точная версия записи, которую вы пытались сохранить,
  3. Версия, хранящаяся на сервере на момент отправки запроса.

Глядя на modified поля этих записей, вы можете решить, какая запись появилась первой и, следовательно, какие данные сохранить. При необходимости вы затем передаете обновленную запись сервера в CloudKit для записи новой записи. Конечно, это может привести к еще одному конфликту (если между ними возникнет еще одно обновление), но тогда вы просто повторяете процесс, пока не получите успешный результат.

В этом простом приложении Note с одним пользователем, переключающимся между устройствами, вы вряд ли увидите слишком много конфликтов в смысле «живого параллелизма». Однако такие конфликты могут возникать и из-за других обстоятельств. Например, пользователь мог внести изменения на одном устройстве в режиме полета, а затем по рассеянности внести другие изменения на другом устройстве, прежде чем отключить режим полета на первом устройстве.

В облачных приложениях для обмена данными чрезвычайно важно учитывать все возможные сценарии.

 // 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) } }

Обработка уведомления об удаленно измененной заметке

Когда приходит уведомление об изменении записи, CloudKitNoteDatabase выполнит тяжелую работу по извлечению изменений из CloudKit. В этом примере это будет только одна запись заметки, но нетрудно понять, как ее можно расширить до ряда различных типов записей и экземпляров.

Например, я включил базовую проверку работоспособности, чтобы убедиться, что я обновляю правильную запись, а затем обновил поля и уведомил делегата о том, что у нас есть новые данные.

 // 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.

Ну вот! 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.

CloudKit Solves The Synchronization Problem

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 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.

Related: Swift Tutorial: An Introduction to the MVVM Design Pattern