ハイライト
iOS 15 では
NSPersistentCloudKitContainerに CloudKit Record Zone Sharing の完全サポートが追加されました。shared store description を追加し、share(_:to:completion:)を呼び、UICloudSharingControllerのコールバックを処理するだけで、Core Data のオブジェクトを複数の iCloud ユーザー間でリアルタイム同期でき、各参加者の権限に合った UI を提供できます。
主要内容
Photos の共有アルバムから始める
App で Photos の共有アルバムのような機能を実装すると想像してください。ユーザーがコンテンツ集合を作り、友人を招待し、全員が閲覧でき、一部は編集もできます。
以前は大量のカスタムコードが必要でした。バックエンド、リアルタイム同期、権限管理、競合解決。CloudKit はインフラを提供しましたが、Core Data に統合する作業量は依然として大きかったです。
iOS 15 の NSPersistentCloudKitContainer がこれをすべてパッケージ化しました。
2 つのデータベース、1 つのコンテキスト
共有機能の中核となるアーキテクチャ変更は、App が 2 つの CloudKit データベースに同時接続することです。
- Private Database: ユーザー自身が所有するデータ
- Shared Database: 他のユーザーが自分と共有したデータ
それぞれがローカルの persistent store にミラーリングされます。重要なのは、同じ NSManagedObjectContext で両方の store のデータにアクセスできることです。共有データのクエリもプライベートデータと同じコードで行えます。
共有ストレージの有効化
最初のステップは、Core Data Stack に shared store description を追加することです。
// [5:20](https://developer.apple.com/videos/play/wwdc2021/10015/?time=320)
let privateStoreDescription = container.persistentStoreDescriptions.first!
let storesURL = privateStoreDescription.url!.deletingLastPathComponent()
privateStoreDescription.url = storesURL.appendingPathComponent("private.sqlite")
privateStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
privateStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
let sharedStoreURL = storesURL.appendingPathComponent("shared.sqlite")
let sharedStoreDescription = privateStoreDescription.copy()
sharedStoreDescription.url = sharedStoreURL
let containerIdentifier = privateStoreDescription.cloudKitContainerOptions!.containerIdentifier
let sharedStoreOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: containerIdentifier)
sharedStoreOptions.databaseScope = .shared
sharedStoreDescription.cloudKitContainerOptions = sharedStoreOptions
container.persistentStoreDescriptions.append(sharedStoreDescription)
キーポイント:
- private store description をコピーし、URL を shared.sqlite に変更
NSPersistentCloudKitContainerOptionsを作成し、databaseScope = .sharedを設定- 2 つの store は同じ CloudKit container identifier を共有
- persistent history tracking と remote change notification を有効化する必要がある
共有の開始
オブジェクトの共有は数行で済みます。share(_:to:completion:) は UICloudSharingController と連携するよう設計されています。
// [6:00](https://developer.apple.com/videos/play/wwdc2021/10015/?time=360)
@IBAction func shareNoteAction(_ sender: Any) {
guard let barButtonItem = sender as? UIBarButtonItem else {
fatalError("Not a UI Bar Button item??")
}
guard let post = self.post else {
fatalError("Can't share without a post")
}
let container = AppDelegate.sharedAppDelegate.coreDataStack.persistentContainer
let cloudSharingController = UICloudSharingController {
(controller, completion: @escaping (CKShare?, CKContainer?, Error?) -> Void) in
container.share([post], to: nil) { objectIDs, share, container, error in
if let actualShare = share {
post.managedObjectContext?.performAndWait {
actualShare[CKShare.SystemFieldKey.title] = post.title
}
}
completion(share, container, error)
}
}
cloudSharingController.delegate = self
if let popover = cloudSharingController.popoverPresentationController {
popover.barButtonItem = barButtonItem
}
present(cloudSharingController, animated: true) {}
}
キーポイント:
container.share([post], to: nil)で post を含む新しい share を作成to: nilは新規 share を作成。既存 share を渡すとオブジェクトをその share に追加- completion で share のメタデータ(タイトルなど)を設定可能
UICloudSharingControllerが招待フローを処理
共有招待の受け入れ
ユーザーがメールの共有リンクをタップしたとき、App は招待を受け入れる必要があります。
// AppDelegate で処理
func application(_ application: UIApplication, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
let container = coreDataStack.persistentContainer
container.acceptShareInvitations(from: [cloudKitShareMetadata], into: sharedPersistentStore) { shares, error in
// NSPersistentCloudKitContainer が共有オブジェクトをローカル store に自動同期
}
}
キーポイント:
acceptShareInvitations(from:into:completion:)で招待を受け入れ- shared store を渡すと、コンテナが共有オブジェクトをローカルに同期
- 同期完了後、App の UI が新データを自動反映
Record Zone Sharing の仕組み
NSPersistentCloudKitContainer は従来の階層型共有ではなく、CloudKit の Record Zone Sharing を使います。
階層型共有では、レコードは root record(share)に関連付けられます。Record Zone Sharing では:
- 共有された
CKRecordは sharedCKRecordZoneに格納 - 各 shared zone は
CKShareレコードで識別 CKShareに所有者、参加者、権限などの情報が含まれる- NSPersistentCloudKitContainer が zone とレコードの割り当てを自動管理
各参加者は 2 つのデータベースにデータを持ちます。
- Private Database: 自分が所有する zone(共有の有無を問わず)
- Shared Database: 他人が自分と共有した zone
共有を意識した UI の構築
共有はデータ同期だけではありません。UI で共有状態を伝える必要があります。重要なシーン:
共有オブジェクトのマーク
// [17:58](https://developer.apple.com/videos/play/wwdc2021/10015/?time=1078)
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// ... dequeue cell ...
let post = dataProvider.fetchedResultsController.object(at: indexPath)
cell.title.text = post.title
if sharingProvider.isShared(object: post) {
let attachment = NSTextAttachment(image: UIImage(systemName: "person.circle")!)
let attributedString = NSMutableAttributedString(attachment: attachment)
attributedString.append(NSAttributedString(string: " " + (post.title ?? "")))
cell.title.text = nil
cell.title.attributedText = attributedString
}
return cell
}
キーポイント:
person.circleSF Symbol で共有コンテンツをマークsharingProvider.isShared(object:)でオブジェクトが共有中か判定
SharingProvider プロトコル
テストとモジュール化のため、講演者は SharingProvider プロトコルを設計しました。
// [17:06](https://developer.apple.com/videos/play/wwdc2021/10015/?time=1026)
protocol SharingProvider {
func isShared(object: NSManagedObject) -> Bool
func isShared(objectID: NSManagedObjectID) -> Bool
func participants(for object: NSManagedObject) -> [RenderableShareParticipant]
func shares(matching objectIDs: [NSManagedObjectID]) throws -> [NSManagedObjectID: RenderableShare]
func canEdit(object: NSManagedObject) -> Bool
func canDelete(object: NSManagedObject) -> Bool
}
このプロトコルの利点:
- 各メソッドが具体的な UI 判断に対応
- ユニットテスト用の mock 実装を注入しやすい
- CloudKit の詳細と UI コードを分離
SharingProvider の実装
// [20:01](https://developer.apple.com/videos/play/wwdc2021/10015/?time=1201)
extension CoreDataStack: SharingProvider {
func isShared(object: NSManagedObject) -> Bool {
return isShared(objectID: object.objectID)
}
func isShared(objectID: NSManagedObjectID) -> Bool {
var isShared = false
if let persistentStore = objectID.persistentStore {
if persistentStore == sharedPersistentStore {
isShared = true
} else {
let container = persistentContainer
do {
let shares = try container.fetchShares(matching: [objectID])
if nil != shares.first {
isShared = true
}
} catch let error {
print("Failed to fetch share for \(objectID): \(error)")
}
}
}
return isShared
}
func canEdit(object: NSManagedObject) -> Bool {
return persistentContainer.canUpdateRecord(forManagedObjectWith: object.objectID)
}
func canDelete(object: NSManagedObject) -> Bool {
return persistentContainer.canDeleteRecord(forManagedObjectWith: object.objectID)
}
}
キーポイント:
- オブジェクトの persistent store が shared store か確認
fetchShares(matching:)で share 情報を取得canUpdateRecord(forManagedObjectWith:)で編集権限を確認canDeleteRecord(forManagedObjectWith:)で削除権限を確認
権限制御
共有作成者は参加者の権限を設定できます。
- Read-Only: 閲覧のみ。編集・削除不可
- Read-Write: 閲覧、編集、コンテンツ追加が可能
UI は現在ユーザーの権限に応じて動的に調整する必要があります。
- 読み取り専用参加者には Edit ボタンを表示しない
- 読み取り専用の共有コンテンツはスワイプ削除不可
- 編集モードでも読み取り専用コンテンツは削除不可
詳細
共有 UI のテスト
講演者は共有関連 UI のテストの重要性を強調しました。SharingProvider プロトコルにより mock の注入が容易です。
// [18:44](https://developer.apple.com/videos/play/wwdc2021/10015/?time=1124)
func testSharedPostsGetDisclosure() {
var sharedObjectIDs: Set<NSManagedObjectID> = Set()
let context = coreDataStack.persistentContainer.viewContext
self.generatePosts(in: context, postSaveBlock: { posts in
for (index, post) in posts.enumerated where (index % 4) == 0 {
sharedObjectIDs.insert(post.objectID)
}
})
let provider = BlockBasedShareProvider(stack: coreDataStack)
provider.isSharedBlock = sharedObjectIDs.contains
mainViewController.sharingProvider = provider
// ... perform fetch and verify cells ...
for index in 0..<rowCount {
let indexPath = IndexPath(row: index, section: 0)
let post = mainViewController.dataProvider.fetchedResultsController.object(at: indexPath)
guard let cell = mainViewController.tableView(
mainViewController.tableView,
cellForRowAt: indexPath
) as? PostCell else {
XCTFail("Encountered an unexpected cell type")
return
}
if sharedObjectIDs.contains(post.objectID) {
guard let attachment = cell.title.attributedText?
.attributes(at: 0, effectiveRange: nil)[.attachment] as? NSTextAttachment else {
XCTFail("Expected an image attachment at the first character")
return
}
XCTAssertEqual(expectedSharedImage, attachment.image)
} else {
XCTAssertEqual(cell.title.text, title)
}
}
}
キーポイント:
BlockBasedShareProviderでカスタム動作を注入isSharedBlockでどのオブジェクトを共有とみなすか制御- 共有オブジェクトの cell に person.circle アイコンが表示されることを検証
- 非共有オブジェクトは通常のテキストタイトルを表示することを検証
共有の境界
NSPersistentCloudKitContainer はオブジェクト間の関係から、共有に含めるレコードを推論できます。オブジェクト A が B に関連していれば、A を共有すると B も含まれます。
明示的に CKShare パラメータを渡して、オブジェクトをどの共有 zone に割り当てるか制御することもできます。
重要ポイント
1. ファミリー共有型の ToDo App を構築する
- 何をするか: 家族の買い物リストやタスク管理 App でメンバーがリアルタイム協業
- なぜ価値があるか:
NSPersistentCloudKitContainerが同期、競合解決、権限管理を処理し、開発者は UI に集中できる - どう始めるか: Core Data Stack に shared store description を追加し、
UICloudSharingControllerで共有を開始
2. ノート App に協業機能を追加する
- 何をするか: 特定のノートを同僚や友人と共有して共同編集
- なぜ価値があるか: 共有粒度は単一の
NSManagedObjectまで。他のプライベートノートは露出しない - どう始めるか: ノートに share ボタンを追加し、
container.share([note], to: nil)を呼ぶ
3. 権限を意識した UI を実装する
- 何をするか: 現在ユーザーの役割(所有者/読み取り専用/読み書き)に応じて UI を動的調整
- なぜ価値があるか:
canEditとcanDeleteAPI で権限チェックが簡単になり、無権限操作を防げる - どう始めるか:
SharingProviderを実装し、cellForRowAtと編集ボタンのロジックで呼び出す
4. テスト可能な共有モジュールを設計する
- 何をするか:
SharingProviderプロトコルで共有ロジックと UI を分離 - なぜ価値があるか: 実 CloudKit なしで共有/非共有、読み取り専用/読み書きなど各シーンをユニットテスト可能
- どう始めるか:
SharingProviderを定義し、BlockBasedShareProvidermock を作成
5. クロスデバイス共有体験を構築する
- 何をするか: NSPersistentCloudKitContainer の自動同期で、共有コンテンツを全デバイスでリアルタイム利用可能に
- なぜ価値があるか: 1 台で共有招待を受け入れると、同じ Apple ID の他デバイスも自動的にアクセス権を得る
- どう始めるか:
acceptShareInvitationsを正しく処理し、全デバイスで同じ Core Data Stack 設定を使う
関連セッション
- What’s new in CloudKit — CloudKit Record Zone Sharing の低レベル機構を深掘り
- Bring Core Data concurrency to Swift and SwiftUI — Core Data の Swift Concurrency サポート。共有データと組み合わせて使用
- Synchronizing a local store to the cloud — Core Data と CloudKit 同期の基礎知識
コメント
GitHub Issues · utterances