Core Data / SwiftData の iCloud 同期を実行して完了してからなにかする

このエントリのタイトルだけだとイメージがつきづらいかもしれないので、まず「Core Data / SwiftData の iCloud 同期を実行して完了してからなにか」したくなる状況について紹介しておきます。

最近 Core Data の iCloud 同期を利用した RSS リーダを作っています。ネットワークから RSS フィードを取得して Core Data のデータベースに保存して、アプリの View はデータベースからデータを読み取って表示しています。 Core Data を iCloud 同期するので、フィードだけでなく既読情報なども端末かんで共有することができます。

このアプリで、バックグラウンドでネットワークから RSS フィードを取得し、データベースに保存されていない新しいエントリはローカル push 通知でユーザに知らせると言う機能を作りたくなりました。この時、データベースの同期状態を気にせず「新しいエントリかどうか」を判断してしまうとまずいことになります。しばらく端末 A でアプリを使っていた場合、 iCloud 同期が走らない限りは端末 B のデータベースには端末 A で読んだエントリが反映されていません。そこで端末 B のバックグラウンドリフレッシュで取得したエントリを端末 B のデータベースに保存されていないからといって「新しい」と判断してしまうと、端末 A ですでに読んだエントリが新しいエントリとして通知されてしまって大変悲しいためです。

この問題を避けるには、端末 B のリフレッシュで RSS フィードを取得する前に Core Data の iCloud 同期を完全に済ませてしまいデータベースを最新の状態にしておけばよさそうです。 iCloud 同期を引き起こしてそれが完了してまで待ってから RSS フィードを取得したいので、ここで「Core Data / SwiftData の iCloud 同期を実行して完了してからなにか」したい状況が発生します。

まず、 iCloud 同期を実行する方法ですが、残念ながら「iCloud 同期を実行するぜ」というそのものずばりな API は提供されていません。しかし、 Core Data / SwiftData にデータを書き込むことで iCloud 同期を引き起こすことができます。もちろん必ず同期してくれる保証ないものの、手元で試す限りはネットワーク環境があればバックグラウンドリフレッシュにおいても同期してくれていそうです。しかも、書き込んだデータのエクスポートだけではなく、 iCloud 上のデータのインポートもしてくれるようなので、これでデータベースを最新の状況にすることができます。 書き込むデータはなんでも良さそうですが、自分のユースケースはバックグラウンドリフレッシュなので、そのバックグラウンドリフレッシュの履歴としてその発生時刻を持つモデルを作ってデータベースに追加することにしています。そのレコード一覧をアプリ内のデバッグメニューで表示することで、バックグラウンドリフレッシュがいつ起きたかを簡単に見られるので便利!

ただ、データの書き込みをしたからといって当然即時で iCloud 同期が完了するわけではないので、しばらく待って完全に同期し終わってから RSS を必要があります。しばらくというのがどれくらいかが難しくて、例えば勘で10秒待つとかでもだいたいよさそうな気がするが、もうちょっと根拠を持って同期が終わったことを判断したい。 そこで Core Data / SwiftData の iCloud 同期が実行されていることを可視化する - maiyama4's blog で紹介した NSPersistentCloudKitContainer.eventChangedNotification を利用します。 iCloud での同期関連の処理が走るとこのイベントが流れてくるので、これを監視します。データの書き込みをしてイベントが流れてくることを確認しつつ、それがひとしきり落ち着いたら同期が完了したということにするのがよさそうです。 「ひとしきり落ち着く」の定義はいろいろあり得そうですが、例えば最後のイベントが流れてから2秒間新しいイベントがなかったらひとしきり落ち着いたとしてよいんじゃないでしょうか。そのための実装を単純化したものを以下に示します。

public final class CloudSyncState {
    private var eventDebouncedTask: Task<Void, any Error>? = nil

    public init() {
        let publisher = NotificationCenter.default
            .publisher(for: NSPersistentCloudKitContainer.eventChangedNotification)

        Task {
            for await event in publisher.buffer(size: .max, prefetch: .byRequest, whenFull: .dropOldest).values {
                eventDebouncedTask?.cancel()
                eventDebouncedTask = Task {
                    try await Task.sleep(for: .seconds(2))
                    // イベントが落ち着いたら実行したい処理をここに書く
                }
            }
        }
    }
}

NSPersistentCloudKitContainer.eventChangedNotification イベントが流れるたびに「2秒間待って目的の処理を実行する」という Task をキャンセルしつつ同じ処理を起動しています。これにより、2秒以内の間隔でイベントが連続で来た場合は Task がキャンセルされて目的の処理は実行されず、最後のイベントから2秒間間隔があいた時に初めて処理を実行される状況を作ることができます。同じことが Combine でも簡単に書けそうですが、これからは Swift Concurrency の時代なので時流に乗っていこう。

この記事で紹介した方法はいまのところ手元ではうまく動作していますが、自分の知る限りはとくに Apple が推奨しているものではなく、また 100% うまく動くことが保証されていないのでご注意ください。