Hoje em dia, o desenvolvimento de aplicativos móveis modernos requer um plano bem elaborado para manter os dados do usuário sincronizados em vários dispositivos. Esse é um problema espinhoso com muitas pegadinhas e armadilhas, mas os usuários esperam que o recurso funcione bem.
Para iOS e macOS, a Apple oferece um kit de ferramentas robusto, chamado API CloudKit , que permite aos desenvolvedores direcionados às plataformas Apple resolver esse problema de sincronização.
Neste artigo, vou demonstrar como usar o CloudKit para manter os dados de um usuário sincronizados entre vários clientes. É destinado a desenvolvedores iOS experientes que já estão familiarizados com as estruturas da Apple e com o Swift. Vou dar um mergulho técnico bastante profundo na API CloudKit para explorar maneiras de aproveitar essa tecnologia para fazer aplicativos incríveis para vários dispositivos. Vou me concentrar em um aplicativo iOS, mas a mesma abordagem pode ser usada para clientes macOS também.
Nosso caso de uso de exemplo é um aplicativo de nota simples com apenas uma única nota, para fins de ilustração. Ao longo do caminho, darei uma olhada em alguns dos aspectos mais complicados da sincronização de dados baseada em nuvem, incluindo tratamento de conflitos e comportamento inconsistente da camada de rede.
O CloudKit é baseado no serviço iCloud da Apple. É justo dizer que o iCloud teve um começo um pouco difícil. Uma transição desajeitada de MobileMe , baixo desempenho e até mesmo algumas preocupações com a privacidade atrasaram o sistema nos primeiros anos.
Para 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 resolveu esses problemas. Em particular, após o lançamento do CloudKit SDK em 2014 , os desenvolvedores terceirizados têm uma solução técnica robusta e cheia de recursos para compartilhamento de dados com base na nuvem entre dispositivos (incluindo aplicativos macOS e até mesmo clientes baseados na Web).
Uma vez que o CloudKit está profundamente ligado aos sistemas operacionais e dispositivos da Apple, não é adequado para aplicativos que requerem uma gama mais ampla de suporte de dispositivo, como clientes Android ou Windows. Para aplicativos que são direcionados à base de usuários da Apple, no entanto, ele fornece um mecanismo profundamente poderoso para autenticação de usuário e sincronização de dados.
O CloudKit organiza os dados por meio de uma hierarquia de classes: CKContainer
, CKDatabase
, CKRecordZone
e CKRecord
.
No nível superior está CKContainer
, que encapsula um conjunto de dados CloudKit relacionados. 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 permitir alguns fluxos de trabalho interessantes entre aplicativos.
Dentro de cada CKContainer
são 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 privado CKDatabase
(cada usuário vê apenas seus próprios dados). E, a partir do iOS 10, um CKDatabase
compartilhado | onde grupos controlados pelo usuário podem compartilhar itens entre os membros do grupo.
Dentro de um CKDatabase
são CKRecordZone
s, e dentro das 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ções de alterações em qualquer um dos itens acima.
Para o aplicativo Note, você pode usar o contêiner padrão. Dentro desse contêiner, você usará o banco de dados privado (porque deseja que a nota do usuário seja vista apenas por aquele usuário) e dentro do banco de dados privado, você usará uma zona de registro personalizada, que permite a notificação de registrar as alterações.
A nota será armazenada como um único CKRecord
com text
, modified
(DateTime) e version
Campos. CloudKit rastreia automaticamente um interno modified
valor, mas você deseja saber o tempo real de modificação, incluindo casos off-line, para fins de resolução de conflitos. O version
O campo é simplesmente uma ilustração de uma boa prática para a 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á alguma necessidade de defesa.
Estou assumindo que você tem um bom conhecimento sobre os fundamentos da criação de aplicativos iOS no Xcode. Se você deseja, você pode baixar e examine 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 muda. No entanto, por uma questão prática, faz sentido usar algum tipo de mecanismo de coalescência de mudança, como um cronômetro de fundo que dispara periodicamente, para evitar o spam dos servidores 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 Fundo (especificamente, notificações remotas).
Para a funcionalidade do CloudKit, dividi as coisas em duas classes: Um nível inferior CloudKitNoteDatabase
singleton e um nível superior CloudKitNote
classe.
Mas, primeiro, uma rápida discussão sobre os erros do CloudKit.
O tratamento cuidadoso de erros é absolutamente essencial para um cliente CloudKit.
Por ser 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 problemas potenciais, como solicitações não autorizadas, alterações conflitantes e assim por diante.
CloudKit fornece uma gama completa de códigos de erro , com informações que acompanham, para permitir que os desenvolvedores lidem com vários casos extremos e, quando necessário, forneça 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 único valor de erro ou um erro composto representado no nível superior como partialFailure
. Ele vem com um Dicionário de CKError
s 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 CKError
com alguns métodos auxiliares.
Observe que todo o código contém comentários explicativos sobre os pontos-chave.
import CloudKit extension CKError { public func isRecordNotFound() -> Bool return isZoneNotFound() 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
SingletonA Apple fornece dois níveis de funcionalidade no CloudKit SDK: 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 recomenda fortemente que os desenvolvedores usem as operações em vez dos métodos de conveniência.
aprenda rápido ou objetivo c
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 para tudo 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 CloudKit que você usará. Na verdade, de certa forma, você está recriando as APIs de conveniência. Mas, ao implementá-los 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ê deseja estender este aplicativo para lidar com várias notas em vez de apenas uma, você poderia fazer isso mais prontamente (e com maior desempenho resultante) do que se tivesse apenas usado 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? // ... }
CloudKit cria automaticamente uma zona padrão para o banco de dados privado. No entanto, você pode obter mais funcionalidade se usar uma zona personalizada, mais notavelmente, o suporte para buscar alterações de registro incrementais.
Como este é o primeiro exemplo de como usar uma operação, aqui estão algumas observações gerais:
Primeiro, todas as operações do CloudKit possuem fechamentos de conclusão personalizados (e muitos possuem fechamentos intermediários, dependendo da operação). CloudKit tem seu próprio CKError
, derivada de Error
, mas você precisa estar ciente da possibilidade de que outros erros também estejam ocorrendo. Finalmente, um dos aspectos mais importantes de qualquer operação é o qualityOfService
valor. Devido à latência da rede, modo avião e outros, o CloudKit tratará internamente novas tentativas e outros itens para operações em um qualityOfService
de “utilidade” ou inferior. Dependendo do contexto, você pode desejar atribuir um maior qualityOfService
e lidar com essas situações você mesmo.
Depois de configuradas, as operações são passadas para o CKDatabase
objeto, onde eles serão executados em um thread de 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) }
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 ocorrerem determinadas alterações do CloudKit. Estas podem ser notificações push normais familiares aos usuários iOS (como som, banner ou emblema) ou, no CloudKit, podem ser uma classe especial de notificação chamada empurrões silenciosos . Esses envios silenciosos acontecem inteiramente sem a visibilidade ou interação do usuário e, como resultado, não exigem que o usuário habilite a notificação por push para o seu aplicativo, poupando muitas dores de cabeça potenciais de experiência do usuário como desenvolvedor de aplicativos.
A maneira de habilitar essas notificações silenciosas é definir o shouldSendContentAvailable
propriedade no CKNotificationInfo
exemplo, deixando todas as configurações de notificação tradicionais (shouldBadge
, soundName
e assim por diante) não definidas.
Observe também que estou usando um CKQuerySubscription
com um predicado “sempre verdadeiro” muito simples para observar as mudanças em um (e único) registro de nota. Em um aplicativo mais sofisticado, você pode desejar aproveitar as vantagens do predicado para restringir o escopo de um determinado CKQuerySubscription
e pode desejar revisar os outros tipos de assinatura disponíveis no CloudKit, como CKDatabaseSuscription
.
Finalmente, observe que você pode usar um UserDefaults
valor armazenado em cache para evitar salvar desnecessariamente a assinatura mais de uma vez. Não há grande mal em configurá-lo, mas a Apple recomenda fazer um esforço para evitar isso, já que 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 subscriptionID = 'cloudkit-note-changes' 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) }
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 real CKRecordID
é um pouco mais complicado porque inclui zoneID
.
O CKFetchRecordsOperation
opera em um ou mais registros por vez. Neste exemplo, há apenas um registro, mas para expansibilidade futura, este é um grande benefício potencial de desempenho.
// 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) }
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 problema potencial 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 do aplicativo está sempre na melhor posição para definir regras para essas situações, que podem incluir tudo, desde mesclagem automática com base no contexto até instruções de resolução direcionadas ao usuário. Não vou ser muito elaborado com meu exemplo; Estou usando o modified
campo 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 essa finalidade, serve para ilustrar o mecanismo pelo qual o CloudKit passa informações de conflito de volta para o cliente.
Observe que, em meu aplicativo de exemplo, essa etapa de resolução de conflito acontece no CloudKitNote
classe, descrita mais tarde.
// 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) }
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 do cliente. Uma vez que as notificações do CloudKit são construídas sobre o sistema de notificação do iOS, você deve estar atento a essas condições.
No entanto, o CloudKit oferece as ferramentas de que você precisa para isso.
Em vez de depender de notificações individuais para fornecer conhecimento detalhado de quais mudanças uma notificação individual representa, você usa uma notificação para simplesmente indicar que alguma coisa mudou, e então você 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 que informa onde você estava antes de ocorrer a sequência de alterações mais recente.
// 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 em cima disso para gerenciar essas operações no contexto de uma nota específica.
CloudKitNote
ClassePara começar, alguns erros personalizados podem ser definidos para proteger o cliente dos componentes internos do CloudKit, e um protocolo de delegação simples pode informar o cliente sobre atualizações remotas nos dados da nota subjacentes.
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
anotarEm Swift, campos individuais em um CKRecord
pode ser acessado por meio do operador subscrito. Todos os valores estão em conformidade com CKRecordValue
, mas estes, por sua vez, são sempre parte 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, quase tudo que parece 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á CKAsset
para armazenar o texto da nota. CKAsset
os dados 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) } }
Carregar uma nota é muito simples. Você faz um pouco de verificação de erro de requisito e, em seguida, simplesmente busca os dados reais de CKRecord
e armazene 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) } }
Há algumas situações especiais a serem observadas ao salvar uma nota.
Em primeiro lugar, 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, se não, você cria um novo local CKRecord
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 atualizando o registro desde a última vez que você o buscou. Antes disso, divida a função salvar em duas etapas. A primeira etapa faz uma configuração única na preparação para escrever o registro, e a segunda etapa passa o registro montado para o singleton CloudKitNoteDatabase
classe. Esta segunda etapa pode ser repetida em caso de conflito.
No caso de um conflito, o CloudKit oferece, no CKError
retornado, três CKRecord
s completos para trabalhar:
Olhando para o modified
campos 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 outro conflito (se outra atualização vier no meio), mas então você simplesmente repete o processo até obter um resultado bem-sucedido.
Neste aplicativo simples do Note, com um único usuário alternando entre os dispositivos, você provavelmente não verá muitos conflitos no sentido de 'simultaneidade ao vivo'. No entanto, esses 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, feito diferentes edições em outro dispositivo antes de desligar 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) } }
Quando chega uma notificação de que um registro foi alterado, 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 poderia ser estendido para uma gama de diferentes tipos de registros e instâncias.
Por exemplo, incluí uma verificação de sanidade básica para ter certeza de que estou atualizando o registro correto e, em seguida, atualizo os campos e notifico o delegado de que temos novos dados.
melhores sites para aprender c ++
// 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) } }
As notificações do CloudKit chegam por meio do mecanismo de notificação padrão do iOS. Portanto, seu AppDelegate
deve chamar application.registerForRemoteNotifications
em didFinishLaunchingWithOptions
e implemente didReceiveRemoteNotification
. Quando o aplicativo receber uma notificação, verifique se ela corresponde à assinatura que você criou e, em caso afirmativo, transmita-a ao 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) } }
Dica: Já que as notificações push não são totalmente suportadas no simulador iOS, você vai querer trabalhar com dispositivos iOS físicos durante o desenvolvimento e teste do recurso de notificação CloudKit. Você pode testar todas as outras funcionalidades do CloudKit no simulador, mas deve estar conectado à sua conta iCloud no dispositivo simulado.
Ai está! Agora você pode escrever, ler e lidar com notificações remotas de atualizações de dados de aplicativos armazenados no iCloud usando a API CloudKit. Mais importante, você tem uma base para adicionar funcionalidades mais avançadas do CloudKit.
Também vale a pena apontar algo com o qual você não precisa se preocupar: autenticação do usuário. Como o CloudKit é baseado no iCloud, o aplicativo depende inteiramente da autenticação do usuário por meio do processo de login do ID da Apple / iCloud. Isso deve representar uma grande economia no desenvolvimento de back-end e nos custos operacionais para desenvolvedores de aplicativos.
Pode ser tentador pensar que a solução acima é uma solução de compartilhamento de dados totalmente robusta, mas não é tão simples.
Implícito em tudo isso é que o CloudKit nem sempre está disponível. Os usuários podem não estar conectados, podem ter desativado o CloudKit para o aplicativo, podem estar no modo avião - a lista de exceções continua. A abordagem de força bruta de exigir uma conexão CloudKit ativa ao usar o aplicativo não é de forma alguma satisfatória da perspectiva do usuário e, de fato, pode ser motivo para rejeição da Apple App Store. Portanto, um modo offline deve ser considerado cuidadosamente.
Não vou entrar em detalhes de tal implementação aqui, mas um esboço deve ser suficiente.
Os mesmos campos de nota para texto e data e hora modificada podem ser armazenados localmente em um arquivo via NSKeyedArchiver
ou semelhantes, e a IU pode fornecer funcionalidade quase total com base nesta cópia local. Também é possível serializar CKRecords
diretamente de e para o armazenamento local. Casos mais avançados podem usar SQLite, ou equivalente, como um banco de dados sombra para fins de redundância offline. O aplicativo pode então tirar proveito de várias notificações fornecidas pelo sistema operacional, em particular, CKAccountChangedNotification
, para saber quando um usuário entrou ou saiu, e iniciar uma etapa de sincronização com CloudKit (incluindo resolução de conflito adequada, é claro) para envie as alterações off-line locais para o servidor e vice-versa.
Além disso, pode ser desejável fornecer alguma indicação de IU da disponibilidade do CloudKit, status de sincronização e, claro, condições de erro que não têm uma resolução interna satisfatória.
Neste artigo, explorei o mecanismo principal da API CloudKit para manter os dados sincronizados entre vários clientes iOS.
Observe que o mesmo código funcionará para clientes macOS também, com pequenos ajustes para diferenças em como as notificações funcionam nessa plataforma.
O CloudKit fornece muito mais funcionalidade além disso, especialmente para modelos de dados sofisticados, compartilhamento público, recursos avançados de notificação de usuário e muito mais.
Embora o iCloud esteja disponível apenas para clientes da Apple, o CloudKit fornece uma plataforma incrivelmente poderosa sobre a qual construir aplicativos multi-cliente realmente interessantes e fáceis de usar com um investimento realmente mínimo do lado do servidor.
Para se aprofundar no CloudKit, eu recomendo fortemente que você reserve um tempo para visualizar as várias apresentações CloudKit de cada um dos últimos WWDCs e acompanhe os exemplos que eles fornecem.
Relacionado: Tutorial Swift: uma introdução ao padrão de design MVVM