CloudKitのガイド:iOSデバイス間でユーザーデータを同期する方法

公開: 2022-03-11

最近のモバイルアプリケーション開発では、さまざまなデバイス間でユーザーデータの同期を維持するためのよく考えられた計画が必要です。 これは多くの落とし穴や落とし穴に伴う厄介な問題ですが、ユーザーはこの機能を期待しており、うまく機能することを期待しています。

iOSおよびmacOSの場合、AppleはCloudKit APIと呼ばれる堅牢なツールキットを提供します。これにより、Appleプラットフォームを対象とする開発者はこの同期の問題を解決できます。

この記事では、CloudKitを使用して、複数のクライアント間でユーザーのデータの同期を維持する方法を示します。 これは、AppleのフレームワークとSwiftにすでに精通している経験豊富なiOS開発者を対象としています。 CloudKit APIについてかなり深く技術的に掘り下げて、このテクノロジーを活用して素晴らしいマルチデバイスアプリを作成する方法を探ります。 iOSアプリケーションに焦点を当てますが、同じアプローチをmacOSクライアントにも使用できます。

このユースケースの例は、説明のために、1つのメモだけを使用した単純なメモアプリケーションです。 その過程で、競合処理や一貫性のないネットワーク層の動作など、クラウドベースのデータ同期のトリッキーな側面のいくつかを見ていきます。

CloudKitを使用して複数のクライアント間でユーザーデータを同期する

CloudKitとは何ですか?

CloudKitはAppleのiCloudサービスの上に構築されています。 iCloudは少し不安定なスタートを切ったと言っても過言ではありません。 MobileMeからの不器用な移行、パフォーマンスの低下、そしてプライバシーの懸念でさえ、初期のシステムを妨げていました。

アプリ開発者にとって、状況はさらに悪化しました。 CloudKit以前は、一貫性のない動作と弱いデバッグツールにより、第1世代のiCloudAPIを使用して最高品質の製品を提供することはほとんど不可能でした。

ただし、時間の経過とともに、Appleはこれらの問題に対処してきました。 特に、2014年にCloudKit SDKがリリースされた後、サードパーティの開発者は、デバイス(macOSアプリケーションやWebベースのクライアントを含む)間でのクラウドベースのデータ共有に対するフル機能の堅牢な技術ソリューションを利用できます。

CloudKitはAppleのオペレーティングシステムやデバイスと密接に関連しているため、AndroidやWindowsクライアントなど、幅広いデバイスサポートを必要とするアプリケーションには適していません。 ただし、Appleのユーザーベースを対象とするアプリの場合、ユーザー認証とデータ同期のための非常に強力なメカニズムを提供します。

基本的なCloudKitセットアップ

CloudKitは、クラスの階層( CKContainerCKDatabaseCKRecordZone 、およびCKRecord )を介してデータを編成します。

トップレベルにはCKContainerがあり、関連するCloudKitデータのセットをカプセル化します。 すべてのアプリは自動的にデフォルトのCKContainerを取得し、権限設定で許可されている場合は、アプリのグループがカスタムCKContainerを共有できます。 これにより、いくつかの興味深いクロスアプリケーションワークフローが可能になります。

CKContainer内には、 CKDatabaseの複数のインスタンスがあります。 CloudKitは、すべてのCloudKit対応アプリを、パブリックCKDatabase (アプリのすべてのユーザーがすべてを表示できます)とプライベートCKDatabase (各ユーザーが自分のデータのみを表示)を持つように自動的に構成します。 また、iOS 10以降、ユーザーが制御するグループがグループのメンバー間でアイテムを共有できる共有CKDatabase

CKRecordZone内にはCKDatabaseがあり、ゾーン内にはCKRecordがあります。 レコードの読み取りと書き込み、一連の基準に一致するレコードのクエリ、および(最も重要な)上記のいずれかの変更の通知を受け取ることができます。

Noteアプリでは、デフォルトのコンテナーを使用できます。 このコンテナ内では、プライベートデータベースを使用し(ユーザーのメモをそのユーザーだけに表示するため)、プライベートデータベース内では、特定の通知を有効にするカスタムレコードゾーンを使用します。変更を記録します。

メモは、 textmodified (DateTime)、およびversionフィールドを持つ単一のCKRecordとして保存されます。 CloudKitは内部のmodified値を自動的に追跡しますが、競合解決の目的で、オフラインの場合を含め、実際の変更された時間を知ることができるようにする必要があります。 versionフィールドは、アップグレードプルーフの優れた方法を示したものにすぎません。複数のデバイスを使用しているユーザーは、すべてのデバイスで同時にアプリを更新できない可能性があるため、防御が必要になる場合があります。

ノートアプリの構築

XcodeでiOSアプリを作成するための基本を十分に理解していることを前提としています。 必要に応じて、このチュートリアル用に作成されたNoteAppXcodeプロジェクトの例をダウンロードして調べることができます。

私たちの目的では、 ViewControllerとしてViewControllerを備えたUITextViewを含む単一のビューアプリケーションで十分です。 概念レベルでは、テキストが変更されるたびにCloudKitレコードの更新をトリガーする必要があります。 ただし、実際問題として、定期的に起動するバックグラウンドタイマーなど、ある種の変更合体メカニズムを使用して、小さな変更が多すぎるiCloudサーバーへのスパムを回避することは理にかなっています。

CloudKitアプリでは、Xcodeターゲットの機能ペインでいくつかの項目を有効にする必要があります。CloudKitチェックボックス、プッシュ通知、バックグラウンドモード(具体的にはリモート通知)など、iCloud(当然)です。

CloudKit機能については、2つのクラスに分けました。低レベルの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に2つのレベルの機能を提供しています。fetch fetch()save()delete()などの高レベルの「便利な」関数と、 CKModifyRecordsOperationなどの面倒な名前の低レベルの操作構造です。

コンビニエンスAPIははるかにアクセスしやすく、操作アプローチは少し威圧的になる可能性があります。 ただし、Appleは開発者に、便利な方法ではなく操作を使用することを強くお勧めします。

CloudKitの操​​作は、CloudKitがどのように機能するかについての詳細を優れた方法で制御し、おそらくもっと重要なこととして、開発者にCloudKitが行うすべての中心となるネットワークの動作について慎重に考えるように強制します。 これらの理由から、これらのコード例の操作を使用しています。

シングルトンクラスは、使用するこれらのCloudKit操作のそれぞれを担当します。 実際、ある意味では、便利なAPIを再作成していることになります。 ただし、Operation APIに基づいて自分で実装することにより、動作をカスタマイズし、エラー処理応答を調整するのに適した場所に身を置くことができます。 たとえば、このアプリを拡張して1つだけではなく複数のNotesを処理する場合は、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クラスがありますが、他のエラーも発生する可能性があることに注意する必要があります。 最後に、操作の最も重要な側面の1つは、 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の最も価値のある機能の1つです。 これらはAppleの通知インフラストラクチャに基づいて構築されており、特定のCloudKitの変更が発生したときにさまざまなクライアントがプッシュ通知を受け取ることができます。 これらは、iOSユーザーに馴染みのある通常のプッシュ通知(サウンド、バナー、バッジなど)にすることも、CloudKitではサイレントプッシュと呼ばれる特別なクラスの通知にすることもできます。 これらのサイレントプッシュは、ユーザーの可視性や操作なしで完全に発生します。その結果、ユーザーがアプリのプッシュ通知を有効にする必要がないため、アプリ開発者としての潜在的なユーザーエクスペリエンスの問題を回避できます。

これらのサイレント通知を有効にする方法は、 CKNotificationInfoインスタンスにshouldSendContentAvailableプロパティを設定し、従来の通知設定( shouldBadgesoundNameなど)をすべて未設定のままにすることです。

また、非常に単純な「常に真」の述語を持つCKQuerySubscriptionを使用して、1つの(そして唯一の)Noteレコードの変更を監視していることにも注意してください。 より洗練されたアプリケーションでは、述語を利用して特定のCKQuerySubscriptionの範囲を狭めたり、CloudKitで利用可能な他のサブスクリプションタイプ( 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) }

レコードの読み込み

名前によるレコードの取得は非常に簡単です。 名前は、単純なデータベースの意味でのレコードの主キーと考えることができます(たとえば、名前は一意である必要があります)。 実際のCKRecordIDは、 zoneIDが含まれているという点で少し複雑です。

CKFetchRecordsOperationは、一度に1つ以上のレコードを操作します。 この例では、レコードは1つだけですが、将来の拡張性のために、これはパフォーマンス上の大きなメリットになる可能性があります。

 // 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に競合解決のための数少ないサーバー側メカニズムの1つを考案させるよりもはるかに優れたソリューションです。

アプリデザイナーは、これらの状況のルールを定義するのに常に最適な立場にあります。これには、コンテキスト認識の自動マージからユーザー主導の解決手順まで、あらゆるものが含まれます。 私の例では、あまり派手になるつもりはありません。 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通知は、レコードが別のクライアントによって更新されたことを確認する手段を提供します。 ただし、ネットワークの状態とパフォーマンスの制約により、個々の通知がドロップされたり、複数の通知が意図的に1つのクライアント通知に統合されたりする可能性があります。 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からNoteへのマッピング

Swiftでは、 CKRecordの個々のフィールドに添え字演算子を使用してアクセスできます。 値はすべてCKRecordValueに準拠していますが、これらは常に、 NSStringNSNumberNSDateなどの使い慣れたデータ型の特定のサブセットの1つです。

また、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にレコードを保存するように要求する場合、これは、最後にレコードをフェッチしてから別のクライアントがレコードを更新することによる競合を処理する必要がある場合があります。 これを見越して、保存機能を2つのステップに分割します。 最初のステップは、レコードの書き込みに備えて1回限りのセットアップを実行し、2番目のステップは、アセンブルされたレコードをシングルトンCloudKitNoteDatabaseクラスに渡します。 競合が発生した場合は、この2番目の手順を繰り返すことができます。

競合が発生した場合、CloudKitは、返されたCKErrorで、次の3つの完全なCKRecordを処理します。

  1. 保存しようとしたレコードの以前のバージョン、
  2. 保存しようとしたレコードの正確なバージョン、
  3. リクエストを送信したときにサーバーが保持していたバージョン。

これらのレコードのmodifiedフィールドを確認することで、どのレコードが最初に発生したか、したがってどのデータを保持するかを決定できます。 次に、必要に応じて、更新されたサーバーレコードをCloudKitに渡して、新しいレコードを書き込みます。 もちろん、これによりさらに別の競合が発生する可能性があります(間に別の更新が発生した場合)が、成功する結果が得られるまでプロセスを繰り返すだけです。

この単純なNoteアプリケーションでは、1人のユーザーがデバイスを切り替えるため、「ライブ同時実行」の意味であまり多くの競合が発生することはほとんどありません。 ただし、このような競合は他の状況から発生する可能性があります。 たとえば、ユーザーが機内モードで1つのデバイスを編集し、最初のデバイスで機内モードをオフにする前に、別のデバイスでうっかり別の編集を行った場合があります。

クラウドベースのデータ共有アプリケーションでは、考えられるすべてのシナリオに注意を払うことが非常に重要です。

 // 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から変更をフェッチするという手間のかかる作業を行います。 この例の場合、1つのノートレコードのみになりますが、これをさまざまなレコードタイプとインスタンスに拡張する方法を理解するのは難しくありません。

例として、正しいレコードを更新していることを確認するための基本的な健全性チェックを含め、フィールドを更新して、新しいデータがあることを代理人に通知します。

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