WWDC Quick Look 💓 By SwiftGGTeam
Build apps that share data through CloudKit and Core Data

Build apps that share data through CloudKit and Core Data

元の動画を見る

ハイライト

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 は shared CKRecordZone に格納
  • 各 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.circle SF 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 を動的調整
  • なぜ価値があるか: canEditcanDelete API で権限チェックが簡単になり、無権限操作を防げる
  • どう始めるか: SharingProvider を実装し、cellForRowAt と編集ボタンのロジックで呼び出す

4. テスト可能な共有モジュールを設計する

  • 何をするか: SharingProvider プロトコルで共有ロジックと UI を分離
  • なぜ価値があるか: 実 CloudKit なしで共有/非共有、読み取り専用/読み書きなど各シーンをユニットテスト可能
  • どう始めるか: SharingProvider を定義し、BlockBasedShareProvider mock を作成

5. クロスデバイス共有体験を構築する

  • 何をするか: NSPersistentCloudKitContainer の自動同期で、共有コンテンツを全デバイスでリアルタイム利用可能に
  • なぜ価値があるか: 1 台で共有招待を受け入れると、同じ Apple ID の他デバイスも自動的にアクセス権を得る
  • どう始めるか: acceptShareInvitations を正しく処理し、全デバイスで同じ Core Data Stack 設定を使う

関連セッション

コメント

GitHub Issues · utterances