Panduan CloudKit: Cara Menyinkronkan Data Pengguna di Seluruh Perangkat iOS
Diterbitkan: 2022-03-11Saat ini, pengembangan aplikasi seluler modern memerlukan rencana yang matang untuk menjaga agar data pengguna tetap sinkron di berbagai perangkat. Ini adalah masalah pelik dengan banyak jebakan dan jebakan, tetapi pengguna mengharapkan fitur tersebut, dan mengharapkannya berfungsi dengan baik.
Untuk iOS dan macOS, Apple menyediakan toolkit tangguh, yang disebut CloudKit API, yang memungkinkan pengembang menargetkan platform Apple untuk memecahkan masalah sinkronisasi ini.
Dalam artikel ini, saya akan mendemonstrasikan cara menggunakan CloudKit untuk menjaga agar data pengguna tetap sinkron di antara beberapa klien. Ini ditujukan untuk pengembang iOS berpengalaman yang sudah terbiasa dengan kerangka kerja Apple dan Swift. Saya akan melakukan penyelaman teknis yang cukup mendalam ke dalam CloudKit API untuk mengeksplorasi cara Anda dapat memanfaatkan teknologi ini untuk membuat aplikasi multi-perangkat yang mengagumkan. Saya akan fokus pada aplikasi iOS, tetapi pendekatan yang sama juga dapat digunakan untuk klien macOS.
Contoh kasus penggunaan kami adalah aplikasi catatan sederhana hanya dengan satu catatan, untuk tujuan ilustrasi. Sepanjang jalan, saya akan melihat beberapa aspek yang lebih rumit dari sinkronisasi data berbasis cloud, termasuk penanganan konflik dan perilaku lapisan jaringan yang tidak konsisten.
Apa itu CloudKit?
CloudKit dibangun di atas layanan iCloud Apple. Cukup adil untuk mengatakan bahwa iCloud memulai dengan awal yang sulit. Transisi yang canggung dari MobileMe, kinerja yang buruk, dan bahkan beberapa masalah privasi menahan sistem di tahun-tahun awal.
Untuk pengembang aplikasi, situasinya bahkan lebih buruk. Sebelum CloudKit, perilaku yang tidak konsisten dan alat debugging yang lemah membuat hampir tidak mungkin untuk memberikan produk berkualitas tinggi menggunakan API iCloud generasi pertama.
Seiring waktu, bagaimanapun, Apple telah mengatasi masalah ini. Secara khusus, setelah rilis CloudKit SDK pada tahun 2014, pengembang pihak ketiga memiliki solusi teknis yang kuat dan berfitur lengkap untuk berbagi data berbasis cloud antar perangkat (termasuk aplikasi macOS dan bahkan klien berbasis web.)
Karena CloudKit sangat terkait dengan sistem operasi dan perangkat Apple, CloudKit tidak cocok untuk aplikasi yang memerlukan dukungan perangkat yang lebih luas, seperti klien Android atau Windows. Namun, untuk aplikasi yang ditargetkan ke basis pengguna Apple, ini menyediakan mekanisme yang sangat kuat untuk otentikasi pengguna dan sinkronisasi data.
Pengaturan CloudKit Dasar
CloudKit mengatur data melalui hierarki kelas: CKContainer
, CKDatabase
, CKRecordZone
, dan CKRecord
.
Di tingkat atas adalah CKContainer
, yang merangkum sekumpulan data CloudKit terkait. Setiap aplikasi secara otomatis mendapatkan CKContainer
default, dan sekelompok aplikasi dapat membagikan CKContainer
khusus jika pengaturan izin memungkinkan. Itu dapat mengaktifkan beberapa alur kerja lintas aplikasi yang menarik.
Dalam setiap CKContainer
ada beberapa instance CKDatabase
. CloudKit secara otomatis mengonfigurasi setiap aplikasi berkemampuan CloudKit di luar kotak untuk memiliki CKDatabase
publik (semua pengguna aplikasi dapat melihat semuanya) dan CKDatabase
pribadi (setiap pengguna hanya melihat data mereka sendiri). Dan, pada iOS 10, CKDatabase
bersama tempat grup yang dikontrol pengguna dapat berbagi item di antara anggota grup.
Di dalam CKDatabase
ada CKRecordZone
s, dan di dalam zona CKRecord
s. Anda dapat membaca dan menulis catatan, kueri untuk catatan yang cocok dengan serangkaian kriteria, dan (yang paling penting) menerima pemberitahuan perubahan pada salah satu hal di atas.
Untuk aplikasi Note, Anda dapat menggunakan penampung default. Dalam wadah ini, Anda akan menggunakan database pribadi (karena Anda ingin catatan pengguna hanya dilihat oleh pengguna itu) dan di dalam database pribadi, Anda akan menggunakan zona rekaman khusus, yang memungkinkan pemberitahuan spesifik mencatat perubahan.
Catatan akan disimpan sebagai CKRecord
tunggal dengan bidang text
, modified
(DateTime), dan version
. CloudKit secara otomatis melacak nilai modified
internal, tetapi Anda ingin mengetahui waktu modifikasi yang sebenarnya, termasuk kasus offline, untuk tujuan resolusi konflik. Bidang version
hanyalah ilustrasi praktik yang baik untuk pemeriksaan pemutakhiran, perlu diingat bahwa pengguna dengan beberapa perangkat mungkin tidak memperbarui aplikasi Anda di semua perangkat secara bersamaan, jadi ada beberapa panggilan untuk membela diri.
Membangun Aplikasi Catatan
Saya berasumsi Anda memiliki pegangan yang baik tentang dasar-dasar membuat aplikasi iOS di Xcode. Jika mau, Anda dapat mengunduh dan memeriksa contoh proyek Note App Xcode yang dibuat untuk tutorial ini.
Untuk tujuan kami, aplikasi tampilan tunggal yang berisi UITextView
dengan ViewController
sebagai delegasinya sudah cukup. Pada tingkat konseptual, Anda ingin memicu pembaruan catatan CloudKit setiap kali teks berubah. Namun, sebagai masalah praktis, masuk akal untuk menggunakan semacam mekanisme penggabungan perubahan, seperti Timer latar belakang yang menyala secara berkala, untuk menghindari spam ke server iCloud dengan terlalu banyak perubahan kecil.
Aplikasi CloudKit memerlukan beberapa item untuk diaktifkan di Panel Kemampuan Target Xcode: iCloud (tentu saja), termasuk kotak centang CloudKit, Pemberitahuan Push, dan Mode Latar Belakang (khususnya, pemberitahuan jarak jauh).
Untuk fungsionalitas CloudKit, saya membaginya menjadi dua kelas: CloudKitNoteDatabase
singleton level lebih rendah dan kelas CloudKitNote
level lebih tinggi.
Tapi pertama-tama, diskusi singkat tentang Kesalahan CloudKit.
Kesalahan CloudKit
Penanganan kesalahan yang hati-hati sangat penting untuk klien CloudKit.
Karena ini adalah API berbasis jaringan, ini rentan terhadap sejumlah masalah kinerja dan ketersediaan. Selain itu, layanan itu sendiri harus melindungi dari berbagai potensi masalah, seperti permintaan yang tidak sah, perubahan yang bertentangan, dan sejenisnya.
CloudKit menyediakan berbagai kode kesalahan, dengan informasi yang menyertainya, untuk memungkinkan pengembang menangani berbagai kasus tepi dan, jika perlu, memberikan penjelasan terperinci kepada pengguna tentang kemungkinan masalah.
Selain itu, beberapa operasi CloudKit dapat mengembalikan kesalahan sebagai nilai kesalahan tunggal atau kesalahan gabungan yang ditandai di tingkat atas sebagai partialFailure
. Muncul dengan Kamus berisi CKError
s yang layak diperiksa lebih cermat untuk mengetahui apa yang sebenarnya terjadi selama operasi gabungan.
Untuk membantu menavigasi beberapa kerumitan ini, Anda dapat memperluas CKError
dengan beberapa metode pembantu.
Harap dicatat semua kode memiliki komentar penjelasan pada poin-poin penting.
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) } }
Singleton CloudKitNoteDatabase
Apple menyediakan dua tingkat fungsionalitas di CloudKit SDK: Fungsi "kenyamanan" tingkat tinggi, seperti fetch()
, save()
, dan delete()
, dan konstruksi operasi tingkat yang lebih rendah dengan nama yang rumit, seperti CKModifyRecordsOperation
.
API kenyamanan jauh lebih mudah diakses, sementara pendekatan operasi bisa sedikit mengintimidasi. Namun, Apple sangat mendesak pengembang untuk menggunakan operasi daripada metode kenyamanan.
Operasi CloudKit memberikan kontrol yang unggul atas detail tentang bagaimana CloudKit melakukan pekerjaannya dan, mungkin yang lebih penting, benar-benar memaksa pengembang untuk berpikir dengan hati-hati tentang perilaku jaringan yang menjadi pusat semua yang dilakukan CloudKit. Untuk alasan ini, saya menggunakan operasi dalam contoh kode ini.
Kelas tunggal Anda akan bertanggung jawab untuk setiap operasi CloudKit yang akan Anda gunakan. Bahkan, dalam arti tertentu, Anda sedang membuat ulang API kenyamanan. Namun, dengan menerapkannya sendiri berdasarkan API Operasi, Anda menempatkan diri Anda di tempat yang baik untuk menyesuaikan perilaku dan menyesuaikan respons penanganan kesalahan Anda. Misalnya, jika Anda ingin memperluas aplikasi ini untuk menangani banyak Catatan daripada hanya satu, Anda dapat melakukannya dengan lebih mudah (dan dengan kinerja yang dihasilkan lebih tinggi) daripada jika Anda baru saja menggunakan API kenyamanan 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? // ... }
Membuat Zona Kustom
CloudKit secara otomatis membuat zona default untuk database pribadi. Namun, Anda bisa mendapatkan lebih banyak fungsi jika Anda menggunakan zona kustom, terutama, dukungan untuk mengambil perubahan catatan tambahan.
Karena ini adalah contoh pertama penggunaan operasi, berikut adalah beberapa pengamatan umum:
Pertama, semua operasi CloudKit memiliki penutupan penyelesaian khusus (dan banyak yang memiliki penutupan menengah, tergantung pada operasinya). CloudKit memiliki kelas CKError
sendiri, yang diturunkan dari Error
, tetapi Anda harus menyadari kemungkinan bahwa kesalahan lain juga akan terjadi. Terakhir, salah satu aspek terpenting dari operasi apa pun adalah nilai qualityOfService
. Karena latensi jaringan, mode pesawat, dan semacamnya, CloudKit secara internal akan menangani percobaan ulang dan semacamnya untuk operasi pada qualityOfService
"utilitas" atau lebih rendah. Bergantung pada konteksnya, Anda mungkin ingin menetapkan qualityOfService
yang lebih tinggi dan menangani sendiri situasi ini.
Setelah diatur, operasi diteruskan ke objek CKDatabase
, di mana mereka akan dieksekusi pada utas latar belakang.
// 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) }
Membuat Langganan
Langganan adalah salah satu fitur CloudKit yang paling berharga. Mereka membangun infrastruktur pemberitahuan Apple untuk memungkinkan berbagai klien mendapatkan pemberitahuan push ketika perubahan CloudKit tertentu terjadi. Ini bisa berupa pemberitahuan push normal yang akrab bagi pengguna iOS (seperti suara, spanduk, atau lencana), atau di CloudKit, itu bisa menjadi kelas pemberitahuan khusus yang disebut push diam . Dorongan diam ini terjadi sepenuhnya tanpa visibilitas atau interaksi pengguna, dan akibatnya, tidak mengharuskan pengguna untuk mengaktifkan pemberitahuan push untuk aplikasi Anda, sehingga menghemat banyak potensi masalah pengalaman pengguna sebagai pengembang aplikasi.
Cara mengaktifkan notifikasi senyap ini adalah dengan menyetel properti shouldSendContentAvailable
pada instance CKNotificationInfo
, sambil membiarkan semua setelan notifikasi tradisional ( shouldBadge
, soundName
, dan seterusnya) tidak disetel.
Perhatikan juga, saya menggunakan CKQuerySubscription
dengan predikat "selalu benar" yang sangat sederhana untuk melihat perubahan pada satu (dan satu-satunya) catatan Catatan. Dalam aplikasi yang lebih canggih, Anda mungkin ingin memanfaatkan predikat untuk mempersempit cakupan CKQuerySubscription
tertentu, dan Anda mungkin ingin meninjau jenis langganan lain yang tersedia di CloudKit, seperti CKDatabaseSuscription
.
Terakhir, perhatikan bahwa Anda dapat menggunakan nilai cache UserDefaults
untuk menghindari penyimpanan langganan yang tidak perlu lebih dari sekali. Tidak ada salahnya mengaturnya, tetapi Apple merekomendasikan untuk menghindari hal ini karena membuang sumber daya jaringan dan server.
// 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) }
Memuat Catatan
Mengambil catatan dengan nama sangat mudah. Anda dapat menganggap nama sebagai kunci utama catatan dalam pengertian basis data sederhana (misalnya, nama harus unik). CKRecordID
sebenarnya sedikit lebih rumit karena menyertakan zoneID
.
CKFetchRecordsOperation
beroperasi pada satu atau beberapa record sekaligus. Dalam contoh ini, hanya ada satu catatan, tetapi untuk perluasan di masa mendatang, ini merupakan potensi manfaat kinerja yang besar.
// 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) }
Menyimpan Catatan
Menyimpan catatan, mungkin, adalah operasi yang paling rumit. Tindakan sederhana menulis catatan ke database cukup mudah, tetapi dalam contoh saya, dengan banyak klien, di sinilah Anda akan menghadapi masalah potensial dalam menangani konflik ketika beberapa klien mencoba menulis ke server secara bersamaan. Untungnya, CloudKit secara eksplisit dirancang untuk menangani kondisi ini. Itu menolak permintaan khusus dengan konteks kesalahan yang cukup dalam respons untuk memungkinkan setiap klien membuat keputusan lokal yang tercerahkan tentang cara menyelesaikan konflik.
Meskipun ini menambah kerumitan pada klien, ini pada akhirnya merupakan solusi yang jauh lebih baik daripada Apple membuat salah satu dari beberapa mekanisme sisi server untuk resolusi konflik.
Perancang aplikasi selalu dalam posisi terbaik untuk menentukan aturan untuk situasi ini, yang dapat mencakup semuanya, mulai dari penggabungan otomatis yang sadar konteks hingga instruksi resolusi yang diarahkan pengguna. Saya tidak akan menjadi sangat mewah dalam contoh saya; Saya menggunakan bidang yang modified
untuk menyatakan bahwa pembaruan terbaru menang. Ini mungkin tidak selalu menjadi hasil terbaik untuk aplikasi profesional, tetapi ini tidak buruk untuk aturan pertama dan, untuk tujuan ini, berfungsi untuk mengilustrasikan mekanisme yang digunakan CloudKit untuk meneruskan informasi konflik kembali ke klien.
Perhatikan bahwa, dalam aplikasi contoh saya, langkah resolusi konflik ini terjadi di kelas CloudKitNote
, yang dijelaskan nanti.
// 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) }
Menangani Pemberitahuan Catatan yang Diperbarui
CloudKit Notifications menyediakan sarana untuk mengetahui kapan catatan telah diperbarui oleh klien lain. Namun, kondisi jaringan dan kendala kinerja dapat menyebabkan notifikasi individual dihapus, atau beberapa notifikasi secara sengaja digabungkan menjadi satu notifikasi klien. Karena notifikasi CloudKit dibuat di atas sistem notifikasi iOS, Anda harus waspada terhadap kondisi ini.

Namun, CloudKit memberi Anda alat yang Anda butuhkan untuk ini.
Daripada mengandalkan notifikasi individual untuk memberi Anda pengetahuan mendetail tentang perubahan yang diwakili oleh notifikasi individual, Anda menggunakan notifikasi untuk sekadar menunjukkan bahwa ada sesuatu yang berubah, lalu Anda dapat menanyakan CloudKit apa yang berubah sejak terakhir kali Anda bertanya. Dalam contoh saya, saya melakukan ini dengan menggunakan CKFetchRecordZoneChangesOperation
dan CKServerChangeTokens
. Token perubahan dapat dianggap seperti penanda yang memberi tahu Anda di mana Anda berada sebelum urutan perubahan terbaru terjadi.
// 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) }
Anda sekarang memiliki blok penyusun tingkat rendah untuk membaca dan menulis catatan, dan untuk menangani pemberitahuan perubahan catatan.
Sekarang mari kita lihat lapisan yang dibangun di atasnya untuk mengelola operasi ini dalam konteks Catatan tertentu.
Kelas CloudKitNote
Sebagai permulaan, beberapa kesalahan khusus dapat ditentukan untuk melindungi klien dari internal CloudKit, dan protokol delegasi sederhana dapat memberi tahu klien tentang pembaruan jarak jauh ke data Note yang mendasarinya.
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... } // … }
Pemetaan Dari CKRecord
ke Note
Di Swift, masing-masing bidang pada CKRecord
dapat diakses melalui operator subskrip. Semua nilai sesuai dengan CKRecordValue
, tetapi ini, pada gilirannya, selalu merupakan salah satu subset spesifik dari tipe data yang sudah dikenal: NSString
, NSNumber
, NSDate
, dan seterusnya.
Selain itu, CloudKit menyediakan jenis catatan khusus untuk objek biner "besar". Tidak ada titik batas tertentu yang ditentukan (total maksimum 1MB disarankan untuk setiap CKRecord
), tetapi sebagai aturan praktis, hampir semua hal yang terasa seperti item independen (gambar, suara, gumpalan teks) daripada sebagai bidang database mungkin harus disimpan sebagai CKAsset
. Praktik ini memungkinkan CloudKit untuk mengelola transfer jaringan dan penyimpanan sisi server dari jenis item ini dengan lebih baik.
Untuk contoh ini, Anda akan menggunakan CKAsset
untuk menyimpan teks catatan. Data CKAsset
ditangani melalui file sementara lokal yang berisi data terkait.
// 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) } }
Memuat Catatan
Memuat catatan sangat mudah. Anda melakukan sedikit pemeriksaan kesalahan yang diperlukan dan kemudian cukup mengambil data aktual dari CKRecord
dan menyimpan nilainya di bidang anggota Anda.
// 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) } }
Menyimpan Catatan dan Menyelesaikan Potensi Konflik
Ada beberapa situasi khusus yang harus diperhatikan saat Anda menyimpan catatan.
Pertama, Anda perlu memastikan bahwa Anda memulai dari CKRecord
yang valid. Anda bertanya kepada CloudKit apakah sudah ada catatan di sana, dan jika tidak, Anda membuat CKRecord
lokal baru untuk digunakan untuk penyimpanan berikutnya.
Saat Anda meminta CloudKit untuk menyimpan catatan, di sinilah Anda mungkin harus menangani konflik karena klien lain memperbarui catatan sejak terakhir kali Anda mengambilnya. Untuk mengantisipasi hal ini, bagi fungsi simpan menjadi dua langkah. Langkah pertama melakukan penyiapan satu kali sebagai persiapan untuk menulis catatan, dan langkah kedua meneruskan catatan yang telah dirakit ke kelas CloudKitNoteDatabase
tunggal. Langkah kedua ini dapat diulangi jika terjadi konflik.
Jika terjadi konflik, CloudKit memberi Anda, dalam CKError
yang dikembalikan, tiga CKRecord
penuh untuk digunakan:
- Versi rekaman sebelumnya yang Anda coba simpan,
- Versi persis dari rekaman yang Anda coba simpan,
- Versi yang dipegang oleh server pada saat Anda mengirimkan permintaan.
Dengan melihat bidang yang modified
dari catatan ini, Anda dapat memutuskan catatan mana yang terjadi lebih dulu, dan oleh karena itu data mana yang harus disimpan. Jika perlu, Anda kemudian meneruskan catatan server yang diperbarui ke CloudKit untuk menulis catatan baru. Tentu saja, ini dapat mengakibatkan konflik lain (jika pembaruan lain muncul di antaranya), tetapi Anda hanya perlu mengulangi prosesnya sampai Anda mendapatkan hasil yang berhasil.
Dalam aplikasi Catatan sederhana ini, dengan satu pengguna beralih antar perangkat, Anda tidak akan melihat terlalu banyak konflik dalam arti "konkurensi langsung". Namun, konflik semacam itu dapat muncul dari keadaan lain. Misalnya, pengguna mungkin telah mengedit satu perangkat saat berada dalam mode pesawat, lalu tanpa sadar melakukan pengeditan yang berbeda di perangkat lain sebelum mematikan mode pesawat di perangkat pertama.
Dalam aplikasi berbagi data berbasis cloud, sangat penting untuk waspada terhadap setiap kemungkinan skenario.
// 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) } }
Menangani Notifikasi Catatan yang Diubah dari Jarak Jauh
Saat pemberitahuan masuk bahwa catatan telah berubah, CloudKitNoteDatabase
akan melakukan pekerjaan berat untuk mengambil perubahan dari CloudKit. Dalam kasus contoh ini, itu hanya akan menjadi satu catatan catatan, tetapi tidak sulit untuk melihat bagaimana ini dapat diperluas ke berbagai jenis catatan dan contoh yang berbeda.
Sebagai contoh tujuan, saya menyertakan pemeriksaan kewarasan dasar untuk memastikan saya memperbarui catatan yang benar, dan kemudian memperbarui bidang dan memberi tahu delegasi bahwa kami memiliki data baru.
// 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.
Ini dia! 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.