CloudKit Kılavuzu: Kullanıcı Verilerini iOS Cihazlar Arasında Senkronize Etme
Yayınlanan: 2022-03-11Bu günlerde, modern mobil uygulama geliştirme, kullanıcı verilerini çeşitli cihazlar arasında senkronize tutmak için iyi düşünülmüş bir plan gerektiriyor. Bu, birçok sorun ve tuzakla çetin bir sorundur, ancak kullanıcılar bu özelliği bekler ve iyi çalışmasını bekler.
Apple, iOS ve macOS için, Apple platformlarını hedefleyen geliştiricilerin bu senkronizasyon sorununu çözmesine olanak tanıyan CloudKit API adlı sağlam bir araç seti sağlar.
Bu makalede, bir kullanıcının verilerini birden çok istemci arasında senkronize tutmak için CloudKit'in nasıl kullanılacağını göstereceğim. Apple'ın çerçevelerine ve Swift'e zaten aşina olan deneyimli iOS geliştiricileri için tasarlanmıştır. Harika çok cihazlı uygulamalar yapmak için bu teknolojiden yararlanmanın yollarını keşfetmek için CloudKit API'sine oldukça derin bir teknik dalış yapacağım. Bir iOS uygulamasına odaklanacağım, ancak aynı yaklaşım macOS istemcileri için de kullanılabilir.
Örnek kullanım örneğimiz, örnekleme amacıyla yalnızca tek bir nota içeren basit bir not uygulamasıdır. Bu arada, çakışma yönetimi ve tutarsız ağ katmanı davranışı da dahil olmak üzere bulut tabanlı veri senkronizasyonunun bazı karmaşık yönlerine göz atacağım.
CloudKit nedir?
CloudKit, Apple'ın iCloud hizmetinin üzerine inşa edilmiştir. İCloud'un biraz zor bir başlangıç yaptığını söylemek doğru olur. MobileMe'den beceriksiz bir geçiş, düşük performans ve hatta bazı gizlilik endişeleri, sistemi ilk yıllarda geride bıraktı.
Uygulama geliştiricileri için durum daha da kötüydü. CloudKit'ten önce, tutarsız davranış ve zayıf hata ayıklama araçları, birinci nesil iCloud API'lerini kullanarak en kaliteli ürünü sunmayı neredeyse imkansız hale getiriyordu.
Ancak zamanla, Apple bu sorunları ele aldı. Özellikle, 2014 yılında CloudKit SDK'nın piyasaya sürülmesinin ardından üçüncü taraf geliştiriciler, cihazlar arasında bulut tabanlı veri paylaşımına (macOS uygulamaları ve hatta web tabanlı istemciler dahil) yönelik tam özellikli, sağlam bir teknik çözüme sahip oldu.
CloudKit, Apple'ın işletim sistemlerine ve cihazlarına derinden bağlı olduğundan, Android veya Windows istemcileri gibi daha geniş bir cihaz desteği yelpazesi gerektiren uygulamalar için uygun değildir. Bununla birlikte, Apple'ın kullanıcı tabanını hedefleyen uygulamalar için, kullanıcı kimlik doğrulaması ve veri senkronizasyonu için son derece güçlü bir mekanizma sağlar.
Temel CloudKit Kurulumu
CloudKit, verileri bir sınıf hiyerarşisi aracılığıyla düzenler: CKContainer
, CKDatabase
, CKRecordZone
ve CKRecord
.
En üst düzeyde, bir dizi ilgili CKContainer
verisini içeren CKContainer bulunur. Her uygulama otomatik olarak varsayılan bir CKContainer
alır ve izin ayarları izin veriyorsa bir grup uygulama özel bir CKContainer
paylaşabilir. Bu, bazı ilginç çapraz uygulama iş akışlarını etkinleştirebilir.
Her CKContainer
içinde birden çok CKDatabase
örneği bulunur. CloudKit, her CloudKit özellikli uygulamayı, genel bir CKDatabase
(uygulamanın tüm kullanıcıları her şeyi görebilir) ve özel bir CKDatabase
(her kullanıcı yalnızca kendi verilerini görür) sahip olacak şekilde otomatik olarak yapılandırır. Ve iOS 10'dan itibaren, kullanıcı tarafından kontrol edilen grupların grup üyeleri arasında öğeleri paylaşabileceği paylaşılan bir CKDatabase
.
Bir CKDatabase
içinde CKRecordZone
s ve CKRecord
s bölgeleri içinde bulunur. Kayıtları okuyabilir ve yazabilir, bir dizi kriterle eşleşen kayıtları sorgulayabilir ve (en önemlisi) yukarıdakilerden herhangi birinde yapılan değişikliklerin bildirimini alabilirsiniz.
Note uygulamanız için varsayılan kapsayıcıyı kullanabilirsiniz. Bu kapsayıcı içinde, özel veritabanını kullanacaksınız (çünkü kullanıcının notunun yalnızca o kullanıcı tarafından görülmesini istiyorsunuz) ve özel veritabanı içinde, belirli bir bildirimin bildirilmesini sağlayan özel bir kayıt bölgesi kullanacaksınız. değişiklikleri kaydedin.
Not, text
, modified
(DateTime) ve version
alanları içeren tek bir CKRecord
olarak saklanacaktır. CloudKit, dahili olarak modified
bir değeri otomatik olarak izler, ancak çakışma çözme amacıyla çevrimdışı durumlar da dahil olmak üzere gerçek değiştirilen zamanı bilmek istersiniz. version
alanı, yalnızca birden fazla cihaza sahip bir kullanıcının uygulamanızı tüm cihazlarda aynı anda güncelleyemeyebileceğini akılda tutarak, yükseltme provası için iyi bir uygulamanın bir örneğidir, bu nedenle savunmaya yönelik bazı çağrılar vardır.
Not Uygulamasını Oluşturma
Xcode'da iOS uygulamaları oluşturmanın temellerini iyi bildiğinizi varsayıyorum. Dilerseniz bu eğitim için oluşturulmuş örnek Note App Xcode projesini indirip inceleyebilirsiniz.
Amaçlarımız için, temsilcisi olarak ViewController
ile bir UITextView
içeren tek bir görünüm uygulaması yeterli olacaktır. Kavramsal düzeyde, metin her değiştiğinde bir CloudKit kayıt güncellemesini tetiklemek istersiniz. Bununla birlikte, pratik bir mesele olarak, iCloud sunucularını çok fazla küçük değişiklikle spam yapmaktan kaçınmak için periyodik olarak ateşlenen bir arka plan Zamanlayıcısı gibi bir tür değişiklik birleştirme mekanizması kullanmak mantıklıdır.
CloudKit uygulaması, Xcode Hedefinin Yetenekler Bölmesi'nde birkaç öğenin etkinleştirilmesini gerektirir: iCloud (doğal olarak), CloudKit onay kutusu, Anında Bildirimler ve Arka Plan Modları (özellikle uzak bildirimler).
CloudKit işlevselliği için her şeyi iki sınıfa ayırdım: Daha düşük seviyeli CloudKitNoteDatabase
singleton ve daha yüksek seviyeli CloudKitNote
sınıfı.
Ama önce, CloudKit Hatalarının kısa bir tartışması.
CloudKit Hataları
Dikkatli hata işleme, bir CloudKit istemcisi için kesinlikle gereklidir.
Ağ tabanlı bir API olduğundan, bir dizi performans ve kullanılabilirlik sorununa açıktır. Ayrıca hizmetin kendisi, yetkisiz istekler, çelişkili değişiklikler ve benzerleri gibi bir dizi olası soruna karşı koruma sağlamalıdır.
CloudKit, geliştiricilerin çeşitli uç durumları ele almasına ve gerektiğinde kullanıcıya olası sorunlar hakkında ayrıntılı açıklamalar sağlamasına olanak tanımak için eşlik eden bilgilerle birlikte eksiksiz bir hata kodu yelpazesi sağlar.
Ayrıca, birkaç CloudKit işlemi, bir hatayı tek bir hata değeri olarak veya en üst düzeyde partialFailure
olarak belirtilen bileşik bir hata olarak döndürebilir. Bileşik bir işlem sırasında tam olarak ne olduğunu anlamak için daha dikkatli bir incelemeyi hak eden, içerilen CKError
s Sözlüğü ile birlikte gelir.
Bu karmaşıklığın bir kısmında gezinmeye yardımcı olmak için CKError
birkaç yardımcı yöntemle genişletebilirsiniz.
Lütfen tüm kodun önemli noktalarda açıklayıcı yorumları olduğunu unutmayın.
import CloudKit extension CKError { public func isRecordNotFound() -> Bool { return isZoneNotFound() || isUnknownItem() } public func isZoneNotFound() -> Bool { return isSpecificErrorCode(code: .zoneNotFound) } public func isUnknownItem() -> Bool { return isSpecificErrorCode(code: .unknownItem) } public func isConflict() -> Bool { return isSpecificErrorCode(code: .serverRecordChanged) } public func isSpecificErrorCode(code: CKError.Code) -> Bool { var match = false if self.code == code { match = true } else if self.code == .partialFailure { // This is a multiple-issue error. Check the underlying array // of errors to see if it contains a match for the error in question. guard let errors = partialErrorsByItemID else { return false } for (_, error) in errors { if let cke = error as? CKError { if cke.code == code { match = true break } } } } return match } // ServerRecordChanged errors contain the CKRecord information // for the change that failed, allowing the client to decide // upon the best course of action in performing a merge. public func getMergeRecords() -> (CKRecord?, CKRecord?) { if code == .serverRecordChanged { // This is the direct case of a simple serverRecordChanged Error. return (clientRecord, serverRecord) } guard code == .partialFailure else { return (nil, nil) } guard let errors = partialErrorsByItemID else { return (nil, nil) } for (_, error) in errors { if let cke = error as? CKError { if cke.code == .serverRecordChanged { // This is the case of a serverRecordChanged Error // contained within a multi-error PartialFailure Error. return cke.getMergeRecords() } } } return (nil, nil) } }
CloudKitNoteDatabase
Singleton
Apple, CloudKit SDK'da iki işlevsellik düzeyi sağlar: fetch()
, save()
ve delete()
gibi üst düzey "kolaylık" işlevleri ve CKModifyRecordsOperation
gibi hantal adlara sahip daha düşük düzeyli işlem yapıları.
Kullanım yaklaşımı biraz korkutucu olabilirken, kolaylık API'sine çok daha erişilebilir. Ancak Apple, geliştiricileri kolaylık yöntemlerinden ziyade işlemleri kullanmaya şiddetle teşvik ediyor.
CloudKit işlemleri, CloudKit'in nasıl çalıştığının ayrıntıları üzerinde üstün kontrol sağlar ve belki de daha da önemlisi, geliştiriciyi CloudKit'in yaptığı her şeyin merkezinde yer alan ağ davranışları hakkında dikkatlice düşünmeye zorlar. Bu sebeplerden dolayı bu kod örneklerindeki işlemleri kullanıyorum.
Kullanacağınız bu CloudKit işlemlerinin her birinden singleton sınıfınız sorumlu olacaktır. Aslında, bir anlamda, kolaylık API'lerini yeniden yaratıyorsunuz. Ancak bunları Operation API'ye dayalı olarak kendiniz uygulayarak, davranışı özelleştirmek ve hata işleme yanıtlarınızı ayarlamak için kendinizi iyi bir yere koyarsınız. Örneğin, bu uygulamayı yalnızca bir Not yerine birden çok Notu işleyecek şekilde genişletmek istiyorsanız, bunu Apple'ın kolaylık API'lerini kullanmaktan daha kolay (ve daha yüksek performansla) yapabilirsiniz.
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? // ... }
Özel Bölge Oluşturma
CloudKit, özel veritabanı için otomatik olarak bir varsayılan bölge oluşturur. Ancak, özel bir bölge kullanırsanız daha fazla işlevsellik elde edebilirsiniz, özellikle de artımlı kayıt değişikliklerini getirme desteği.
Bu, bir işlemi kullanmanın ilk örneği olduğundan, işte birkaç genel gözlem:
İlk olarak, tüm CloudKit işlemlerinde özel tamamlama kapanışları bulunur (ve çoğu, işleme bağlı olarak ara kapanışlara sahiptir). CloudKit'in Error
türetilen kendi CKError
sınıfı vardır, ancak başka hataların da gelme olasılığının farkında olmanız gerekir. Son olarak, herhangi bir işlemin en önemli yönlerinden biri, qualityOfService
değeridir. Ağ gecikmesi, uçak modu vb. nedeniyle CloudKit, "yardımcı program" veya daha düşük bir qualityOfService
işlemler için yeniden denemeleri ve benzerlerini dahili olarak gerçekleştirir. Bağlama bağlı olarak, daha yüksek bir qualityOfService
ve bu durumları kendiniz ele almak isteyebilirsiniz.
Ayarlandıktan sonra işlemler, arka plan iş parçacığında yürütülecekleri CKDatabase
nesnesine iletilir.
// 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) }
Abonelik Oluşturma
Abonelikler, en değerli CloudKit özelliklerinden biridir. Belirli CloudKit değişiklikleri gerçekleştiğinde çeşitli istemcilerin anında iletme bildirimleri almasına izin vermek için Apple'ın bildirim altyapısını oluştururlar. Bunlar, iOS kullanıcılarının aşina olduğu (ses, başlık veya rozet gibi) normal anında iletme bildirimleri olabilir veya CloudKit'te, sessiz bildirimler adı verilen özel bir bildirim sınıfı olabilir. Bu sessiz göndermeler, tamamen kullanıcı görünürlüğü veya etkileşimi olmadan gerçekleşir ve sonuç olarak, kullanıcının uygulamanız için anında iletme bildirimini etkinleştirmesini gerektirmez, bu da sizi bir uygulama geliştiricisi olarak birçok olası kullanıcı deneyimi baş ağrısından kurtarır.
Bu sessiz bildirimleri etkinleştirmenin yolu, CKNotificationInfo
örneğinde shouldSendContentAvailable
özelliğini ayarlarken, tüm geleneksel bildirim ayarlarını ( shouldBadge
, soundName
vb.) ayarlanmadan bırakmaktır.
Ayrıca, bir (ve yalnızca) Not kaydındaki değişiklikleri izlemek için çok basit bir "her zaman doğru" yüklemi olan bir CKQuerySubscription
kullanıyorum. Daha karmaşık bir uygulamada, belirli bir CKQuerySubscription
kapsamını daraltmak için yüklemden yararlanmak isteyebilirsiniz ve CloudKit altında bulunan CKDatabaseSuscription
gibi diğer abonelik türlerini gözden geçirmek isteyebilirsiniz.
Son olarak, aboneliği bir kereden fazla gereksiz yere kaydetmemek için bir UserDefaults
önbelleğe alınmış değeri kullanabileceğinizi gözlemleyin. Ayarlamanın çok büyük bir zararı yoktur, ancak Apple, ağ ve sunucu kaynaklarını boşa harcadığı için bundan kaçınmak için çaba sarf etmenizi önerir.
// 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) }
Kayıtları Yükleme
İsme göre bir kayıt almak çok basittir. Adı, basit bir veritabanı anlamında kaydın birincil anahtarı olarak düşünebilirsiniz (örneğin, adlar benzersiz olmalıdır). Gerçek CKRecordID
, zoneID
içerdiği için biraz daha karmaşıktır.
CKFetchRecordsOperation
, aynı anda bir veya daha fazla kayıt üzerinde çalışır. Bu örnekte, yalnızca bir kayıt vardır, ancak gelecekteki genişletilebilirlik için bu, büyük bir potansiyel performans avantajıdır.
// 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) }
Kayıtları Kaydetme
Kayıtları kaydetmek, belki de en karmaşık işlemdir. Veritabanına bir kayıt yazma eylemi yeterince basittir, ancak benim örneğimde, birden çok istemciyle, bu, birden çok istemci sunucuya aynı anda yazmaya çalıştığında olası bir çakışmayı ele alma sorunuyla karşı karşıya kalacağınız yerdir. Neyse ki CloudKit, bu durumu ele almak için açıkça tasarlanmıştır. Her müşterinin çatışmanın nasıl çözüleceği konusunda yerel, aydınlanmış bir karar vermesine izin vermek için yanıtta yeterli hata bağlamıyla belirli istekleri reddeder.
Bu, istemciye karmaşıklık katmasına rağmen, sonuçta Apple'ın çakışma çözümü için birkaç sunucu tarafı mekanizmadan birini bulmasından çok daha iyi bir çözüm.
Uygulama tasarımcısı, bağlama duyarlı otomatik birleştirmeden kullanıcı tarafından yönlendirilen çözüm talimatlarına kadar her şeyi içerebilen bu durumlar için kuralları tanımlamak için her zaman en iyi konumdadır. Örneğimde çok süslü olmayacağım; En son güncellemenin kazandığını bildirmek için modified
alanı kullanıyorum. Bu, profesyonel uygulamalar için her zaman en iyi sonuç olmayabilir, ancak ilk kural için fena değil ve bu amaçla, CloudKit'in çakışma bilgilerini istemciye geri iletme mekanizmasını göstermeye hizmet ediyor.
Benim örnek uygulamamda bu çakışma çözme adımının daha sonra açıklanacak olan CloudKitNote
sınıfında gerçekleştiğine dikkat edin.
// 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) }
Güncellenmiş Kayıtların İşlenmesi Bildirimi
CloudKit Bildirimleri, kayıtların başka bir istemci tarafından ne zaman güncellendiğini öğrenmek için araçlar sağlar. Ancak, ağ koşulları ve performans kısıtlamaları, tek tek bildirimlerin bırakılmasına veya birden çok bildirimin kasıtlı olarak tek bir istemci bildiriminde birleştirilmesine neden olabilir. CloudKit'in bildirimleri, iOS bildirim sisteminin üzerine kurulduğundan, bu koşullara dikkat etmeniz gerekir.

Ancak CloudKit, bunun için ihtiyacınız olan araçları size sunar.
Tek bir bildirimin hangi değişikliği temsil ettiği konusunda size ayrıntılı bilgi vermesi için tek tek bildirimlere güvenmek yerine, yalnızca bir şeyin değiştiğini belirtmek için bir bildirim kullanırsınız ve ardından CloudKit'e en son sorduğunuzdan bu yana nelerin değiştiğini sorabilirsiniz. Örneğimde bunu CKFetchRecordZoneChangesOperation
ve CKServerChangeTokens
kullanarak yapıyorum. Değişiklik belirteçleri, en son değişiklik dizisi gerçekleşmeden önce nerede olduğunuzu söyleyen bir yer imi gibi düşünülebilir.
// 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) }
Artık kayıtları okumak ve yazmak ve kayıt değişikliklerinin bildirimlerini yönetmek için düşük seviyeli yapı taşlarına sahipsiniz.
Şimdi bu işlemleri belirli bir Not bağlamında yönetmek için bunun üzerine inşa edilmiş bir katmana bakalım.
CloudKitNote
Sınıfı
Yeni başlayanlar için, istemciyi CloudKit'in içindekilerden korumak için birkaç özel hata tanımlanabilir ve basit bir temsilci protokolü, istemciyi temeldeki Note verilerine yapılan uzaktan güncellemeler konusunda bilgilendirebilir.
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
Eşleştirme
Swift'de, bir CKRecord
üzerindeki bireysel alanlara indis operatörü aracılığıyla erişilebilir. Değerlerin tümü CKRecordValue
ile uyumludur, ancak bunlar sırayla her zaman tanıdık veri türlerinin belirli bir alt kümesinden biridir: NSString
, NSNumber
, NSDate
vb.
Ayrıca CloudKit, "büyük" ikili nesneler için belirli bir kayıt türü sağlar. Belirli bir kesme noktası belirtilmemiştir (her CKRecord
için toplam maksimum 1 MB önerilir), ancak genel bir kural olarak, bağımsız bir öğe gibi hissettiren hemen hemen her şey (bir görüntü, ses, metin bloğu) gibi değil. bir veritabanı alanı muhtemelen bir CKAsset
olarak saklanmalıdır. Bu uygulama, CloudKit'in bu tür öğelerin ağ aktarımını ve sunucu tarafı depolamasını daha iyi yönetmesine olanak tanır.
Bu örnekte, not metnini saklamak için CKAsset
kullanacaksınız. CKAsset
verileri, ilgili verileri içeren yerel geçici dosyalar aracılığıyla işlenir.
// 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) } }
Not Yükleme
Not yüklemek çok basittir. Biraz gerekli hata kontrolünü yaparsınız ve ardından CKRecord
gerçek verileri alır ve değerleri üye alanlarınızda saklarsınız.
// 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) } }
Not Kaydetme ve Olası Çakışmayı Çözme
Bir notu kaydederken dikkat etmeniz gereken birkaç özel durum vardır.
Öncelikle, geçerli bir CKRecord
emin olmanız gerekir. CloudKit'e orada zaten bir kayıt olup olmadığını sorarsınız ve yoksa sonraki kaydetme için kullanmak üzere yeni bir yerel CKRecord
oluşturursunuz.
CloudKit'ten kaydı kaydetmesini istediğinizde, başka bir istemcinin kaydı en son getirdiğinizden bu yana güncellemesi nedeniyle bir çakışmayla ilgilenmeniz gerekebilir. Bunu öngörerek, kaydetme işlevini iki adıma bölün. İlk adım, kaydı yazmaya hazırlık için tek seferlik bir kurulum yapar ve ikinci adım, birleştirilmiş kaydı tekil CloudKitNoteDatabase
sınıfına iletir. Bu ikinci adım, bir çakışma durumunda tekrarlanabilir.
Bir çakışma durumunda, CloudKit, döndürülen CKError
birlikte çalışmanız için size üç tam CKRecord
verir:
- Kaydetmeye çalıştığınız kaydın önceki sürümü,
- Kaydetmeye çalıştığınız kaydın tam sürümü,
- İsteği gönderdiğiniz sırada sunucu tarafından tutulan sürüm.
Bu kayıtların modified
alanlarına bakarak hangi kaydın önce meydana geldiğine ve dolayısıyla hangi verilerin tutulacağına karar verebilirsiniz. Gerekirse, yeni kaydı yazmak için güncellenmiş sunucu kaydını CloudKit'e iletirsiniz. Tabii ki, bu başka bir çakışmaya neden olabilir (arada başka bir güncelleme gelirse), ancak başarılı bir sonuç elde edene kadar işlemi tekrarlarsınız.
Tek bir kullanıcının cihazlar arasında geçiş yaptığı bu basit Note uygulamasında, "canlı eşzamanlılık" anlamında çok fazla çakışma görmeniz olası değildir. Ancak, bu tür çatışmalar başka koşullardan kaynaklanabilir. Örneğin, bir kullanıcı uçak modundayken bir cihazda düzenlemeler yapmış ve ardından ilk cihazda uçak modunu kapatmadan önce dalgın bir şekilde başka bir cihazda farklı düzenlemeler yapmış olabilir.
Bulut tabanlı veri paylaşım uygulamalarında, olası her senaryo için tetikte olmak son derece önemlidir.
// 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) } }
Uzaktan Değiştirilen Bir Notun Bildirimini İşleme
Bir kaydın değiştiğine dair bir bildirim geldiğinde, CloudKitNoteDatabase
, değişiklikleri CloudKit'ten getirmenin ağır yükünü üstlenir. Bu örnek durumda, yalnızca bir not kaydı olacak, ancak bunun bir dizi farklı kayıt türü ve örneğine nasıl genişletilebileceğini görmek zor değil.
Örneğin, doğru kaydı güncellediğimden emin olmak için temel bir akıl sağlığı kontrolü ekledim ve ardından alanları güncelleyip temsilciye yeni verilerimiz olduğunu bildirdim.
// 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.
Buyrun! 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.