$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

SwiftDataでiCloud同期のimportが成功した時にUI更新などの処理を実行する

SwiftUIとSwiftDataでiCloud同期対応のアプリを作っていて、他のデバイスでデータが変更された時にViewModelのデータを自動的に更新したいなと思った。CloudKitのimport通知を監視して実現できたのでメモしておく。

背景・課題

SwiftDataでiCloud同期を有効にすると、他のデバイスでのデータ変更が自動的に同期される。しかしViewModelでUIの状態管理をしている場合、ViewModelのデータ配列は自動更新されない。そのため画面に表示されるデータが古いままになってしまう問題があった。

ちなみにSwiftDataを使う場合、@Query を使う手もあるが、AI時代においてテストが書きづらいやり方を取りたくなかった背景もある。

やり方

NSPersistentCloudKitContainerの通知を監視することで解決できた。

import Combine
import CoreData
import SwiftData

@Observable
@MainActor
class TodoListViewModel {
  private let modelContext: ModelContext
  private(set) var todos: [TodoItem] = []
  private var cancellables: Set<AnyCancellable> = []

  init(modelContext: ModelContext) {
    self.modelContext = modelContext
    loadTodos()
    setupCloudKitNotificationObserver()
  }

  /// CloudKitでデータをimportした時に、todos配列を更新する
  private func setupCloudKitNotificationObserver() {
    NotificationCenter.default
      // 1. CloudKitのイベント変更通知を監視する
      .publisher(for: NSPersistentCloudKitContainer.eventChangedNotification)
      // 2. 通知からCloudKitイベントオブジェクトを取り出す
      .compactMap { notification -> NSPersistentCloudKitContainer.Event? in
        notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
          as? NSPersistentCloudKitContainer.Event
      }
      // 3. importイベントかつ成功したもののみを通過させる
      .filter { event in
        event.type == .import && event.succeeded
      }
      // 4. メインスレッドで処理する(UI更新のため)
      .receive(on: DispatchQueue.main)
      // 5. データを再読み込みする
      .sink { [weak self] _ in
        self?.loadTodos()
      }
      // 6. 購読を自動解除できるように
      .store(in: &cancellables)
  }

  /// SwiftDataからTodoアイテムを読み込んでtodos配列を更新
  func loadTodos() {
    let descriptor = FetchDescriptor<TodoItem>(sortBy: [
      SortDescriptor(\.order),
      SortDescriptor(\.id),
    ])
    todos = (try? modelContext.fetch(descriptor)) ?? []
  }
}

実装のポイントは以下の通り。

  • NSPersistentCloudKitContainer.eventChangedNotificationを監視する
  • 通知からイベントを取り出し、typeが.importかつsucceededがtrueの場合のみ処理する
  • メインスレッドで受け取るように.receive(on: DispatchQueue.main)を使う
  • import成功時にloadTodos()を呼んでデータを再読み込みする

ハマったポイント

最初はCloudKitの通知を受け取るタイミングがわからなかった。調べてみるとNSPersistentCloudKitContainer.Eventにはsetupimportexportの3つのタイプがある。他デバイスからのデータ同期時はimportタイプの通知が来るとわかった。また完了時にsucceededがtrueとなることがわかった。そのため、その情報を使ってフィルタリングした。

まとめ

こんな感じでNSPersistentCloudKitContainerの通知を監視することで、iCloud同期のimport成功時にViewModelのデータを更新できた。SwiftDataでiCloud同期対応アプリを作る時の参考になれば。

参考