最近のモバイルアプリケーション開発では、さまざまなデバイス間でユーザーデータの同期を維持するための綿密な計画が必要です。これは多くの落とし穴や落とし穴を伴う厄介な問題ですが、ユーザーはこの機能を期待しており、うまく機能することを期待しています。
iOSおよびmacOSの場合、Appleはと呼ばれる堅牢なツールキットを提供します CloudKit API 、これにより、Appleプラットフォームをターゲットとする開発者は、この同期の問題を解決できます。
この記事では、CloudKitを使用して、複数のクライアント間でユーザーのデータの同期を維持する方法を示します。を対象としています 経験豊富なiOS開発者 AppleのフレームワークとSwiftにすでに精通している人。 CloudKit APIについてかなり深く技術的に掘り下げて、このテクノロジーを活用してすばらしいマルチデバイスアプリを作成する方法を探ります。 iOSアプリケーションに焦点を当てますが、同じアプローチをmacOSクライアントにも使用できます。
このユースケースの例は、説明のために、1つのノートだけを持つ単純なノートアプリケーションです。その過程で、競合処理や一貫性のないネットワークレイヤーの動作など、クラウドベースのデータ同期のトリッキーな側面をいくつか見ていきます。
CloudKitは、AppleのiCloudサービスの上に構築されています。 iCloudは少し不安定なスタートを切ったと言っても過言ではありません。からの不器用な移行 MobileMe 、パフォーマンスの低下、およびプライバシーの懸念でさえ、初期のシステムを妨げていました。
アプリ開発者にとって、状況はさらに悪化しました。 CloudKit以前は、一貫性のない動作と弱いデバッグツールにより、第1世代のiCloudAPIを使用して最高品質の製品を提供することはほとんど不可能でした。
ただし、時間の経過とともに、Appleはこれらの問題に対処してきました。特に、のリリースに続いて 2014年のCloudKitSDK 、サードパーティの開発者は、デバイス(macOSアプリケーションやWebベースのクライアントを含む)間でのクラウドベースのデータ共有に対するフル機能の堅牢な技術ソリューションを持っています。
CloudKitはAppleのオペレーティングシステムとデバイスに深く関係しているため、AndroidやWindowsクライアントなど、幅広いデバイスサポートを必要とするアプリケーションには適していません。ただし、Appleのユーザーベースを対象とするアプリの場合、ユーザー認証とデータ同期のための非常に強力なメカニズムを提供します。
CloudKitは、クラスの階層(CKContainer
、CKDatabase
、CKRecordZone
、およびCKRecord
)を介してデータを編成します。
トップレベルはCKContainer
で、関連するCloudKitデータのセットをカプセル化します。すべてのアプリは自動的にデフォルトのCKContainer
を取得し、アプリのグループはカスタムCKContainer
を共有できます権限設定で許可されている場合。これにより、いくつかの興味深いクロスアプリケーションワークフローが可能になります。
各CKContainer
内CKDatabase
の複数のインスタンスです。 CloudKitは、CloudKit対応のすべてのアプリを、すぐに使用できるように自動的に構成して、パブリックCKDatabase
を作成します。 (アプリのすべてのユーザーがすべてを見ることができます)とプライベートCKDatabase
(各ユーザーには自分のデータのみが表示されます)。そして、iOS 10の時点で、共有CKDatabase
ユーザーが制御するグループは、グループのメンバー間でアイテムを共有できます。
CKDatabase
内CKRecordZone
sであり、ゾーン内CKRecord
sです。レコードの読み取りと書き込み、一連の基準に一致するレコードのクエリ、および(最も重要な)上記のいずれかの変更の通知の受信を行うことができます。
Noteアプリでは、デフォルトのコンテナーを使用できます。このコンテナ内では、プライベートデータベースを使用し(ユーザーのメモをそのユーザーだけに表示するため)、プライベートデータベース内では、特定の通知を有効にするカスタムレコードゾーンを使用します。変更を記録します。
メモは単一のCKRecord
として保存されますtext
、modified
を使用(DateTime)、およびversion
田畑。 CloudKitは内部のmodified
を自動的に追跡します値ですが、競合解決の目的で、オフラインの場合を含め、実際の変更時刻を知りたいと考えています。 version
フィールドは、アップグレードプルーフの優れた方法を示したものにすぎません。複数のデバイスを使用しているユーザーは、すべてのデバイスで同時にアプリを更新できない可能性があるため、防御が必要になる場合があります。
XcodeでiOSアプリを作成するための基本を十分に理解していることを前提としています。ご希望の場合、次のことができます ダウンロード このチュートリアル用に作成されたNoteAppXcodeプロジェクトの例を調べてください。
私たちの目的のために、UITextView
を含むシングルビューアプリケーションViewController
でその代理人で十分です。概念レベルでは、テキストが変更されるたびにCloudKitレコードの更新をトリガーする必要があります。ただし、実際問題として、定期的に起動するバックグラウンドタイマーなど、ある種の変更合体メカニズムを使用して、小さな変更が多すぎるiCloudサーバーへのスパムを回避することは理にかなっています。
CloudKitアプリでは、Xcodeターゲットの機能ペインでいくつかの項目を有効にする必要があります。CloudKitチェックボックス、プッシュ通知、バックグラウンドモード(具体的にはリモート通知)など、iCloud(当然)です。
CloudKitの機能については、2つのクラスに分けました。下位レベルCloudKitNoteDatabase
シングルトン以上のレベルCloudKitNote
クラス。
ただし、最初に、CloudKitエラーについて簡単に説明します。
CloudKitクライアントでは、注意深いエラー処理が絶対に不可欠です。
これはネットワークベースのAPIであるため、パフォーマンスと可用性の問題のホスト全体の影響を受けやすくなっています。また、サービス自体は、許可されていない要求、競合する変更など、さまざまな潜在的な問題から保護する必要があります。
CloudKitは提供します エラーコードの全範囲 、付随する情報とともに、開発者がさまざまなエッジケースを処理できるようにし、必要に応じて、考えられる問題についてユーザーに詳細な説明を提供します。
また、複数のCloudKit操作は、エラーを単一のエラー値として返すことも、トップレベルでpartialFailure
として示される複合エラーを返すこともあります。含まれているCKError
の辞書が付属しており、複合操作中に正確に何が起こったかを把握するために、より注意深く調べる必要があります。
この複雑さの一部をナビゲートするために、CKError
を拡張できます。いくつかのヘルパーメソッドを使用します。
すべてのコードには、重要なポイントに説明コメントがあることに注意してください。
import CloudKit extension CKError { public func isRecordNotFound() -> Bool return isZoneNotFound() 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()
、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には独自のCKError
がありますクラスはError
から派生しますが、他のエラーも発生する可能性があることに注意する必要があります。最後に、操作の最も重要な側面の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では、と呼ばれる特別なクラスの通知にすることもできます。 サイレントプッシュ 。これらのサイレントプッシュは、ユーザーの可視性や操作なしで完全に発生します。その結果、ユーザーがアプリのプッシュ通知を有効にする必要がないため、アプリ開発者としての潜在的なユーザーエクスペリエンスの頭痛の種を減らすことができます。
これらのサイレント通知を有効にする方法は、shouldSendContentAvailable
を設定することです。 CKNotificationInfo
のプロパティたとえば、従来の通知設定(shouldBadge
、soundName
など)をすべて未設定のままにします。
また、私はCKQuerySubscription
を使用しています。 1つの(そして唯一の)ノートレコードの変更を監視するための非常に単純な「常に真」の述語を使用します。より洗練されたアプリケーションでは、述語を利用して特定の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 subscriptionID = 'cloudkit-note-changes' 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に尋ねることができます。私の例では、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
からのマッピング注意しますSwiftでは、CKRecord
の個々のフィールド添え字演算子を使用してアクセスできます。値はすべてCKRecordValue
に準拠していますが、これらは常に、よく知られているデータ型の特定のサブセットの1つです: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にレコードの保存を依頼する場合、最後にレコードをフェッチしてから別のクライアントがレコードを更新するため、競合を処理する必要がある場合があります。これを見越して、保存機能を2つのステップに分割します。最初のステップでは、レコードの書き込みに備えて1回限りのセットアップを行い、2番目のステップでは、アセンブルされたレコードをシングルトンCloudKitNoteDatabase
に渡します。クラス。競合が発生した場合は、この2番目の手順を繰り返すことができます。
競合が発生した場合、CloudKitは、返されたCKError
で、3つの完全なCKRecord
を処理するために提供します。
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通知は、標準のiOS通知メカニズムを介して到着します。したがって、あなたのAppDelegate
application.registerForRemoteNotifications
を呼び出す必要がありますdidFinishLaunchingWithOptions
で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 AppStoreからの拒否の理由となる可能性があります。したがって、オフラインモードは慎重に検討する必要があります。
ここではそのような実装の詳細については説明しませんが、概要で十分です。
テキストと変更された日時の同じメモフィールドは、NSKeyedArchiver
を介してファイルにローカルに保存できます。など、およびUIは、このローカルコピーに基づいてほぼ完全な機能を提供できます。 CKRecords
をシリアル化することも可能ですローカルストレージとの間で直接。より高度なケースでは、オフラインの冗長性を目的としたシャドウデータベースとしてSQLiteまたは同等のものを使用できます。その後、アプリはOSが提供するさまざまな通知、特にCKAccountChangedNotification
を利用して、ユーザーがいつサインインまたはサインアウトしたかを認識し、CloudKitとの同期手順を開始できます(もちろん、適切な競合解決を含む)。ローカルのオフライン変更をサーバーにプッシュします。その逆も同様です。
また、CloudKitの可用性、同期ステータス、そしてもちろん、満足のいく内部解像度を持たないエラー状態のUI表示を提供することが望ましい場合があります。
この記事では、複数のiOSクライアント間でデータの同期を維持するためのコアCloudKitAPIメカニズムについて説明しました。
同じコードがmacOSクライアントでも機能することに注意してください。ただし、そのプラットフォームでの通知の動作方法の違いをわずかに調整します。
CloudKitは、特に高度なデータモデル、パブリック共有、高度なユーザー通知機能などのために、これに加えてはるかに多くの機能を提供します。
iCloudはAppleのお客様のみが利用できますが、CloudKitは、サーバー側の投資を最小限に抑えて、非常に興味深く、ユーザーフレンドリーなマルチクライアントアプリケーションを構築するための非常に強力なプラットフォームを提供します。
CloudKitをさらに深く掘り下げるには、時間をかけて表示することを強くお勧めします さまざまなCloudKitプレゼンテーション 最後のいくつかのWWDCのそれぞれから、それらが提供する例に従ってください。
関連: Swiftチュートリアル:MVVMデザインパターンの概要