دليل إلى CloudKit: كيفية مزامنة بيانات المستخدم عبر أجهزة iOS

نشرت: 2022-03-11

في هذه الأيام ، يتطلب تطوير تطبيقات الهاتف المحمول الحديثة خطة مدروسة جيدًا للحفاظ على مزامنة بيانات المستخدم عبر الأجهزة المختلفة. هذه مشكلة شائكة مع العديد من المزالق والمزالق ، لكن المستخدمين يتوقعون الميزة ، ويتوقعون أن تعمل بشكل جيد.

بالنسبة لنظامي iOS و macOS ، توفر Apple مجموعة أدوات قوية تسمى CloudKit API ، والتي تتيح للمطورين استهداف منصات Apple لحل مشكلة المزامنة هذه.

في هذه المقالة ، سأوضح كيفية استخدام CloudKit للحفاظ على مزامنة بيانات المستخدم بين العديد من العملاء. إنه مخصص لمطوري iOS ذوي الخبرة الذين هم بالفعل على دراية بأطر عمل Apple ومع Swift. سأقوم بإلقاء نظرة تقنية عميقة إلى حد ما في CloudKit API لاستكشاف الطرق التي يمكنك من خلالها الاستفادة من هذه التكنولوجيا لإنشاء تطبيقات رائعة متعددة الأجهزة. سأركز على تطبيق iOS ، ولكن يمكن استخدام نفس الأسلوب لعملاء macOS أيضًا.

مثال حالة الاستخدام لدينا هو تطبيق ملاحظة بسيط مع ملاحظة واحدة فقط ، لأغراض التوضيح. على طول الطريق ، سألقي نظرة على بعض الجوانب الأكثر تعقيدًا لمزامنة البيانات المستندة إلى مجموعة النظراء ، بما في ذلك معالجة التعارض وسلوك طبقة الشبكة غير المتسقة.

استخدام CloudKit لمزامنة بيانات المستخدم بين عملاء متعددين

ما هو CloudKit؟

تم تصميم CloudKit فوق خدمة iCloud من Apple. من العدل أن نقول إن iCloud بدأ بداية صعبة بعض الشيء. أدى الانتقال الخاطئ من MobileMe ، والأداء الضعيف ، وحتى بعض مخاوف الخصوصية إلى تراجع النظام في السنوات الأولى.

بالنسبة لمطوري التطبيقات ، كان الوضع أسوأ. قبل CloudKit ، جعل السلوك غير المتسق وأدوات تصحيح الأخطاء الضعيفة من المستحيل تقريبًا تقديم منتج عالي الجودة باستخدام الجيل الأول من واجهات برمجة تطبيقات iCloud.

مع مرور الوقت ، عالجت شركة Apple هذه المشكلات. على وجه الخصوص ، بعد إصدار CloudKit SDK في عام 2014 ، يمتلك مطورو الطرف الثالث حلاً تقنيًا قويًا ومتكامل الميزات لمشاركة البيانات المستندة إلى مجموعة النظراء بين الأجهزة (بما في ذلك تطبيقات macOS وحتى العملاء المستندة إلى الويب).

نظرًا لأن CloudKit يرتبط ارتباطًا وثيقًا بأنظمة تشغيل وأجهزة Apple ، فهو غير مناسب للتطبيقات التي تتطلب نطاقًا أوسع من دعم الأجهزة ، مثل عملاء Android أو Windows. بالنسبة للتطبيقات التي تستهدف قاعدة مستخدمي Apple ، فإنها توفر آلية قوية للغاية لمصادقة المستخدم ومزامنة البيانات.

إعداد CloudKit الأساسي

تنظم CloudKit البيانات عبر تسلسل هرمي للفئات: CKContainer و CKDatabase و CKRecordZone و CKRecord .

في المستوى الأعلى يوجد CKContainer ، والذي يضم مجموعة من بيانات CloudKit ذات الصلة. يحصل كل تطبيق تلقائيًا على CKContainer افتراضي ، ويمكن لمجموعة من التطبيقات مشاركة CKContainer مخصصة إذا سمحت إعدادات الأذونات بذلك. يمكن أن يتيح ذلك بعض مهام سير العمل عبر التطبيقات المثيرة للاهتمام.

يوجد داخل كل CKContainer مثيلات متعددة من CKDatabase . تقوم CloudKit تلقائيًا بتهيئة كل تطبيق ممكّن لـ CloudKit من خارج الصندوق للحصول على قاعدة CKDatabase عامة (يمكن لجميع مستخدمي التطبيق رؤية كل شيء) CKDatabase خاصة (يرى كل مستخدم بياناته الخاصة فقط). واعتبارًا من iOS 10 ، قاعدة CKDatabase فيها المستخدم مشاركة العناصر بين أعضاء المجموعة.

داخل قاعدة بيانات CKRecordZone CKDatabase ، وداخل مناطق CKRecord s. يمكنك قراءة السجلات وكتابتها ، والاستعلام عن السجلات التي تطابق مجموعة من المعايير ، و (الأهم من ذلك) تلقي إخطار بالتغييرات على أي مما سبق.

بالنسبة لتطبيق Note الخاص بك ، يمكنك استخدام الحاوية الافتراضية. داخل هذه الحاوية ، ستستخدم قاعدة البيانات الخاصة (لأنك تريد أن يرى هذا المستخدم ملاحظة المستخدم فقط) وداخل قاعدة البيانات الخاصة ، ستستخدم منطقة تسجيل مخصصة ، والتي تتيح إخطارًا محددًا سجل التغييرات.

سيتم تخزين CKRecord مفرد مع text modified (DateTime) وحقول version . يتتبع CloudKit تلقائيًا قيمة داخلية modified ، ولكنك تريد أن تكون قادرًا على معرفة الوقت الفعلي المعدل ، بما في ذلك الحالات غير المتصلة بالإنترنت ، لأغراض حل النزاعات. يعد حقل version مجرد توضيح لممارسة جيدة لتدقيق الترقية ، مع الأخذ في الاعتبار أن مستخدمًا لديه أجهزة متعددة قد لا يقوم بتحديث تطبيقك على جميع الأجهزة في نفس الوقت ، لذلك هناك بعض الدعوات للدفاع.

بناء تطبيق الملاحظات

أفترض أن لديك معالجة جيدة لأساسيات إنشاء تطبيقات iOS في Xcode. إذا كنت ترغب في ذلك ، يمكنك تنزيل وفحص مثال مشروع Note App Xcode الذي تم إنشاؤه لهذا البرنامج التعليمي.

لأغراضنا ، يكفي تطبيق عرض واحد يحتوي على UITextView مع ViewController . على المستوى المفاهيمي ، تريد تشغيل تحديث سجل CloudKit كلما تغير النص. ومع ذلك ، من الناحية العملية ، من المنطقي استخدام نوع من آلية الاندماج للتغيير ، مثل مؤقت الخلفية الذي يتم تنشيطه بشكل دوري ، لتجنب إرسال بريد عشوائي على خوادم iCloud مع العديد من التغييرات الصغيرة.

يتطلب تطبيق CloudKit تمكين بعض العناصر في جزء القدرات من Xcode Target: iCloud (بشكل طبيعي) ، بما في ذلك مربع الاختيار CloudKit ، وإخطارات الدفع ، وأنماط الخلفية (على وجه التحديد ، الإشعارات عن بُعد).

بالنسبة لوظيفة CloudKit ، قمت بتقسيم الأشياء إلى فئتين: فئة واحدة من CloudKitNoteDatabase ذات مستوى CloudKitNote CloudKitNoteDatabase مستوى أعلى.

لكن أولاً ، مناقشة سريعة لأخطاء CloudKit.

أخطاء CloudKit

تعتبر المعالجة الدقيقة للأخطاء ضرورية للغاية لعميل CloudKit.

نظرًا لأنها واجهة برمجة تطبيقات قائمة على الشبكة ، فهي عرضة لمجموعة كاملة من مشكلات الأداء والتوافر. أيضًا ، يجب أن تحمي الخدمة نفسها من مجموعة من المشكلات المحتملة ، مثل الطلبات غير المصرح بها ، والتغييرات المتضاربة ، وما شابه ذلك.

توفر CloudKit مجموعة كاملة من رموز الأخطاء ، مع المعلومات المصاحبة ، للسماح للمطورين بالتعامل مع حالات الحافة المختلفة ، وعند الضرورة ، تقديم تفسيرات مفصلة للمستخدم حول المشكلات المحتملة.

أيضًا ، يمكن للعديد من عمليات CloudKit إرجاع خطأ كقيمة خطأ واحدة أو خطأ مركب يُشار إليه في المستوى الأعلى على أنه partialFailure . يأتي مع قاموس CKError المتضمنة التي تستحق المزيد من الفحص الدقيق لمعرفة ما حدث بالضبط أثناء عملية مركبة.

للمساعدة في التنقل في بعض هذا التعقيد ، يمكنك توسيع CKError بعدة طرق مساعدة.

يرجى ملاحظة أن جميع الكود بها تعليقات توضيحية في النقاط الرئيسية.

 import CloudKit extension CKError { public func isRecordNotFound() -> Bool { return isZoneNotFound() || isUnknownItem() } public func isZoneNotFound() -> Bool { return isSpecificErrorCode(code: .zoneNotFound) } public func isUnknownItem() -> Bool { return isSpecificErrorCode(code: .unknownItem) } public func isConflict() -> Bool { return isSpecificErrorCode(code: .serverRecordChanged) } public func isSpecificErrorCode(code: CKError.Code) -> Bool { var match = false if self.code == code { match = true } else if self.code == .partialFailure { // This is a multiple-issue error. Check the underlying array // of errors to see if it contains a match for the error in question. guard let errors = partialErrorsByItemID else { return false } for (_, error) in errors { if let cke = error as? CKError { if cke.code == code { match = true break } } } } return match } // ServerRecordChanged errors contain the CKRecord information // for the change that failed, allowing the client to decide // upon the best course of action in performing a merge. public func getMergeRecords() -> (CKRecord?, CKRecord?) { if code == .serverRecordChanged { // This is the direct case of a simple serverRecordChanged Error. return (clientRecord, serverRecord) } guard code == .partialFailure else { return (nil, nil) } guard let errors = partialErrorsByItemID else { return (nil, nil) } for (_, error) in errors { if let cke = error as? CKError { if cke.code == .serverRecordChanged { // This is the case of a serverRecordChanged Error // contained within a multi-error PartialFailure Error. return cke.getMergeRecords() } } } return (nil, nil) } }

CloudKitNoteDatabase Singleton

توفر Apple مستويين من الوظائف في CloudKit SDK: وظائف "ملائمة" عالية المستوى ، مثل fetch() ، save() ، delete() ، وإنشاءات التشغيل ذات المستوى الأدنى بأسماء مرهقة ، مثل CKModifyRecordsOperation .

يمكن الوصول إلى واجهة برمجة التطبيقات الملائمة بشكل أكبر ، في حين أن نهج التشغيل قد يكون مخيفًا بعض الشيء. ومع ذلك ، تحث Apple المطورين بشدة على استخدام العمليات بدلاً من أساليب الراحة.

توفر عمليات CloudKit تحكمًا فائقًا في تفاصيل كيفية قيام CloudKit بعملها ، وربما الأهم من ذلك أنها تجبر المطور حقًا على التفكير بعناية في سلوكيات الشبكة المركزية لكل ما تقوم به CloudKit. لهذه الأسباب ، أستخدم العمليات في أمثلة التعليمات البرمجية هذه.

سيكون صفك الفردي مسؤولاً عن كل من عمليات CloudKit التي ستستخدمها. في الواقع ، بمعنى ما ، تقوم بإعادة إنشاء واجهات برمجة التطبيقات الملائمة. ولكن ، من خلال تنفيذها بنفسك استنادًا إلى Operation API ، فإنك تضع نفسك في مكان جيد لتخصيص السلوك وضبط استجابات معالجة الأخطاء. على سبيل المثال ، إذا كنت ترغب في توسيع هذا التطبيق للتعامل مع ملاحظات متعددة بدلاً من واحدة فقط ، فيمكنك القيام بذلك بسهولة أكبر (وبأداء ناتج أعلى) مما لو كنت قد استخدمت للتو واجهات برمجة تطبيقات ملائمة من Apple.

 import CloudKit public protocol CloudKitNoteDatabaseDelegate { func cloudKitNoteRecordChanged(record: CKRecord) } public class CloudKitNoteDatabase { static let shared = CloudKitNoteDatabase() private init() { let zone = CKRecordZone(zoneName: "note-zone") zoneID = zone.zoneID } public var delegate: CloudKitNoteDatabaseDelegate? public var zoneID: CKRecordZoneID? // ... }

إنشاء منطقة مخصصة

تنشئ CloudKit تلقائيًا منطقة افتراضية لقاعدة البيانات الخاصة. ومع ذلك ، يمكنك الحصول على المزيد من الوظائف إذا كنت تستخدم منطقة مخصصة ، وعلى الأخص ، دعم جلب التغييرات المتزايدة في السجل.

نظرًا لأن هذا هو المثال الأول لاستخدام عملية ، فإليك بعض الملاحظات العامة:

أولاً ، تحتوي جميع عمليات CloudKit على عمليات إغلاق مخصصة للإكمال (والعديد منها يحتوي على عمليات إغلاق وسيطة ، اعتمادًا على العملية). يحتوي CloudKit على فئة CKError الخاصة به ، والمشتقة من Error ، ولكن عليك أن تكون على دراية باحتمالية حدوث أخطاء أخرى أيضًا. أخيرًا ، من أهم جوانب أي عملية قيمة qualityOfService . نظرًا لوقت استجابة الشبكة ، ووضع الطائرة ، وما إلى ذلك ، ستتعامل CloudKit داخليًا مع عمليات إعادة المحاولة وما شابه ذلك للعمليات في qualityOfService "فائدة" أو أقل. اعتمادًا على السياق ، قد ترغب في تعيين qualityOfService أعلى والتعامل مع هذه المواقف بنفسك.

بمجرد الإعداد ، يتم تمرير العمليات إلى كائن CKDatabase ، حيث سيتم تنفيذها على مؤشر ترابط في الخلفية.

 // Create a custom zone to contain our note records. We only have to do this once. private func createZone(completion: @escaping (Error?) -> Void) { let recordZone = CKRecordZone(zoneID: self.zoneID!) let operation = CKModifyRecordZonesOperation(recordZonesToSave: [recordZone], recordZoneIDsToDelete: []) operation.modifyRecordZonesCompletionBlock = { _, _, error in guard error == nil else { completion(error) return } completion(nil) } operation.qualityOfService = .utility let container = CKContainer.default() let db = container.privateCloudDatabase db.add(operation) }

إنشاء الاشتراك

الاشتراكات هي واحدة من أكثر ميزات CloudKit قيمة. إنهم يعتمدون على البنية التحتية لإشعارات Apple للسماح للعديد من العملاء بالحصول على إشعارات فورية عند حدوث تغييرات معينة في CloudKit. يمكن أن تكون هذه إشعارات دفع عادية مألوفة لمستخدمي iOS (مثل الصوت أو الشعار أو الشارة) ، أو في CloudKit ، يمكن أن تكون فئة خاصة من الإشعارات تسمى الدفعات الصامتة . تحدث هذه الدفعات الصامتة تمامًا بدون رؤية المستخدم أو تفاعله ، ونتيجة لذلك ، لا تطلب من المستخدم تمكين دفع الإشعارات لتطبيقك ، مما يوفر لك الكثير من المتاعب المحتملة لتجربة المستخدم كمطور تطبيق.

تتمثل طريقة تمكين هذه الإشعارات الصامتة في تعيين الخاصية shouldSendContentAvailable في مثيل CKNotificationInfo ، مع ترك جميع إعدادات الإشعارات التقليدية ( shouldBadge و soundName وما إلى ذلك) بدون ضبط.

لاحظ أيضًا ، إنني أستخدم CKQuerySubscription مع مُسند بسيط جدًا "صحيح دائمًا" لمراقبة التغييرات في سجل الملاحظة (فقط). في تطبيق أكثر تعقيدًا ، قد ترغب في الاستفادة من المسند لتضييق نطاق CKQuerySubscription معين ، وقد ترغب في مراجعة أنواع الاشتراك الأخرى المتاحة ضمن CloudKit ، مثل CKDatabaseSuscription .

أخيرًا ، لاحظ أنه يمكنك استخدام قيمة UserDefaults المخزنة مؤقتًا لتجنب حفظ الاشتراك غير الضروري أكثر من مرة. لا يوجد ضرر كبير في ضبطه ، لكن Apple توصي ببذل جهد لتجنب ذلك لأنه يهدر موارد الشبكة والخادم.

 // Create the CloudKit subscription we'll use to receive notification of changes. // The SubscriptionID lets us identify when an incoming notification is associated // with the query we created. public let subscription private let subscriptionSavedKey = "ckSubscriptionSaved" public func saveSubscription() { // Use a local flag to avoid saving the subscription more than once. let alreadySaved = UserDefaults.standard.bool(forKey: subscriptionSavedKey) guard !alreadySaved else { return } // If you wanted to have a subscription fire only for particular // records you can specify a more interesting NSPredicate here. // For our purposes we'll be notified of all changes. let predicate = NSPredicate(value: true) let subscription = CKQuerySubscription(recordType: "note", predicate: predicate, subscriptionID: subscriptionID, options: [.firesOnRecordCreation, .firesOnRecordDeletion, .firesOnRecordUpdate]) // We set shouldSendContentAvailable to true to indicate we want CloudKit // to use silent pushes, which won't bother the user (and which don't require // user permission.) let notificationInfo = CKNotificationInfo() notificationInfo.shouldSendContentAvailable = true subscription.notificationInfo = notificationInfo let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: []) operation.modifySubscriptionsCompletionBlock = { (_, _, error) in guard error == nil else { return } UserDefaults.standard.set(true, forKey: self.subscriptionSavedKey) } operation.qualityOfService = .utility let container = CKContainer.default() let db = container.privateCloudDatabase db.add(operation) }

تحميل السجلات

يعد جلب السجل بالاسم أمرًا بسيطًا للغاية. يمكنك التفكير في الاسم على أنه المفتاح الأساسي للسجل بمعنى قاعدة بيانات بسيط (يجب أن تكون الأسماء فريدة ، على سبيل المثال). CKRecordID الفعلي أكثر تعقيدًا بعض الشيء من حيث أنه يشتمل على zoneID .

تعمل CKFetchRecordsOperation على سجل واحد أو أكثر في وقت واحد. في هذا المثال ، يوجد رقم قياسي واحد فقط ، ولكن بالنسبة لقابلية التوسعة المستقبلية ، فهذه ميزة كبيرة محتملة للأداء.

 // Fetch a record from the iCloud database public func loadRecord(name: String, completion: @escaping (CKRecord?, Error?) -> Void) { let recordID = CKRecordID(recordName: name, zoneID: self.zoneID!) let operation = CKFetchRecordsOperation(recordIDs: [recordID]) operation.fetchRecordsCompletionBlock = { records, error in guard error == nil else { completion(nil, error) return } guard let noteRecord = records?[recordID] else { // Didn't get the record we asked about? // This shouldn't happen but we'll be defensive. completion(nil, CKError.unknownItem as? Error) return } completion(noteRecord, nil) } operation.qualityOfService = .utility let container = CKContainer.default() let db = container.privateCloudDatabase db.add(operation) }

حفظ السجلات

ربما يكون حفظ السجلات هو العملية الأكثر تعقيدًا. إن العمل البسيط المتمثل في كتابة سجل إلى قاعدة البيانات واضح بما فيه الكفاية ، ولكن في المثال الخاص بي ، مع العديد من العملاء ، هذا هو المكان الذي ستواجه فيه المشكلة المحتملة للتعامل مع التعارض عندما يحاول العديد من العملاء الكتابة إلى الخادم بشكل متزامن. لحسن الحظ ، تم تصميم CloudKit بشكل صريح للتعامل مع هذه الحالة. إنه يرفض طلبات محددة ذات سياق خطأ كاف في الاستجابة للسماح لكل عميل باتخاذ قرار محلي مستنير حول كيفية حل النزاع.

على الرغم من أن هذا يضيف تعقيدًا للعميل ، إلا أنه في النهاية حل أفضل بكثير من جعل شركة Apple تتوصل إلى إحدى الآليات القليلة من جانب الخادم لحل النزاعات.

يكون مصمم التطبيق دائمًا في أفضل وضع لتحديد القواعد لهذه المواقف ، والتي يمكن أن تتضمن كل شيء بدءًا من الدمج التلقائي مع مراعاة السياق إلى إرشادات الدقة الموجهة من المستخدم. أنا لن أتخيل كثيرا في نموذجي ؛ أنا أستخدم الحقل modified للإعلان عن فوز آخر تحديث. قد لا يكون هذا دائمًا أفضل نتيجة للتطبيقات الاحترافية ، ولكنه ليس سيئًا بالنسبة للقاعدة الأولى ، ولهذا الغرض ، يعمل على توضيح الآلية التي تقوم CloudKit من خلالها بنقل معلومات التعارض إلى العميل.

لاحظ أنه في تطبيقي النموذجي ، تحدث خطوة حل التعارض هذه في فئة CloudKitNote الموصوفة لاحقًا.

 // Save a record to the iCloud database public func saveRecord(record: CKRecord, completion: @escaping (Error?) -> Void) { let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: []) operation.modifyRecordsCompletionBlock = { _, _, error in guard error == nil else { guard let ckerror = error as? CKError else { completion(error) return } guard ckerror.isZoneNotFound() else { completion(error) return } // ZoneNotFound is the one error we can reasonably expect & handle here, since // the zone isn't created automatically for us until we've saved one record. // create the zone and, if successful, try again self.createZone() { error in guard error == nil else { completion(error) return } self.saveRecord(record: record, completion: completion) } return } // Lazy save the subscription upon first record write // (saveSubscription is internally defensive against trying to save it more than once) self.saveSubscription() completion(nil) } operation.qualityOfService = .utility let container = CKContainer.default() let db = container.privateCloudDatabase db.add(operation) }

التعامل مع إعلام السجلات المحدثة

توفر إشعارات CloudKit الوسائل لمعرفة متى يتم تحديث السجلات من قبل عميل آخر. ومع ذلك ، يمكن أن تتسبب ظروف الشبكة وقيود الأداء في إسقاط الإشعارات الفردية ، أو اندماج عدة إشعارات عن قصد في إشعار عميل واحد. نظرًا لأن إشعارات CloudKit مبنية على نظام إشعارات iOS ، يجب أن تكون على اطلاع بهذه الشروط.

ومع ذلك ، تمنحك CloudKit الأدوات التي تحتاجها لهذا الغرض.

بدلاً من الاعتماد على الإشعارات الفردية لتزويدك بمعرفة مفصلة عن التغيير الذي يمثله الإشعار الفردي ، يمكنك استخدام إشعار للإشارة ببساطة إلى أن شيئًا ما قد تغير ، وبعد ذلك يمكنك أن تسأل CloudKit عما تغير منذ آخر مرة طلبت فيها. في المثال الخاص بي ، أفعل ذلك باستخدام CKFetchRecordZoneChangesOperation و CKServerChangeTokens . يمكن اعتبار الرموز المميزة للتغيير بمثابة إشارة مرجعية تخبرك بمكانك قبل حدوث أحدث تسلسل للتغييرات.

 // Handle receipt of an incoming push notification that something has changed. private let serverChangeTokenKey = "ckServerChangeToken" public func handleNotification() { // Use the ChangeToken to fetch only whatever changes have occurred since the last // time we asked, since intermediate push notifications might have been dropped. var changeToken: CKServerChangeToken? = nil let changeTokenData = UserDefaults.standard.data(forKey: serverChangeTokenKey) if changeTokenData != nil { changeToken = NSKeyedUnarchiver.unarchiveObject(with: changeTokenData!) as! CKServerChangeToken? } let options = CKFetchRecordZoneChangesOptions() options.previousServerChangeToken = changeToken let optionsMap = [zoneID!: options] let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zoneID!], optionsByRecordZoneID: optionsMap) operation.fetchAllChanges = true operation.recordChangedBlock = { record in self.delegate?.cloudKitNoteRecordChanged(record: record) } operation.recordZoneChangeTokensUpdatedBlock = { zoneID, changeToken, data in guard let changeToken = changeToken else { return } let changeTokenData = NSKeyedArchiver.archivedData(withRootObject: changeToken) UserDefaults.standard.set(changeTokenData, forKey: self.serverChangeTokenKey) } operation.recordZoneFetchCompletionBlock = { zoneID, changeToken, data, more, error in guard error == nil else { return } guard let changeToken = changeToken else { return } let changeTokenData = NSKeyedArchiver.archivedData(withRootObject: changeToken) UserDefaults.standard.set(changeTokenData, forKey: self.serverChangeTokenKey) } operation.fetchRecordZoneChangesCompletionBlock = { error in guard error == nil else { return } } operation.qualityOfService = .utility let container = CKContainer.default() let db = container.privateCloudDatabase db.add(operation) }

لديك الآن الكتل البرمجية الإنشائية منخفضة المستوى في مكانها لقراءة السجلات وكتابتها ، وللتعامل مع إعلامات تغييرات السجل.

لنلقِ نظرة الآن على طبقة مبنية فوق ذلك لإدارة هذه العمليات في سياق ملاحظة معينة.

فئة CloudKitNote

بالنسبة للمبتدئين ، يمكن تعريف بعض الأخطاء المخصصة لحماية العميل من العناصر الداخلية لـ CloudKit ، ويمكن لبروتوكول مفوض بسيط إبلاغ العميل بالتحديثات عن بُعد لبيانات الملاحظات الأساسية.

 import CloudKit enum CloudKitNoteError : Error { case noteNotFound case newerVersionAvailable case unexpected } public protocol CloudKitNoteDelegate { func cloudKitNoteChanged(note: CloudKitNote) } public class CloudKitNote : CloudKitNoteDatabaseDelegate { public var delegate: CloudKitNoteDelegate? private(set) var text: String? private(set) var modified: Date? private let recordName = "note" private let version = 1 private var noteRecord: CKRecord? public init() { CloudKitNoteDatabase.shared.delegate = self } // CloudKitNoteDatabaseDelegate call: public func cloudKitNoteRecordChanged(record: CKRecord) { // will be filled in below... } // … }

التعيين من CKRecord إلى الملاحظة

في Swift ، يمكن الوصول إلى الحقول الفردية في CKRecord عبر عامل التشغيل المنخفض. تتوافق جميع القيم مع CKRecordValue ، ولكن هذه بدورها تكون دائمًا واحدة من مجموعة فرعية محددة من أنواع البيانات المألوفة: NSString و NSNumber و NSDate وما إلى ذلك.

أيضًا ، توفر CloudKit نوعًا محددًا من السجلات للكائنات الثنائية "الكبيرة". لم يتم تحديد نقطة فصل محددة (يوصى بإجمالي 1 ميغابايت كحد أقصى لكل CKRecord ) ، ولكن كقاعدة عامة ، أي شيء يبدو وكأنه عنصر مستقل (صورة ، صوت ، كتلة نصية) بدلاً من من المحتمل أن يتم تخزين حقل قاعدة البيانات على هيئة CKAsset . تسمح هذه الممارسة لـ CloudKit بإدارة نقل الشبكة والتخزين على جانب الخادم لهذه الأنواع من العناصر بشكل أفضل.

في هذا المثال ، ستستخدم CKAsset لتخزين نص الملاحظة. يتم التعامل مع بيانات CKAsset عبر ملفات مؤقتة محلية تحتوي على البيانات المقابلة.

 // Map from CKRecord to our native data fields private func syncToRecord(record: CKRecord) -> (String?, Date?, Error?) { let version = record["version"] as? NSNumber guard version != nil else { return (nil, nil, CloudKitNoteError.unexpected) } guard version!.intValue <= self.version else { // Simple example of a version check, in case the user has // has updated the client on another device but not this one. // A possible response might be to prompt the user to see // if the update is available on this device as well. return (nil, nil, CloudKitNoteError.newerVersionAvailable) } let textAsset = record["text"] as? CKAsset guard textAsset != nil else { return (nil, nil, CloudKitNoteError.noteNotFound) } // CKAsset data is stored as a local temporary file. Read it // into a String here. let modified = record["modified"] as? Date do { let text = try String(contentsOf: textAsset!.fileURL) return (text, modified, nil) } catch { return (nil, nil, error) } }

تحميل ملاحظة

تحميل ملاحظة أمر بسيط للغاية. تقوم ببعض التحقق من الأخطاء المطلوبة ثم تقوم ببساطة بإحضار البيانات الفعلية من CKRecord وتخزين القيم في حقول الأعضاء الخاصة بك.

 // Load a Note from iCloud public func load(completion: @escaping (String?, Date?, Error?) -> Void) { let noteDB = CloudKitNoteDatabase.shared noteDB.loadRecord(name: recordName) { (record, error) in guard error == nil else { guard let ckerror = error as? CKError else { completion(nil, nil, error) return } if ckerror.isRecordNotFound() { // This typically means we just haven't saved it yet, // for example the first time the user runs the app. completion(nil, nil, CloudKitNoteError.noteNotFound) return } completion(nil, nil, error) return } guard let record = record else { completion(nil, nil, CloudKitNoteError.unexpected) return } let (text, modified, error) = self.syncToRecord(record: record) self.noteRecord = record self.text = text self.modified = modified completion(text, modified, error) } }

حفظ ملاحظة وحل التضارب المحتمل

هناك بعض المواقف الخاصة التي يجب أن تكون على دراية بها عند حفظ ملاحظة.

أولاً ، تحتاج إلى التأكد من أنك تبدأ من CKRecord صالح. أنت تسأل CloudKit إذا كان هناك بالفعل سجل هناك ، وإذا لم يكن كذلك ، يمكنك إنشاء CKRecord محلي جديد لاستخدامه في الحفظ اللاحق.

عندما تطلب من CloudKit حفظ السجل ، فهذا هو المكان الذي قد تضطر فيه إلى معالجة التعارض بسبب قيام عميل آخر بتحديث السجل منذ آخر مرة قمت فيه بجلبه. تحسبًا لذلك ، قسّم وظيفة الحفظ إلى خطوتين. تقوم الخطوة الأولى بإعداد لمرة واحدة استعدادًا لكتابة السجل ، والخطوة الثانية تمرر السجل المجمع لأسفل إلى فئة CloudKitNoteDatabase . يمكن تكرار هذه الخطوة الثانية في حالة وجود تعارض.

في حالة حدوث تعارض ، تمنحك CloudKit ، في CKError الذي تم إرجاعه ، ثلاثة CKRecord كاملة للعمل معها:

  1. الإصدار السابق من السجل الذي حاولت حفظه ،
  2. النسخة الدقيقة للسجل الذي حاولت حفظه ،
  3. النسخة التي يحتفظ بها الخادم وقت إرسال الطلب.

من خلال النظر في الحقول modified لهذه السجلات ، يمكنك تحديد السجل الذي حدث أولاً ، وبالتالي البيانات التي يجب الاحتفاظ بها. إذا لزم الأمر ، يمكنك بعد ذلك تمرير سجل الخادم المحدث إلى CloudKit لكتابة السجل الجديد. بالطبع ، قد يؤدي هذا إلى تعارض آخر (إذا حدث تحديث آخر بينهما) ، ولكن بعد ذلك تقوم فقط بتكرار العملية حتى تحصل على نتيجة ناجحة.

في تطبيق Note البسيط هذا ، مع قيام مستخدم واحد بالتبديل بين الأجهزة ، من غير المحتمل أن ترى الكثير من التعارضات بمعنى "التزامن المباشر". ومع ذلك ، يمكن أن تنشأ مثل هذه النزاعات من ظروف أخرى. على سبيل المثال ، قد يكون المستخدم قد أجرى تعديلات على أحد الأجهزة أثناء وجوده في وضع الطائرة ، ثم أجرى تعديلاً مختلفًا شارد الذهن على جهاز آخر قبل إيقاف تشغيل وضع الطائرة على الجهاز الأول.

في تطبيقات مشاركة البيانات المستندة إلى مجموعة النظراء ، من المهم للغاية أن تكون على اطلاع بكل سيناريو محتمل.

 // Save a Note to iCloud. If necessary, handle the case of a conflicting change. public func save(text: String, modified: Date, completion: @escaping (Error?) -> Void) { guard let record = self.noteRecord else { // We don't already have a record. See if there's one up on iCloud let noteDB = CloudKitNoteDatabase.shared noteDB.loadRecord(name: recordName) { record, error in if let error = error { guard let ckerror = error as? CKError else { completion(error) return } guard ckerror.isRecordNotFound() else { completion(error) return } // No record up on iCloud, so we'll start with a // brand new record. let recordID = CKRecordID(recordName: self.recordName, zoneID: noteDB.zoneID!) self.noteRecord = CKRecord(recordType: "note", recordID: recordID) self.noteRecord?["version"] = NSNumber(value:self.version) } else { guard record != nil else { completion(CloudKitNoteError.unexpected) return } self.noteRecord = record } // Repeat the save attempt now that we've either fetched // the record from iCloud or created a new one. self.save(text: text, modified: modified, completion: completion) } return } // Save the note text as a temp file to use as the CKAsset data. let tempDirectory = NSTemporaryDirectory() let tempFileName = NSUUID().uuidString let tempFileURL = NSURL.fileURL(withPathComponents: [tempDirectory, tempFileName]) do { try text.write(to: tempFileURL!, atomically: true, encoding: .utf8) } catch { completion(error) return } let textAsset = CKAsset(fileURL: tempFileURL!) record["text"] = textAsset record["modified"] = modified as NSDate saveRecord(record: record) { updated, error in defer { try? FileManager.default.removeItem(at: tempFileURL!) } guard error == nil else { completion(error) return } guard !updated else { // During the save we found another version on the server side and // the merging logic determined we should update our local data to match // what was in the iCloud database. let (text, modified, syncError) = self.syncToRecord(record: self.noteRecord!) guard syncError == nil else { completion(syncError) return } self.text = text self.modified = modified // Let the UI know the Note has been updated. self.delegate?.cloudKitNoteChanged(note: self) completion(nil) return } self.text = text self.modified = modified completion(nil) } } // This internal saveRecord method will repeatedly be called if needed in the case // of a merge. In those cases, we don't have to repeat the CKRecord setup. private func saveRecord(record: CKRecord, completion: @escaping (Bool, Error?) -> Void) { let noteDB = CloudKitNoteDatabase.shared noteDB.saveRecord(record: record) { error in guard error == nil else { guard let ckerror = error as? CKError else { completion(false, error) return } let (clientRec, serverRec) = ckerror.getMergeRecords() guard let clientRecord = clientRec, let serverRecord = serverRec else { completion(false, error) return } // This is the merge case. Check the modified dates and choose // the most-recently modified one as the winner. This is just a very // basic example of conflict handling, more sophisticated data models // will likely require more nuance here. let clientModified = clientRecord["modified"] as? Date let serverModified = serverRecord["modified"] as? Date if (clientModified?.compare(serverModified!) == .orderedDescending) { // We've decided ours is the winner, so do the update again // using the current iCloud ServerRecord as the base CKRecord. serverRecord["text"] = clientRecord["text"] serverRecord["modified"] = clientModified! as NSDate self.saveRecord(record: serverRecord) { modified, error in self.noteRecord = serverRecord completion(true, error) } } else { // We've decided the iCloud version is the winner. // No need to overwrite it there but we'll update our // local information to match to stay in sync. self.noteRecord = serverRecord completion(true, nil) } return } completion(false, nil) } }

معالجة الإخطار بملاحظة تم تغييرها عن بُعد

عندما يأتي إشعار بأن أحد السجلات قد تم تغييره ، CloudKitNoteDatabase ستقوم بالرفع الثقيل لجلب التغييرات من CloudKit. في حالة المثال هذه ، سيكون سجل ملاحظة واحدًا فقط ، ولكن ليس من الصعب رؤية كيف يمكن توسيع ذلك ليشمل مجموعة من أنواع السجلات والمثيلات المختلفة.

على سبيل المثال ، قمت بتضمين فحص سلامة أساسي للتأكد من أنني أقوم بتحديث السجل الصحيح ، ثم قم بتحديث الحقول وإخطار المفوض بأن لدينا بيانات جديدة.

 // CloudKitNoteDatabaseDelegate call: public func cloudKitNoteRecordChanged(record: CKRecord) { if record.recordID == self.noteRecord?.recordID { let (text, modified, error) = self.syncToRecord(record: record) guard error == nil else { return } self.noteRecord = record self.text = text self.modified = modified self.delegate?.cloudKitNoteChanged(note: self) } }

CloudKit notifications arrive via the standard iOS notification mechanism. Thus, your AppDelegate should call application.registerForRemoteNotifications in didFinishLaunchingWithOptions and implement didReceiveRemoteNotification . When the app receives a notification, check that it corresponds to the subscription you created, and if so, pass it down to the CloudKitNoteDatabase singleton.

 func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let dict = userInfo as! [String: NSObject] let notification = CKNotification(fromRemoteNotificationDictionary: dict) let db = CloudKitNoteDatabase.shared if notification.subscriptionID == db.subscriptionID { db.handleNotification() completionHandler(.newData) } else { completionHandler(.noData) } }

Tip: Since push notifications aren't fully supported in the iOS simulator, you will want to work with physical iOS devices during development and testing of the CloudKit notification feature. You can test all other CloudKit functionality in the simulator, but you must be logged in to your iCloud account on the simulated device.

ها أنت ذا! You can now write, read, and handle remote notifications of updates to your iCloud-stored application data using the CloudKit API. More importantly, you have a foundation for adding more advanced CloudKit functionality.

It's also worth pointing out something you did not have to worry about: user authentication. Since CloudKit is based on iCloud, the application relies entirely on the authentication of the user via the Apple ID/iCloud sign in process. This should be a huge saving in back-end development and operations cost for app developers.

Handling the Offline Case

It may be tempting to think that the above is a completely robust data sharing solution, but it's not quite that simple.

Implicit in all of this is that CloudKit may not always be available. Users may not be signed in, they may have disabled CloudKit for the app, they may be in airplane mode—the list of exceptions goes on. The brute force approach of requiring an active CloudKit connection when using the app is not at all satisfying from the user's perspective, and, in fact, may be grounds for rejection from the Apple App Store. So, an offline mode must be carefully considered.

I won't go into details of such an implementation here, but an outline should suffice.

The same note fields for text and modified datetime can be stored locally in a file via NSKeyedArchiver or the like, and the UI can provide near full functionality based on this local copy. It is also possible to serialize CKRecords directly to and from local storage. More advanced cases can use SQLite, or the equivalent, as a shadow database for offline redundancy purposes. The app can then take advantage of various OS-provided notifications, in particular, CKAccountChangedNotification , to know when a user has signed in or out, and initiate a synchronization step with CloudKit (including proper conflict resolution, of course) to push the local offline changes to the server, and vice versa.

Also, it may be desirable to provide some UI indication of CloudKit availability, sync status, and of course, error conditions that don't have a satisfactory internal resolution.

CloudKit Solves The Synchronization Problem

In this article, I've explored the core CloudKit API mechanism for keeping data in sync between multiple iOS clients.

Note that the same code will work for macOS clients as well, with slight adjustments for differences in how notifications work on that platform.

CloudKit provides much more functionality on top of this, especially for sophisticated data models, public sharing, advanced user notification features, and more.

Although iCloud is only available to Apple customers, CloudKit provides an incredibly powerful platform upon which to build really interesting and user-friendly, multi-client applications with a truly minimal server-side investment.

To dig deeper into CloudKit, I strongly recommend taking the time to view the various CloudKit presentations from each of the last few WWDCs and follow along with the examples they provide.

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