คำแนะนำเกี่ยวกับ CloudKit: วิธีซิงค์ข้อมูลผู้ใช้ระหว่างอุปกรณ์ iOS
เผยแพร่แล้ว: 2022-03-11ทุกวันนี้ การพัฒนาแอปพลิเคชั่นมือถือสมัยใหม่จำเป็นต้องมีการวางแผนที่ดีในการทำให้ข้อมูลผู้ใช้ซิงค์กันในอุปกรณ์ต่างๆ นี่เป็นปัญหาที่ยุ่งยากสำหรับข้อผิดพลาดและข้อผิดพลาดมากมาย แต่ผู้ใช้คาดหวังคุณลักษณะนี้และคาดหวังว่าคุณลักษณะนี้จะทำงานได้ดี
สำหรับ iOS และ macOS นั้น Apple มีชุดเครื่องมือที่มีประสิทธิภาพซึ่งเรียกว่า CloudKit API ซึ่งช่วยให้นักพัฒนาที่กำหนดเป้าหมายแพลตฟอร์ม Apple เพื่อแก้ปัญหาการซิงโครไนซ์นี้
ในบทความนี้ ผมจะสาธิตวิธีใช้ CloudKit เพื่อเก็บข้อมูลผู้ใช้ให้ตรงกันระหว่างไคลเอนต์หลายเครื่อง มีไว้สำหรับนักพัฒนา iOS ที่มีประสบการณ์ซึ่งคุ้นเคยกับเฟรมเวิร์กของ Apple และ Swift แล้ว ฉันจะเจาะลึกเทคนิคเชิงลึกใน CloudKit API เพื่อสำรวจวิธีที่คุณสามารถใช้เทคโนโลยีนี้เพื่อสร้างแอปสำหรับหลายอุปกรณ์ที่ยอดเยี่ยม ฉันจะเน้นที่แอปพลิเคชัน iOS แต่วิธีการเดียวกันนี้สามารถใช้กับไคลเอนต์ macOS ได้เช่นกัน
กรณีการใช้งานตัวอย่างของเราคือแอปพลิเคชันบันทึกย่อแบบง่ายที่มีบันทึกย่อเพียงตัวเดียวเพื่อวัตถุประสงค์ในการอธิบาย ระหว่างทาง ฉันจะดูแง่มุมที่ยากขึ้นบางประการของการซิงโครไนซ์ข้อมูลบนคลาวด์ ซึ่งรวมถึงการจัดการข้อขัดแย้งและพฤติกรรมของเลเยอร์เครือข่ายที่ไม่สอดคล้องกัน
CloudKit คืออะไร?
CloudKit สร้างขึ้นจากบริการ iCloud ของ Apple เป็นเรื่องที่ยุติธรรมที่จะบอกว่า iCloud เริ่มต้นขึ้นเล็กน้อย การเปลี่ยนผ่านจาก MobileMe อย่างงุ่มง่าม ประสิทธิภาพต่ำ และแม้แต่ข้อกังวลด้านความเป็นส่วนตัวบางอย่างก็รั้งระบบไว้ในช่วงต้นปี
สำหรับนักพัฒนาแอป สถานการณ์ยิ่งแย่ลงไปอีก ก่อน CloudKit พฤติกรรมที่ไม่สอดคล้องกันและเครื่องมือแก้ไขจุดบกพร่องที่อ่อนแอทำให้แทบจะเป็นไปไม่ได้เลยที่จะส่งมอบผลิตภัณฑ์คุณภาพสูงโดยใช้ iCloud API รุ่นแรก
อย่างไรก็ตาม เมื่อเวลาผ่านไป Apple ได้แก้ไขปัญหาเหล่านี้แล้ว โดยเฉพาะอย่างยิ่ง หลังจากการเปิดตัว CloudKit SDK ในปี 2014 นักพัฒนาจากภายนอกมีโซลูชันทางเทคนิคที่มีคุณลักษณะครบถ้วนและมีประสิทธิภาพสำหรับการแชร์ข้อมูลบนระบบคลาวด์ระหว่างอุปกรณ์ต่างๆ (รวมถึงแอปพลิเคชัน macOS และแม้แต่ไคลเอ็นต์บนเว็บ)
เนื่องจาก CloudKit เชื่อมโยงกับระบบปฏิบัติการและอุปกรณ์ของ Apple อย่างมาก จึงไม่เหมาะสำหรับแอปพลิเคชันที่ต้องการการสนับสนุนอุปกรณ์ในวงกว้าง เช่น ไคลเอ็นต์ Android หรือ Windows อย่างไรก็ตาม สำหรับแอปที่กำหนดเป้าหมายไปยังฐานผู้ใช้ของ Apple จะมีกลไกที่ทรงพลังอย่างล้ำลึกสำหรับการตรวจสอบสิทธิ์ผู้ใช้และการซิงโครไนซ์ข้อมูล
การตั้งค่า CloudKit พื้นฐาน
CloudKit จัดระเบียบข้อมูลผ่านลำดับชั้นของคลาส: CKContainer
, CKDatabase
, CKRecordZone
และ CKRecord
ที่ระดับบนสุดคือ CKContainer
ซึ่งห่อหุ้มชุดข้อมูล CloudKit ที่เกี่ยวข้อง ทุกแอปจะได้รับ CKContainer
เริ่มต้นโดยอัตโนมัติ และกลุ่มของแอปสามารถแชร์ CKContainer
ที่กำหนดเองได้หากการตั้งค่าการอนุญาตอนุญาต ที่สามารถเปิดใช้งานเวิร์กโฟลว์ข้ามแอปพลิเคชันที่น่าสนใจบางอย่างได้
ภายใน CKContainer แต่ละอันมี CKContainer
หลายอินสแตน CKDatabase
CloudKit จะกำหนดค่าทุกแอปที่เปิดใช้งาน CloudKit โดยอัตโนมัติเพื่อให้มี CKDatabase
สาธารณะ (ผู้ใช้แอปทุกคนสามารถเห็นทุกอย่าง) และ CKDatabase
ส่วนตัว (ผู้ใช้แต่ละคนเห็นเฉพาะข้อมูลของตนเองเท่านั้น) และสำหรับ iOS 10 นั้น CKDatabase
ที่ใช้ร่วมกันซึ่งกลุ่มที่ควบคุมโดยผู้ใช้สามารถแชร์รายการระหว่างสมาชิกของกลุ่มได้
ภายใน CKDatabase
คือ CKRecordZone
และภายในโซน CKRecord
คุณสามารถอ่านและเขียนระเบียน สืบค้นระเบียนที่ตรงกับชุดของเกณฑ์ และ (ที่สำคัญที่สุด) รับการแจ้งเตือนการเปลี่ยนแปลงใดๆ ข้างต้น
สำหรับแอป 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
ระดับที่สูงกว่า
แต่ก่อนอื่น ให้อภิปรายอย่างรวดเร็วเกี่ยวกับข้อผิดพลาดของ CloudKit
ข้อผิดพลาด CloudKit
การจัดการข้อผิดพลาดอย่างระมัดระวังเป็นสิ่งสำคัญอย่างยิ่งสำหรับไคลเอ็นต์ CloudKit
เนื่องจากเป็น API แบบเครือข่าย จึงอ่อนไหวต่อปัญหาด้านประสิทธิภาพและความพร้อมใช้งานทั้งหมด นอกจากนี้ ตัวบริการเองยังต้องป้องกันปัญหาที่อาจเกิดขึ้น เช่น คำขอที่ไม่ได้รับอนุญาต การเปลี่ยนแปลงที่ขัดแย้งกัน และอื่นๆ
CloudKit จัดเตรียมรหัสข้อผิดพลาดอย่างเต็มรูปแบบ พร้อมข้อมูลประกอบ เพื่อให้นักพัฒนาสามารถจัดการกับเคส Edge ต่างๆ และให้คำอธิบายโดยละเอียดแก่ผู้ใช้เกี่ยวกับปัญหาที่อาจเกิดขึ้น หากจำเป็น
นอกจากนี้ การดำเนินการของ 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) } }
The CloudKitNoteDatabase
Singleton
Apple มีฟังก์ชันการทำงานสองระดับใน CloudKit SDK: ฟังก์ชัน "ความสะดวก" ระดับสูง เช่น fetch()
, save()
และ delete()
และโครงสร้างการดำเนินการระดับล่างที่มีชื่อที่ยุ่งยาก เช่น CKModifyRecordsOperation
API อำนวยความสะดวกสามารถเข้าถึงได้มากขึ้น ในขณะที่แนวทางการดำเนินการอาจดูน่ากลัวเล็กน้อย อย่างไรก็ตาม Apple ขอแนะนำให้นักพัฒนาใช้การดำเนินการมากกว่าวิธีอำนวยความสะดวก
การทำงานของ CloudKit ให้การควบคุมที่เหนือกว่าในรายละเอียดเกี่ยวกับวิธีการทำงานของ CloudKit และอาจสำคัญกว่านั้นคือ บังคับให้นักพัฒนาคิดอย่างรอบคอบเกี่ยวกับพฤติกรรมเครือข่ายที่เป็นศูนย์กลางของทุกสิ่งที่ CloudKit ทำ ด้วยเหตุผลเหล่านี้ ฉันจึงใช้การดำเนินการในตัวอย่างโค้ดเหล่านี้
คลาส singleton ของคุณจะรับผิดชอบการดำเนินการ CloudKit แต่ละรายการที่คุณจะใช้ ในความเป็นจริง คุณกำลังสร้าง API ที่สะดวกขึ้นใหม่ แต่ด้วยการใช้งานด้วยตัวเองโดยอิงตาม Operation API แสดงว่าคุณอยู่ในที่ที่ดีในการปรับแต่งพฤติกรรมและปรับแต่งการตอบสนองการจัดการข้อผิดพลาดของคุณ ตัวอย่างเช่น หากคุณต้องการขยายแอปนี้เพื่อจัดการโน้ตหลายรายการแทนที่จะเป็นเพียงโน้ตเดียว คุณสามารถทำได้อย่างรวดเร็ว (และด้วยประสิทธิภาพที่สูงกว่า) มากกว่าที่คุณจะใช้เพียงแค่ 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 และโปรโตคอลผู้รับมอบสิทธิ์อย่างง่ายสามารถแจ้งให้ไคลเอนต์ทราบถึงการอัปเดตระยะไกลไปยังข้อมูล Note พื้นฐาน
import CloudKit enum CloudKitNoteError : Error { case noteNotFound case newerVersionAvailable case unexpected } public protocol CloudKitNoteDelegate { func cloudKitNoteChanged(note: CloudKitNote) } public class CloudKitNote : CloudKitNoteDatabaseDelegate { public var delegate: CloudKitNoteDelegate? private(set) var text: String? private(set) var modified: Date? private let recordName = "note" private let version = 1 private var noteRecord: CKRecord? public init() { CloudKitNoteDatabase.shared.delegate = self } // CloudKitNoteDatabaseDelegate call: public func cloudKitNoteRecordChanged(record: CKRecord) { // will be filled in below... } // … }
การทำแผนที่จาก CKRecord
ไปยัง Note
ใน Swift คุณสามารถเข้าถึงแต่ละฟิลด์ใน CKRecord
ได้ผ่านตัวดำเนินการตัวห้อย ค่าทั้งหมดสอดคล้องกับ CKRecordValue
แต่ค่าเหล่านี้กลับกลายเป็นชุดย่อยเฉพาะของประเภทข้อมูลที่คุ้นเคยเสมอ: NSString
, NSNumber
, NSDate
และอื่นๆ
นอกจากนี้ CloudKit ยังมีประเภทบันทึกเฉพาะสำหรับอ็อบเจ็กต์ไบนารี "ขนาดใหญ่" ไม่ได้ระบุจุดตัดเฉพาะ (แนะนำให้รวมสูงสุด 1MB สำหรับแต่ละ 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 ที่ส่งคืน CKError
แบบเต็มสาม CKRecord
เพื่อทำงานกับ:
- บันทึกเวอร์ชันก่อนหน้าที่คุณพยายามบันทึก
- เวอร์ชันที่แน่นอนของบันทึกที่คุณพยายามจะบันทึก
- เวอร์ชันที่เซิร์ฟเวอร์ถือครองเมื่อคุณส่งคำขอ
เมื่อดูที่ฟิลด์ที่ 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.