CloudKit 가이드: iOS 기기 간에 사용자 데이터를 동기화하는 방법

게시 됨: 2022-03-11

오늘날의 최신 모바일 애플리케이션 개발에는 다양한 장치에서 사용자 데이터를 동기화 상태로 유지하기 위한 세심한 계획이 필요합니다. 이것은 많은 문제와 함정이 있는 골치 아픈 문제이지만 사용자는 이 기능을 기대하고 잘 작동할 것으로 기대합니다.

iOS 및 macOS의 경우 Apple은 Apple 플랫폼을 대상으로 하는 개발자가 이 동기화 문제를 해결할 수 있도록 하는 CloudKit API라는 강력한 도구 키트를 제공합니다.

이 기사에서는 CloudKit을 사용하여 여러 클라이언트 간에 동기화된 사용자 데이터를 유지하는 방법을 보여줍니다. Apple의 프레임워크와 Swift에 이미 익숙한 숙련된 iOS 개발자를 대상으로 합니다. 이 기술을 활용하여 멋진 다중 장치 앱을 만들 수 있는 방법을 탐색하기 위해 CloudKit API에 대해 상당히 심층적인 기술 분석을 할 것입니다. iOS 응용 프로그램에 중점을 둘 것이지만 macOS 클라이언트에도 동일한 접근 방식을 사용할 수 있습니다.

예제 사용 사례는 설명을 위해 단일 메모가 있는 간단한 메모 응용 프로그램입니다. 그 과정에서 충돌 처리 및 일관되지 않은 네트워크 계층 동작을 포함하여 클라우드 기반 데이터 동기화의 까다로운 측면을 살펴보겠습니다.

CloudKit을 사용하여 여러 클라이언트 간에 사용자 데이터 동기화

클라우드킷이란?

CloudKit은 Apple의 iCloud 서비스 위에 구축되었습니다. iCloud가 다소 불안정한 출발을 했다고 말할 수 있습니다. MobileMe에서 서투른 전환, 성능 저하 및 일부 개인 정보 보호 문제로 인해 초기에는 시스템이 보류되었습니다.

앱 개발자의 경우 상황은 더 나빴습니다. CloudKit 이전에는 일관성 없는 동작과 약한 디버깅 도구로 인해 1세대 iCloud API를 사용하여 최고 품질의 제품을 제공하는 것이 거의 불가능했습니다.

그러나 시간이 지남에 따라 Apple은 이러한 문제를 해결했습니다. 특히 2014년 CloudKit SDK가 출시된 후 타사 개발자는 장치(macOS 애플리케이션 및 웹 기반 클라이언트 포함) 간의 클라우드 기반 데이터 공유에 대한 모든 기능을 갖춘 강력한 기술 솔루션을 갖게 되었습니다.

CloudKit은 Apple의 운영 체제 및 장치와 깊이 연결되어 있으므로 Android 또는 Windows 클라이언트와 같이 광범위한 장치 지원이 필요한 애플리케이션에는 적합하지 않습니다. 그러나 Apple 사용자 기반을 대상으로 하는 앱의 경우 사용자 인증 및 데이터 동기화를 위한 매우 강력한 메커니즘을 제공합니다.

기본 CloudKit 설정

CloudKit은 CKContainer , CKDatabase , CKRecordZoneCKRecord 클래스 계층을 통해 데이터를 구성합니다.

최상위 수준에는 관련 CloudKit 데이터 세트를 캡슐화하는 CKContainer 가 있습니다. 모든 앱은 자동으로 기본 CKContainer 를 가져오고 권한 설정이 허용하는 경우 앱 그룹은 사용자 지정 CKContainer 를 공유할 수 있습니다. 이를 통해 몇 가지 흥미로운 애플리케이션 간 워크플로를 사용할 수 있습니다.

CKContainer 내에는 CKDatabase 의 여러 인스턴스가 있습니다. CloudKit은 공개 CKDatabase (앱의 모든 사용자가 모든 것을 볼 수 있음)와 비공개 CKDatabase (각 사용자가 자신의 데이터만 볼 수 있음)를 갖도록 모든 CloudKit 지원 앱을 즉시 구성합니다. 그리고 iOS 10부터 사용자 제어 그룹이 그룹 구성원 간에 항목을 공유할 수 있는 공유 CKDatabase 입니다.

CKRecordZone 내에는 CKDatabase 가 있고 영역 내에는 CKRecord 가 있습니다. 레코드를 읽고 쓰고, 기준 세트와 일치하는 레코드를 쿼리하고, (가장 중요하게) 위의 변경 사항에 대한 알림을 받을 수 있습니다.

Note 앱의 경우 기본 컨테이너를 사용할 수 있습니다. 이 컨테이너 내에서 개인 데이터베이스를 사용하고(사용자의 메모가 해당 사용자에게만 표시되기를 원하기 때문에) 개인 데이터베이스 내에서 특정 알림을 가능하게 하는 사용자 지정 레코드 영역을 사용할 것입니다. 변경 사항을 기록합니다.

메모는 text , modified (DateTime) 및 version 필드가 있는 단일 CKRecord 로 저장됩니다. CloudKit은 내부 modified 값을 자동으로 추적하지만 충돌 해결을 위해 오프라인 사례를 포함하여 실제 수정 시간을 알 수 있기를 원합니다. version 필드는 여러 기기를 사용하는 사용자가 동시에 모든 기기에서 앱을 업데이트할 수 없다는 점을 염두에 두고 업그레이드 증명을 위한 모범 사례의 예시일 뿐입니다. 따라서 방어가 필요합니다.

메모 앱 빌드

Xcode에서 iOS 앱을 만드는 기본 사항을 잘 알고 있다고 가정합니다. 원하는 경우 이 튜토리얼을 위해 생성된 예제 Note App Xcode 프로젝트를 다운로드하여 검사할 수 있습니다.

우리의 목적을 위해 ViewController 를 대리자로 하는 UITextView 를 포함하는 단일 보기 응용 프로그램으로 충분합니다. 개념적 수준에서 텍스트가 변경될 때마다 CloudKit 레코드 업데이트를 트리거하려고 합니다. 그러나 실제로는 주기적으로 실행되는 백그라운드 타이머와 같은 일종의 변경 병합 메커니즘을 사용하여 너무 많은 작은 변경으로 iCloud 서버에 스팸을 보내는 것을 방지하는 것이 좋습니다.

CloudKit 앱을 사용하려면 CloudKit 확인란, 푸시 알림 및 백그라운드 모드(특히 원격 알림)를 포함하여 iCloud(자연스럽게)와 같은 Xcode 대상의 기능 창에서 몇 가지 항목을 활성화해야 합니다.

CloudKit 기능의 경우 하위 수준 CloudKitNoteDatabase 싱글톤과 상위 수준 CloudKitNote 클래스의 두 가지 클래스로 나눴습니다.

그러나 먼저 CloudKit 오류에 대한 빠른 설명입니다.

CloudKit 오류

CloudKit 클라이언트에서는 신중한 오류 처리가 절대적으로 필요합니다.

네트워크 기반 API이기 때문에 성능 및 가용성 문제의 전체 호스트에 취약합니다. 또한 서비스 자체는 승인되지 않은 요청, 충돌하는 변경 등과 같은 다양한 잠재적 문제로부터 보호해야 합니다.

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 싱글톤

Apple은 CloudKit SDK에서 두 가지 수준의 기능을 제공합니다. fetch() , save()delete() 와 같은 높은 수준의 "편의성" 기능과 CKModifyRecordsOperation 과 같은 성가신 이름을 가진 낮은 수준의 작업 구성입니다.

편의 API는 훨씬 더 쉽게 액세스할 수 있지만 작업 방식은 약간 위협적일 수 있습니다. 그러나 Apple은 개발자에게 편리한 방법보다 작업을 사용하도록 강력히 촉구합니다.

CloudKit 작업은 CloudKit이 작업을 수행하는 방식에 대한 세부적인 제어를 제공하며, 아마도 더 중요하게는 개발자가 CloudKit이 수행하는 모든 작업의 ​​중심이 되는 네트워크 동작에 대해 신중하게 생각하도록 강제합니다. 이러한 이유로 이 코드 예제의 작업을 사용하고 있습니다.

싱글톤 클래스는 사용할 이러한 각 CloudKit 작업을 담당합니다. 실제로 어떤 의미에서는 편의 API를 다시 만들고 있습니다. 그러나 Operation API를 기반으로 직접 구현하면 동작을 사용자 지정하고 오류 처리 응답을 조정할 수 있는 좋은 위치에 놓이게 됩니다. 예를 들어, 하나가 아닌 여러 개의 메모를 처리하도록 이 앱을 확장하려는 경우 Apple의 편리한 API를 사용하는 것보다 더 쉽게(그리고 더 높은 결과 성능으로) 수행할 수 있습니다.

 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에는 Error 에서 파생된 자체 CKError 클래스가 있지만 다른 오류도 발생할 가능성을 알고 있어야 합니다. 마지막으로 모든 작업에서 가장 중요한 측면 중 하나는 qualityOfService 값입니다. 네트워크 지연, 비행기 모드 등으로 인해 qualityOfService 은 "utility" 이하의 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 기능 중 하나입니다. 특정 CloudKit 변경이 발생할 때 다양한 클라이언트가 푸시 알림을 받을 수 있도록 Apple의 알림 인프라를 기반으로 합니다. 이는 iOS 사용자에게 친숙한 일반 푸시 알림(예: 사운드, 배너 또는 배지)이거나 CloudKit에서 자동 푸시 라는 특별한 알림 클래스일 수 있습니다. 이러한 자동 푸시는 사용자 가시성 또는 상호 작용 없이 완전히 발생하므로 사용자가 앱에 대해 푸시 알림을 활성화할 필요가 없으므로 앱 개발자로서 잠재적인 사용자 경험 골칫거리가 많이 줄어듭니다.

이러한 자동 알림을 활성화하는 방법은 CKNotificationInfo 인스턴스에 shouldSendContentAvailable 속성을 설정하고 모든 기존 알림 설정( shouldBadge , soundName 등)을 설정하지 않은 채로 두는 것입니다.

또한, 저는 하나의 (유일한) Note 레코드의 변경 사항을 감시하기 위해 매우 간단한 "항상 참" 술어와 함께 CKQuerySubscription 을 사용하고 있습니다. 보다 정교한 애플리케이션에서는 술어를 활용하여 특정 CKQuerySubscription 의 범위를 좁힐 수 있고 CKDatabaseSuscription 과 같이 CKDatabaseSuscription 에서 사용 가능한 다른 구독 유형을 검토할 수 있습니다.

마지막으로 UserDefaults 캐시 값을 사용하여 불필요하게 구독을 두 번 이상 저장하지 않도록 할 수 있습니다. 설정에 큰 지장은 없으나, 네트워크와 서버 자원을 낭비하므로 가급적 피하는 것이 좋습니다.

 // 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) }

기록 로드 중

이름으로 레코드를 가져오는 것은 매우 간단합니다. 이름은 단순한 데이터베이스 의미에서 레코드의 기본 키로 생각할 수 있습니다(예: 이름은 고유해야 함). 실제 CKRecordIDzoneID 를 포함한다는 점에서 조금 더 복잡합니다.

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에 요청할 수 있습니다. 내 예에서는 CKFetchRecordZoneChangesOperationCKServerChangeTokens 를 사용하여 이 작업을 수행합니다. 변경 토큰은 가장 최근의 변경 시퀀스가 ​​발생하기 전의 위치를 ​​알려주는 책갈피와 같이 생각할 수 있습니다.

 // 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 에서 메모로 매핑

Swift에서 CKRecord 의 개별 필드는 첨자 연산자를 통해 액세스할 수 있습니다. 값은 모두 CKRecordValue 를 따르지만 NSString , NSNumber , NSDate 등과 같은 친숙한 데이터 유형의 특정 하위 집합 중 하나입니다.

또한 CloudKit은 "대형" 바이너리 개체에 대한 특정 레코드 유형을 제공합니다. 특정 컷오프 포인트는 지정되지 않았지만(각 CKRecord 에 대해 최대 총 1MB가 권장됨), 경험상 독립된 항목(이미지, 사운드, 텍스트 덩어리)처럼 느껴지는 모든 것입니다. 데이터베이스 필드는 아마도 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 에서 작업할 3개의 전체 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 알림은 표준 iOS 알림 메커니즘을 통해 도착합니다. 따라서 AppDelegatedidFinishLaunchingWithOptions 에서 application.registerForRemoteNotifications 를 호출하고 didReceiveRemoteNotification 을 구현해야 합니다. 앱이 알림을 받으면 생성한 구독과 일치하는지 확인하고, 그렇다면 CloudKitNoteDatabase 싱글톤으로 전달하세요.

 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) } }

팁: 푸시 알림은 iOS 시뮬레이터에서 완전히 지원되지 않으므로 CloudKit 알림 기능을 개발 및 테스트하는 동안 물리적 iOS 장치로 작업하고 싶을 것입니다. 시뮬레이터에서 다른 모든 CloudKit 기능을 테스트할 수 있지만 시뮬레이션된 장치에서 iCloud 계정에 로그인해야 합니다.

저기요! 이제 CloudKit API를 사용하여 iCloud에 저장된 애플리케이션 데이터에 대한 업데이트에 대한 원격 알림을 쓰고, 읽고, 처리할 수 있습니다. 더 중요한 것은 더 많은 고급 CloudKit 기능을 추가할 수 있는 기반이 있다는 것입니다.

또한 걱정할 필요가 없는 사용자 인증을 지적할 가치가 있습니다. CloudKit은 iCloud를 기반으로 하기 때문에 애플리케이션은 전적으로 Apple ID/iCloud 로그인 프로세스를 통한 사용자 인증에 의존합니다. 이는 앱 개발자의 백엔드 개발 및 운영 비용을 크게 절감할 것입니다.

오프라인 케이스 처리

위의 내용이 완전히 강력한 데이터 공유 솔루션이라고 생각하고 싶을 수 있지만 그렇게 간단하지는 않습니다.

이 모든 것이 암시하는 것은 CloudKit이 항상 사용 가능한 것은 아니라는 것입니다. 사용자는 로그인하지 않았거나 앱에 대해 CloudKit을 비활성화했을 수 있으며 비행기 모드에 있을 수 있습니다. 예외 목록은 계속됩니다. 앱을 사용할 때 활성 CloudKit 연결을 요구하는 무차별 대입 방식은 사용자의 관점에서 전혀 만족스럽지 않으며 실제로 Apple App Store에서 거부 사유가 될 수 있습니다. 따라서 오프라인 모드를 신중하게 고려해야 합니다.

여기에서 그러한 구현에 대한 세부 사항을 다루지는 않겠지만 개요는 충분해야 합니다.

텍스트 및 수정된 날짜 시간에 대한 동일한 메모 필드는 NSKeyedArchiver 등을 통해 파일에 로컬로 저장할 수 있으며 UI는 이 로컬 복사본을 기반으로 거의 모든 기능을 제공할 수 있습니다. 로컬 저장소에서 직접 CKRecords 를 직렬화하는 것도 가능합니다. 고급 사례에서는 SQLite 또는 이에 상응하는 것을 오프라인 중복성을 위한 섀도 데이터베이스로 사용할 수 있습니다. 그런 다음 앱은 다양한 OS 제공 알림, 특히 CKAccountChangedNotification 을 활용하여 사용자가 로그인 또는 로그아웃한 시점을 파악하고 CloudKit과 동기화 단계(물론 적절한 충돌 해결 포함)를 시작하여 로컬 오프라인을 푸시할 수 있습니다. 서버로의 변경 및 그 반대의 경우도 마찬가지입니다.

또한 CloudKit 가용성, 동기화 상태 및 물론 만족스러운 내부 해결이 없는 오류 조건에 대한 일부 UI 표시를 제공하는 것이 바람직할 수 있습니다.

CloudKit은 동기화 문제를 해결합니다.

이 기사에서는 여러 iOS 클라이언트 간에 데이터 동기화를 유지하기 위한 핵심 CloudKit API 메커니즘을 살펴보았습니다.

동일한 코드는 macOS 클라이언트에서도 작동하며 해당 플랫폼에서 알림이 작동하는 방식의 차이를 약간 조정합니다.

CloudKit은 특히 정교한 데이터 모델, 공개 공유, 고급 사용자 알림 기능 등에 대해 훨씬 더 많은 기능을 제공합니다.

iCloud는 Apple 고객만 사용할 수 있지만 CloudKit은 서버 측 투자를 최소화하면서 흥미롭고 사용자 친화적인 다중 클라이언트 응용 프로그램을 구축할 수 있는 믿을 수 없을 정도로 강력한 플랫폼을 제공합니다.

CloudKit에 대해 더 자세히 알아보려면 시간을 할애하여 최근 몇 개의 WWDC 각각의 다양한 CloudKit 프레젠테이션을 보고 그들이 제공하는 예제를 따라갈 것을 강력히 권장합니다.

관련: Swift 튜토리얼: MVVM 디자인 패턴 소개