Um guia para o CloudKit: como sincronizar dados do usuário em dispositivos iOS

Publicados: 2022-03-11

Atualmente, o desenvolvimento de aplicativos móveis modernos exige um plano bem pensado para manter os dados do usuário sincronizados em vários dispositivos. Este é um problema espinhoso com muitas armadilhas e armadilhas, mas os usuários esperam que o recurso funcione bem.

Para iOS e macOS, a Apple fornece um kit de ferramentas robusto, chamado CloudKit API, que permite que desenvolvedores direcionados às plataformas da Apple resolvam esse problema de sincronização.

Neste artigo, demonstrarei como usar o CloudKit para manter os dados de um usuário sincronizados entre vários clientes. Destina-se a desenvolvedores iOS experientes que já estão familiarizados com os frameworks da Apple e com o Swift. Vou fazer um mergulho técnico bastante profundo na API do CloudKit para explorar maneiras de aproveitar essa tecnologia para criar aplicativos incríveis para vários dispositivos. Vou me concentrar em um aplicativo iOS, mas a mesma abordagem também pode ser usada para clientes macOS.

Nosso caso de uso de exemplo é um aplicativo de nota simples com apenas uma nota, para fins de ilustração. Ao longo do caminho, examinarei alguns dos aspectos mais complicados da sincronização de dados baseada em nuvem, incluindo manipulação de conflitos e comportamento inconsistente da camada de rede.

Usando o CloudKit para sincronizar dados do usuário entre vários clientes

O que é o CloudKit?

CloudKit é construído sobre o serviço iCloud da Apple. É justo dizer que o iCloud teve um começo meio difícil. Uma transição desajeitada do MobileMe, baixo desempenho e até algumas preocupações com a privacidade impediram o sistema nos primeiros anos.

Para os desenvolvedores de aplicativos, a situação era ainda pior. Antes do CloudKit, o comportamento inconsistente e as ferramentas de depuração fracas tornavam quase impossível entregar um produto de alta qualidade usando as APIs do iCloud de primeira geração.

Com o tempo, no entanto, a Apple abordou esses problemas. Em particular, após o lançamento do CloudKit SDK em 2014, os desenvolvedores de terceiros têm uma solução técnica robusta e completa para compartilhamento de dados baseado em nuvem entre dispositivos (incluindo aplicativos macOS e até clientes baseados na web).

Como o CloudKit está profundamente ligado aos sistemas operacionais e dispositivos da Apple, ele não é adequado para aplicativos que exigem uma gama mais ampla de suporte a dispositivos, como clientes Android ou Windows. No entanto, para aplicativos direcionados à base de usuários da Apple, ele fornece um mecanismo extremamente poderoso para autenticação de usuários e sincronização de dados.

Configuração básica do CloudKit

O CloudKit organiza os dados por meio de uma hierarquia de classes: CKContainer , CKDatabase , CKRecordZone e CKRecord .

No nível superior está o CKContainer , que encapsula um conjunto de dados relacionados do CloudKit. Cada aplicativo obtém automaticamente um CKContainer padrão e um grupo de aplicativos pode compartilhar um CKContainer personalizado se as configurações de permissão permitirem. Isso pode habilitar alguns fluxos de trabalho interessantes entre aplicativos.

Dentro de cada CKContainer existem várias instâncias de CKDatabase . O CloudKit configura automaticamente cada aplicativo habilitado para CloudKit pronto para uso para ter um CKDatabase público (todos os usuários do aplicativo podem ver tudo) e um CKDatabase privado (cada usuário vê apenas seus próprios dados). E, a partir do iOS 10, um CKDatabase compartilhado onde grupos controlados por usuários podem compartilhar itens entre os membros do grupo.

Dentro de um CKDatabase estão CKRecordZone s, e dentro de zonas CKRecord s. Você pode ler e gravar registros, consultar registros que correspondam a um conjunto de critérios e (o mais importante) receber notificação de alterações em qualquer um dos itens acima.

Para seu aplicativo Note, você pode usar o contêiner padrão. Dentro desse container, você usará o banco de dados privado (porque deseja que a nota do usuário seja vista apenas por esse usuário) e dentro do banco de dados privado, você usará uma zona de registro personalizada, que permite a notificação de registrar alterações.

A Nota será armazenada como um único CKRecord com campos text , modified (DateTime) e version . O CloudKit rastreia automaticamente um valor modified interno, mas você deseja saber o tempo real de modificação, incluindo casos offline, para fins de resolução de conflitos. O campo de version é simplesmente uma ilustração de boa prática para verificação de atualização, tendo em mente que um usuário com vários dispositivos pode não atualizar seu aplicativo em todos eles ao mesmo tempo, portanto, há uma necessidade de defesa.

Criando o aplicativo de notas

Suponho que você tenha um bom domínio sobre o básico da criação de aplicativos iOS no Xcode. Se desejar, você pode baixar e examinar o exemplo do projeto Note App Xcode criado para este tutorial.

Para nossos propósitos, um único aplicativo de visualização contendo um UITextView com o ViewController como seu delegado será suficiente. No nível conceitual, você deseja acionar uma atualização de registro do CloudKit sempre que o texto for alterado. No entanto, na prática, faz sentido usar algum tipo de mecanismo de coalescência de alterações, como um temporizador em segundo plano que é acionado periodicamente, para evitar enviar spam aos servidores do iCloud com muitas pequenas alterações.

O aplicativo CloudKit requer que alguns itens sejam habilitados no painel de recursos do Xcode Target: iCloud (naturalmente), incluindo a caixa de seleção CloudKit, notificações push e modos de segundo plano (especificamente, notificações remotas).

Para a funcionalidade CloudKit, dividi as coisas em duas classes: Um singleton CloudKitNoteDatabase nível inferior e uma classe CloudKitNote de nível superior.

Mas primeiro, uma rápida discussão sobre erros do CloudKit.

Erros do CloudKit

O tratamento cuidadoso de erros é absolutamente essencial para um cliente CloudKit.

Como é uma API baseada em rede, é suscetível a uma série de problemas de desempenho e disponibilidade. Além disso, o próprio serviço deve proteger contra uma série de possíveis problemas, como solicitações não autorizadas, alterações conflitantes e similares.

O CloudKit fornece uma gama completa de códigos de erro, com informações de acompanhamento, para permitir que os desenvolvedores lidem com vários casos extremos e, quando necessário, forneçam explicações detalhadas ao usuário sobre possíveis problemas.

Além disso, várias operações do CloudKit podem retornar um erro como um valor de erro único ou um erro composto indicado no nível superior como partialFailure . Ele vem com um Dicionário de CKError contidos que merecem uma inspeção mais cuidadosa para descobrir o que exatamente aconteceu durante uma operação composta.

Para ajudar a navegar um pouco dessa complexidade, você pode estender o CKError com alguns métodos auxiliares.

Observe que todo o código tem comentários explicativos nos pontos-chave.

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

O Singleton CloudKitNoteDatabase

A Apple fornece dois níveis de funcionalidade no SDK do CloudKit: funções de “conveniência” de alto nível, como fetch() , save() e delete() , e construções de operação de nível inferior com nomes complicados, como CKModifyRecordsOperation .

A API de conveniência é muito mais acessível, enquanto a abordagem de operação pode ser um pouco intimidante. No entanto, a Apple exorta fortemente os desenvolvedores a usar as operações em vez dos métodos de conveniência.

As operações do CloudKit fornecem controle superior sobre os detalhes de como o CloudKit faz seu trabalho e, talvez mais importante, realmente forçam o desenvolvedor a pensar cuidadosamente sobre os comportamentos de rede centrais em tudo o que o CloudKit faz. Por esses motivos, estou usando as operações nesses exemplos de código.

Sua classe singleton será responsável por cada uma dessas operações do CloudKit que você usará. Na verdade, de certa forma, você está recriando as APIs de conveniência. Mas, ao implementá-los você mesmo com base na API de operação, você se coloca em um bom lugar para personalizar o comportamento e ajustar suas respostas de tratamento de erros. Por exemplo, se você quiser estender este aplicativo para lidar com várias notas em vez de apenas uma, poderá fazê-lo mais prontamente (e com maior desempenho resultante) do que se tivesse usado as APIs de conveniência da 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? // ... }

Criando uma zona personalizada

O CloudKit cria automaticamente uma zona padrão para o banco de dados privado. No entanto, você pode obter mais funcionalidades se usar uma zona personalizada, principalmente suporte para buscar alterações de registro incremental.

Como este é um primeiro exemplo de uso de uma operação, aqui estão algumas observações gerais:

Primeiro, todas as operações do CloudKit têm fechamentos de conclusão personalizados (e muitos têm fechamentos intermediários, dependendo da operação). CloudKit tem sua própria classe CKError , derivada de Error , mas você precisa estar ciente da possibilidade de que outros erros estejam ocorrendo também. Finalmente, um dos aspectos mais importantes de qualquer operação é o valor qualityOfService . Devido à latência da rede, modo avião e outros, o qualityOfService tratará internamente as tentativas e outras operações em uma qualidade de serviço de “utilidade” ou inferior. Dependendo do contexto, você pode querer atribuir um qualityOfService mais alto e lidar com essas situações você mesmo.

Uma vez configuradas, as operações são passadas para o objeto CKDatabase , onde serão executadas em um thread em segundo plano.

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

Criando uma assinatura

As assinaturas são um dos recursos mais valiosos do CloudKit. Eles se baseiam na infraestrutura de notificação da Apple para permitir que vários clientes recebam notificações push quando ocorrem determinadas alterações do CloudKit. Podem ser notificações push normais familiares aos usuários do iOS (como som, banner ou emblema) ou, no CloudKit, podem ser uma classe especial de notificação chamada pushes silenciosos . Esses pushes silenciosos acontecem inteiramente sem visibilidade ou interação do usuário e, como resultado, não exigem que o usuário ative a notificação por push para seu aplicativo, economizando muitas dores de cabeça em potencial na experiência do usuário como desenvolvedor de aplicativos.

A maneira de habilitar essas notificações silenciosas é definir a propriedade shouldSendContentAvailable na instância CKNotificationInfo , deixando todas as configurações de notificação tradicionais ( shouldBadge , soundName e assim por diante) indefinidas.

Observe também que estou usando um CKQuerySubscription com um predicado “sempre verdadeiro” muito simples para observar alterações no único (e único) registro Note. Em um aplicativo mais sofisticado, você pode aproveitar o predicado para restringir o escopo de um CKQuerySubscription específico e revisar os outros tipos de assinatura disponíveis no CloudKit, como CKDatabaseSuscription .

Por fim, observe que você pode usar um valor armazenado em cache UserDefaults para evitar salvar desnecessariamente a assinatura mais de uma vez. Não há grandes danos em configurá-lo, mas a Apple recomenda fazer um esforço para evitar isso, pois desperdiça recursos de rede e servidor.

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

Carregando registros

Buscar um registro pelo nome é muito simples. Você pode pensar no nome como a chave primária do registro em um sentido simples de banco de dados (os nomes devem ser exclusivos, por exemplo). O CKRecordID real é um pouco mais complicado, pois inclui o zoneID .

O CKFetchRecordsOperation opera em um ou mais registros por vez. Neste exemplo, há apenas um registro, mas para expansibilidade futura, esse é um grande benefício de desempenho em potencial.

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

Salvando registros

Salvar registros é, talvez, a operação mais complicada. O simples ato de gravar um registro no banco de dados é bastante direto, mas no meu exemplo, com vários clientes, é aqui que você enfrentará o possível problema de lidar com um conflito quando vários clientes tentarem gravar no servidor simultaneamente. Felizmente, o CloudKit foi projetado explicitamente para lidar com essa condição. Ele rejeita solicitações específicas com contexto de erro suficiente na resposta para permitir que cada cliente tome uma decisão local e esclarecida sobre como resolver o conflito.

Embora isso acrescente complexidade ao cliente, em última análise, é uma solução muito melhor do que a Apple criar um dos poucos mecanismos do lado do servidor para resolução de conflitos.

O designer de aplicativos está sempre na melhor posição para definir regras para essas situações, que podem incluir desde a mesclagem automática com reconhecimento de contexto até instruções de resolução direcionadas ao usuário. Não vou ficar muito chique no meu exemplo; Estou usando o campo modified para declarar que a atualização mais recente vence. Isso pode nem sempre ser o melhor resultado para aplicativos profissionais, mas não é ruim para uma primeira regra e, para isso, serve para ilustrar o mecanismo pelo qual o CloudKit passa informações de conflito de volta ao cliente.

Observe que, no meu aplicativo de exemplo, essa etapa de resolução de conflitos acontece na classe CloudKitNote , descrita posteriormente.

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

Manipulação de Notificação de Registros Atualizados

As notificações do CloudKit fornecem os meios para descobrir quando os registros foram atualizados por outro cliente. No entanto, as condições de rede e as restrições de desempenho podem fazer com que notificações individuais sejam descartadas ou que várias notificações se unam intencionalmente em uma única notificação de cliente. Como as notificações do CloudKit são criadas com base no sistema de notificação do iOS, você deve estar atento a essas condições.

No entanto, o CloudKit oferece as ferramentas necessárias para isso.

Em vez de depender de notificações individuais para fornecer um conhecimento detalhado de qual alteração uma notificação individual representa, você usa uma notificação para simplesmente indicar que algo mudou e, em seguida, pode perguntar ao CloudKit o que mudou desde a última vez que você perguntou. No meu exemplo, faço isso usando CKFetchRecordZoneChangesOperation e CKServerChangeTokens . Os tokens de alteração podem ser considerados como um marcador informando onde você estava antes da sequência mais recente de alterações.

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

Agora você tem os blocos de construção de baixo nível para ler e gravar registros e para lidar com notificações de alterações de registro.

Vejamos agora uma camada construída sobre ela para gerenciar essas operações no contexto de uma Nota específica.

A classe CloudKitNote

Para começar, alguns erros personalizados podem ser definidos para proteger o cliente das partes internas do CloudKit, e um protocolo de delegação simples pode informar o cliente sobre atualizações remotas dos dados subjacentes do 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... } // … }

Mapeamento de CKRecord para nota

Em Swift, campos individuais em um CKRecord podem ser acessados ​​através do operador subscript. Todos os valores estão em conformidade com CKRecordValue , mas estes, por sua vez, são sempre um de um subconjunto específico de tipos de dados familiares: NSString , NSNumber , NSDate e assim por diante.

Além disso, o CloudKit fornece um tipo de registro específico para objetos binários “grandes”. Nenhum ponto de corte específico é especificado (um máximo de 1 MB no total é recomendado para cada CKRecord ), mas como regra geral, praticamente qualquer coisa que pareça um item independente (uma imagem, um som, um blob de texto) em vez de um campo de banco de dados provavelmente deve ser armazenado como um CKAsset . Essa prática permite que o CloudKit gerencie melhor a transferência de rede e o armazenamento do lado do servidor desses tipos de itens.

Para este exemplo, você usará o CKAsset para armazenar o texto da nota. Os dados CKAsset são tratados por meio de arquivos temporários locais contendo os dados correspondentes.

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

Carregando uma nota

Carregar uma nota é muito simples. Você faz um pouco de verificação de erros necessária e, em seguida, simplesmente busca os dados reais do CKRecord e armazena os valores em seus campos de membro.

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

Salvando uma Nota e Resolvendo Potenciais Conflitos

Há algumas situações especiais a serem observadas ao salvar uma nota.

Primeiro, você precisa ter certeza de que está começando com um CKRecord válido. Você pergunta ao CloudKit se já existe um registro lá e, caso contrário, cria um novo CKRecord local para usar no salvamento subsequente.

Quando você pede ao CloudKit para salvar o registro, é aqui que você pode ter que lidar com um conflito devido a outro cliente atualizar o registro desde a última vez que você o buscou. Em antecipação a isso, divida a função salvar em duas etapas. A primeira etapa faz uma configuração única em preparação para gravar o registro e a segunda etapa passa o registro montado para a classe CloudKitNoteDatabase singleton. Este segundo passo pode ser repetido em caso de conflito.

Em caso de conflito, o CloudKit fornece, no CKError retornado, três CKRecord completos para trabalhar:

  1. A versão anterior do registro que você tentou salvar,
  2. A versão exata do registro que você tentou salvar,
  3. A versão mantida pelo servidor no momento em que você enviou a solicitação.

Observando os campos modified desses registros, você pode decidir qual registro ocorreu primeiro e, portanto, quais dados manter. Se necessário, você passa o registro do servidor atualizado para o CloudKit para gravar o novo registro. Claro, isso pode resultar em mais um conflito (se outra atualização ocorrer no meio), mas você apenas repete o processo até obter um resultado bem-sucedido.

Neste aplicativo Note simples, com um único usuário alternando entre dispositivos, você provavelmente não verá muitos conflitos no sentido de “simultaneidade ao vivo”. No entanto, tais conflitos podem surgir de outras circunstâncias. Por exemplo, um usuário pode ter feito edições em um dispositivo enquanto estava no modo avião e, em seguida, distraidamente, fez edições diferentes em outro dispositivo antes de desativar o modo avião no primeiro dispositivo.

Em aplicativos de compartilhamento de dados baseados em nuvem, é extremamente importante estar atento a todos os cenários possíveis.

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

Manipulando a Notificação de uma Nota Alterada Remotamente

Quando chega uma notificação de que um registro foi alterado, o CloudKitNoteDatabase fará o trabalho pesado de buscar as alterações do CloudKit. Neste caso de exemplo, será apenas um registro de nota, mas não é difícil ver como isso pode ser estendido para uma variedade de diferentes tipos e instâncias de registro.

Para fins de exemplo, incluí uma verificação de sanidade básica para garantir que estou atualizando o registro correto e, em seguida, atualize os campos e notifique o delegado de que temos novos dados.

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

Ai está! 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