CloudKit + Core Data
#CloudKit #Core_Data
概要
iOS 13 より Core Data と CloudKit の連携が格段に楽になった。Core Data はその永続化ストアとしていくつか種類が選べるが、NSSQLiteStoreType を利用しており、かついくつかの制限さえ満たしていれば、既存の Core Data を利用したアプリにもとても簡単に CloudKit 連携を導入することができる。既存アプリで既に CloudKit コンテナを利用していた場合には、Core Data との互換性の問題から利用できないため、新規にコンテナを用意する必要がある。
特定のコンテナを設定したい場合は NSPersistentCloudKitContainerOptions で設定できる。iOS 14 からは private/public/shared も選べるようになっているようだ。
Using Core Data With CloudKit - WWDC 2019 - Videos
Mirroring a Core Data Store with CloudKit
ドキュメント
WWDC のセッションでも、ドキュメントを豊富に用意しましたと謳っている。
Setting Up Core Data with CloudKit
Creating a Core Data Model for CloudKit
Reading CloudKit Records for Core Data
Syncing a Core Data Store with CloudKit
https://www.raywenderlich.com/4878052-cloudkit-tutorial-getting-started
Core Data の基礎がわかってからでないとわからないと思うので見たほうがいい。Core Data の知識がある程度あればある程度わかると思う。
https://developer.apple.com/videos/play/wwdc2019/202/
Core Data モデリングの制限
CloudKit と連携する上で、以下の制限がある。
Entity にユニーク制約が付与できない
Attribute の型に Undefined と objectID が利用できない
Relationship は全て optional である必要がある
Relationship の操作が atomic にならない場合がある
Relationship は全て inverse を持つ必要がある
Relationship の deletion rule のうち、Deny はサポートされていない
ある Configuration 内の Entity は、他の Configuration の Entity と relationship を持ってはいけない
Creating a Core Data Model for CloudKit
Core Data モデルに合わせた CloudKit スキーマの初期化
開発中のリセット
開発中はまだ CloudKit のスキーマが確定していない可能性があるし、インテグレーションテストの際には毎回スキーマの初期化がしたいかもしれない。そのような場合には スキーマの初期化 が利用できる。ただし、プロダクションコードにスキーマの初期化は含めてはならない。
NSPersistentCloudKitContainer のセットアップ時に、shouldInitializeSchema を true に設定する.... と、document にはあるが、最新の環境 (Xcode 12, Swift5) だとこのようなフラグは存在しない。
initializeCloudKitSchema(options:) というメソッドがあるので、こちらを使えば良いらしい。doc に何もないように見えるが、Xcode からコメントを覗きにいくと、以下のような記述がある。
/*
This method creates a set of representative CKRecord instances for all stores in the container
that use Core Data with CloudKit and uploads them to CloudKit. These records are "fully saturated"
in that they have a representative value set for every field Core Data might serialize for the given
managed object model.
After records are successfully uploaded the schema will be visible in the CloudKit dashboard and
the representative records will be deleted.
This method returns YES if these operations succeed, or NO and the underlying error if they fail.
Note: This method also validates the managed object model in use for a store, so a validation error
may be returned if the model is not valid for use with CloudKit.
*/
open func initializeCloudKitSchema(options: NSPersistentCloudKitContainerSchemaInitializationOptions = []) throws
https://developer.apple.com/forums/thread/120453
プロダクションへの反映
スキーマをプロダクションに反映すると、ReordType や Attribute はもう変更できない。新しい RecordType の追加はできるし、既存の Record に新しい Property を追加することはできるが、修正したり削除することはできない。
詳しくは下記。
https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitQuickStart/DeployingYourCloudKitApp/DeployingYourCloudKitApp.html#//apple_ref/doc/uid/TP40014987-CH10
プロダクションスキーマの更新
CloudKit の RecordType やフィールドは変更不可能であるため、モデルに変更を加えたい場合は以下を検討する。
ユーザを互換性のある新しい Store にマイグレーションする。紐付けには NSPersistentCloudKitContainer が利用できる
新しいフィールドを既存の RecordType に追加する。ただし、前バージョンのアプリからはそのフィールドは読み取れない
Entity に Version attribute を含め、アプリバージョンによってフェッチするデータをフィルタする
Creating a Core Data Model for CloudKit
Core Data と CloudKit レコードのマッピング
概要
Core Data と CloudKit を連携させる場合、大抵は Core Data の Managed Object を直接触ることになる。が、その裏の CKRecord を直接触ることもできる。Core Data 由来で作成された CKRecord は、コンフリクト回避のために CD_ が prefix として名前に付与されている。ただし、CKRecord に直接アクセスする場合、Core Data がどのように CloudKit レコードにマッピングされているか知る必要がある。
WIP
大体以下に書いてある。型がどう変換されるとか、Relationship がどのように iCloud 上で実現されているかとか
https://developer.apple.com/documentation/coredata/mirroring_a_core_data_store_with_cloudkit/reading_cloudkit_records_for_core_data
Tips: データの一意性
既存の Core Data を利用したアプリで CloudKit を利用しようとした場合、問題が発生する場合がある。データの重複である。ある端末のデータがまず iCloud にアップロードされると、他の端末にそれがダウンロードされる。その端末内の既存のデータとは別に。結果、全てのデータが二重になってしまう。ということが起き得るらしい。
自分は体験していないからわからないけど、理由を聞くとなんとなくわかる。全てのデータは Attribute に UUID を持っていたらしいが、それは別に主キーというわけではない。CloudKit ではユニーク制約は使えないし。そのため、CloudKit からすると、どれとどれが同一のデータなのか はわからない。そのため、一度 CloudKit にアップロードされ、その後同期されたデータなら問題ないけど、元々端末内にあったデータについては、CloudKit からすればたまたま似たようなデータがその端末のローカルに存在しただけなので、無視して CloudKit からデータを DL してしまう。
https://moneymasterapp.wordpress.com/2019/09/17/core-data-cloudkit-syncing-existing-data/
Cloud からのデータ取得を通知する
NSPersistentStoreRemoteChangeNotificationPostOptionKey は、PersistentStore に変更があった時に通知してくれる。これを利用して iCloud からデータ取得したタイミングで通知を受け取ることも一応できる。
https://www.wwdcnotes.com/notes/wwdc19/230/
https://schwiftyui.com/swiftui/using-cloudkit-in-swiftui/
CloudKit の同期をアプリのバックグラウンド時に行う
方法が見つからなかった。
https://stackoverflow.com/questions/63766887/nspersistentcloudkitcontainer-does-not-sync-in-background
iCloud 同期がオフからオンに切り替わっても変更をマージできる
iCloud 同期設定をアプリ内から切り替える
cloudKitContainerOptions に nil を設定すると、NDPersistentCloudKitContainer が Cloud Container に接続しにいかなくなるので、同期が発生しなくなる。これは persistent store のロード前に実行しておく必要がある。この時、NSPersistentHistoryTokenKey をオンにしておく。これをしておくと、再び iCloud 同期を開始した際に、差分がマージされる。アプリ内で iCloud 同期の切り替えを行なった場合、persistentContainer の作成し直しが必要となる。
code:swift
lazy var persistentContainer: NSPersistentCloudKitContainer = {
let container = NSPersistentCloudKitContainer(name: "MyModel")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("Failed to retrieve a persistent store description.")
}
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
container.loadPersistentStores { _, error in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
return container
}
Toggle iCloud sync on/off for NSPersistentCloudKitContainer
Toggle sync with NSPersistentCloudKitContainer | Apple Developer Forums
iCloud 同期設定の確認をする
iCloud 同期設定/iCloud アカウントのアプリ外での変更を検知する
accountStatus(completionHandler:) を利用すると、iCloud アカウントの状態として CKAccountStatus を参照できる。さらに、CKAccountChangedNotification を利用すると、iCloud アカウントになんらかの変更が発生した時にそれを検知できる。
code:swift
NotificationCenter.default.addObserver(self,
selector: #selector(self.didChange(notification:)),
name: .CKAccountChanged,
object: nil)
@objc
func didChange(notification: NSNotification) {
CKContainer.default().accountStatus { status, error in
if let error = error {
fatalError("Failed to check iCloud account status. \(error.localizedDescription)")
}
switch status {
case .available:
// iCloud アカウントが利用可能
case .noAccount:
// iCloud アカウントログインされていない
// (ただし、iCloud 設定を端末の設定画面からオフにしてもこれになるようだ)
case .couldNotDetermine:
// なんらかの理由でステータスを判断できなかった
case .restricted:
// システムが iCloud アカウントへのアクセスを拒否された
@unknown default:
// 不明
}
}
}
https://cocoacasts.com/handling-account-status-changes-with-cloudkit
別の iCloud アカウントに切り替えた場合の挙動はどうなるか
以下のようなエラーが出て、CoreData ローカルのデータが勝手にリセットされた。ので、アプリケーション側でのハンドリングは不要そうだった
code:text
error error: CoreData+CloudKit: -NSCloudKitMirroringDelegate logResetSyncNotification:(2581): <NSCloudKitMirroringDelegate: 0x2833f2a40>: Sending 'NSCloudKitMirroringDelegateDidResetSyncNotificationName' with reason: 'AccountChange'
https://developer.apple.com/videos/play/wwdc2016/231/