PR

SwiftDataとCloudKitとSubscriptionと私

Swiftのお勉強という名目でアプリ作成中のMittyです。皆様いかがお過ごしでしょうか。

同一Apple ID間で端末や接続した機器のバッテリ情報を同期させるアプリを作成中です。色々と壁にあったてはよじ登りを繰り返しており、忘れる前に、未来の自分への備忘録としてSwiftDataやRemote Notificationなどの情報を記載しておきます。

SwiftDataとは

SwiftData | Apple Developer Documentation
Write your model code declaratively to add managed persistence and efficient model fetching.

そもそも「SwiftDataはCoreDataをベースとしたフレームワークである」という事をわかっていたつもりで完全にスルーしていました(汗。SwiftDataを見つける前はCoreDataを調べていたにもかかわらず、リンクできていませんでしたね…そんなこんなでなんとかSwiftDataを利用できるようになったので、その手順を備忘録として記載します。

コンテナ作成と各種設定

まずはアプリ用のコンテナを用意します。適当なコンテナIDではなく「iCloud.Bundle ID」を利用した方が良さそうです。「Bundle ID」はiOSとWatchOSとで異なりますが、メインのiOSで設定している名前にしました。

iCloud経由で共有するので「Capability」に「iCloud」を追加します。今回は「CloudKit」にチェックを入れて、「Containers」に作成した(もしくは+で新規作成)コンテナにチェックを入れます。

Preserving your app’s model data across launches | Apple Developer Documentation
Describe your model classes to SwiftData using the framework’s macros, and store instances of those models so they exist...
Swift
import SwiftUI
import SwiftData

@main
struct BatteryMonitorCheckApp: App {
    var sharedModelContainer: ModelContainer = BatteryModel.sharedContainer

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

.modelContainer(sharedModelContainer)でコンテナをUI系の他階層に渡せます。

Swift
class BatteryModel {
    var device: BatteryInfo?

    public static let sharedContainer: ModelContainer = {
        let schema = Schema([
            BatteryInfo.self
        ])
        let memory: Bool = {
            #if targetEnvironment(simulator)
                return true
            #else
                return false
            #endif
        }()
        let modelConfiguration = ModelConfiguration(isStoredInMemoryOnly: memory)

        do {
            let container = try ModelContainer(for: schema, configurations: [modelConfiguration])
            return container
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()
}

シュミレーターではメモリ上のものを利用するようにisStoredInMemoryOnlyの値を変更しています。それ以外はほぼサンプルコードのままです。

モデルの定義

Swift
import Foundation
import SwiftData

@Model
public class BatteryInfo: Identifiable {

    var uuid: UUID = UUID()
    var level: Int = 0
    var state: BatteryState = BatteryState.unknown
    var time: Date = Date()
    var name: String = ""
    
    var timestamp: String {
        let formatter = DateFormatter()
        formatter.dateFormat = "HH:mm:ss.SSS"
        return formatter.string(from: time)
    }
    
    init(uuid: UUID, level: Int, state: BatteryState, time: Date, name: String) {
        self.uuid = uuid
        self.level = level
        self.state = state
        self.time = time
        self.name = name
    }
}

enum BatteryState: String, Codable{
    case full = "full"
    case unplugged = "unplugged"
    case charging = "charging"
    case unknown = "unknown"
}

Schemaに渡すModelを定義します。クラス作って@Modelで宣言するだけ。色々と制限があるので、コンパイル可能なようになるまで、プロパティを変更しつつデータ構造を決めます。

Viewからの参照

Swift
import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var devices: [BatteryInfo]
    @State private var model = BMonDeviceModel.shared
    @State private var nameInput: String = ""

    var body: some View {
        NavigationStack {
            List {
                ForEach(devices, id: \.self.data.last!.id) { (device: BatteryInfo) in
                    NavigationLink(value: device) {
                        ListView(device: device)
                            .frame(height: 50)
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .navigationDestination(for: BatteryInfo.self) { device in
                DetailView(device: device, name: device.name)
            }
            .toolbar {
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        }
        .onAppear() {
            model.setModelContext(context: modelContext)
        }
    }

    private func addItem() {
        withAnimation {
            let newItem = BatteryInfo(uuid: UUID(),
                                      level: 66,
                                      state: .unplugged,
                                      time: Date(),
                                      name: "iPhone 16")
            modelContext.insert(newItem)
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(devices[index])
            }
        }
    }
}

「@Environment(.modelContext) private var modelContext」でViewで使うコンテキストを取得して、「@Query private var devices: [BatteryInfo]」により@Modelで登録したスキーマでデータベースに変更がある場合は自動で更新を検出して描画されると。

簡単で良いですね。CoreDataだとツール使って定義したりと結構面倒な感じっだったので。今回のやりたい事としては「情報が更新されたら各端末間でデータを共有する事」なのでSwiftData関連作業はおしまいです。

CloudKit Consoleとの関係

CloudKitを使ってはいたが、理解が足りなかった(汗。

SwiftDataで実装したデータたちがどのようにiCloud上に展開されているかを知りたい場合はCloudKit consoleでちゃんとデータが更新されているかなどをチェックすることができます。前回のDocumentsベースの利用では意識しませんでしたが、データベースやゾーンの関係については以下の公式がわかりやすいです。

CloudKitを利用した設計 - iCloud - Apple Developer
CloudKitにはパワフルなクラウドアプリを簡単に開発するための包括的な機能セットが用意されています。

Recordの確認

CloudKit ConsoleのデータベースでQueryするためにはスキーマのIndexesにある対象のレコードに「recordeName」を追加するらしいです。Google先生が教えてくれました(汗。レコードのメタデータにすでにいたのであると思ってたけど手動で追加する必要があるそうです。上の画像ではすでに作成済みなため、一番下にrecordNameがいますね。

recordNameを追加後は、データベースを「Private Database」にして、ゾーンは今回のケース(特に指定していなければ)では「com.apple.coredata.cloudkit.zone」を選択します。レコードタイプなどを選択してQueryすると現在保存されているデータをみることができます。

Zoneの確認

ゾーンに関してもデータベース(Public/Private)を選択すると現在作成されているゾーンが表示されます。何も指定してないから、ずーっとデフォルトだと思っていましたが、基本カスタムゾーンなんすね。

「com.apple.coredata.cloudkit.zone」は自動で作成されるカスタムゾーンってことですね。基本指定しなければこのゾーンが作成されていると思います。下にあるSubscriptionは次の章で説明します。

Remote NotificationとSubscription

SwiftDataを使い始めてから、アプリが「Foreground」か「Background」にいる間はデータ更新が自動で反映されている事を確認していましたが、「Suspended」にいると更新されない事が気になっていました。ちなみに「Background 」でもアプリは実行されているが、「Supended」はシステムがバックグランドにいるアプリを止めている状態でメモリ上には存在するらしい。

また、データ同期に関して調べていると公式サイトには「これやってみなよユー」的な感じで説明があります。

SwiftData requires two separate capabilities to perform automatic iCloud sync: the iCloud capability, which lets you configure CloudKit, and the Background Modes capability, which lets your app receive remote notifications from CloudKit that contain information about new changes on the server.

Add-the-iCloud-and-Background-Modes-capabilities

要は「CapabilityのiCloudとBackground Notificationにチェックいれると自動でRemote Notification (Subscription)が追加されるよ。」って事らしい。

手続き(各種設定)を済ませてから、実機で動作さていると「Xcodeからの実行中はBackgroundに居続ける」事がわかりました。Xcodeからのデバッグ中は応答性が良いなと思っていましたが…とりあえずわかったのは普通にアプリをバックグラウンド(ホーム画面に戻ったりとか)にするとあっとうまにサスペンドされるってことですね。システムが決めているので理由はわかりませんが。

で、サスペンド中にイベントを受信するためにはUser Notificationが必要とのことで、Remote Notificationを調べたところ、「Notificationを受けるためにはSubscriptionの登録が必要」ということがわかりました。

結論:自動で追加されるSubscriptionはSuspendでも更新している

そもそも自動で追加されていると言ってもコード上に何かある訳ではなく、「Cloudkit Console」なるデータベースの確認ツールで初めて気がつく事ができました。「com.apple.coredata.cloudkit.private.subscription」が自動で追加されています。最初みた時に「なんじゃこれ」って削除してしまい、その後のアプリの挙動が変わってデバッグに数日使ってしまいました。最終的に上記の公式にある「二つのCapabilityの追加でRemote Notificationが有効になる」の意味に気が付くまで一週間ほど旅をしていたのは内緒です。

で、この更新のタイミングはシステム側の色々な状況が影響するらしいです。「更新されないやんけ」とコード変えたり、デバッグしてみましたが、結論として更新されていました。サスペンドに入ったことはアプリ側で検知できないので、ログベースで確認していると起動してバックグラウンドに入れてしばらく放置したアプリがたまに更新されている事がわかりました。いくつかのSubscriptionを試しましたが、サーバーとの同期の観点だけだと、デフォルトで追加されるこのサブスクだけでことが足りる事がわかりましたとさ。

CKQuerySubscriptionを使ってみた

自動追加のサブスク以外で試したうちの一つが「CKQuerySubscription」です。自動で追加されるものにはコールバック的なものもないのですが、明示的に登録するサブスクにはいくつかの手続きが必要になります。SwiftUIで始めた私の場合はUIApplicationDelegateを継承したクラスを作成して起動時の手続きが必要です。

Swift
class BMonAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    var sharedModelContainer: ModelContainer = BatteryModel.sharedContainer
    let model: BMonDeviceModel = BMonDeviceModel.shared

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound],
                                                                completionHandler: { authorized, error in
            if authorized {
                DispatchQueue.main.async(execute: {
                    application.registerForRemoteNotifications()
                })
            }
        })
        Deb.log("\(#function) called")
        return true
    }

ユーザの許可を経て「application.registerForRemoteNotifications()」の呼び出してリモート登録を開始します。

Swift
func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    guard !UserDefaults.standard.bool(forKey: "didCreateBatteryInforSubscription") else {
        Deb.log("\(#function) \(deviceToken) already created subscription")
        return
    }
    let predicate = NSPredicate(value: true)
    let subscription = CKQuerySubscription(recordType: "CD_BatteryInfo",
                                           predicate: predicate,
                                           subscriptionID: "battery-info-query",
                                          options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion])
    
    subscription.zoneID = CKRecordZone(zoneName: "com.apple.coredata.cloudkit.zone").zoneID
    
    let notificationInfo = CKSubscription.NotificationInfo()
    notificationInfo.shouldSendContentAvailable = true
    subscription.notificationInfo = notificationInfo
    CKContainer.default().privateCloudDatabase.save(subscription, completionHandler: {subscription, error in
      if let error = error {
          Deb.log("\(#function) error \(error)")
      }
      guard let subscription = subscription else { return }
      Deb.log("\(#function) subscription successes \(subscription)")
    })
    Deb.log("\(#function) subscription has been requested \(deviceToken)")
}

登録開始後に「didRegisterForRemoteNotificationsWithDeviceToken」がコールされるのでここでサブスクを作成して登録します。注意点としては第一引数のrecordTypeが「CD_ 」付きで指定しないとサブスク登録時にサーバー側から「見つからね」とエラーが出る事です。上記コードではゾーン指定していますが、これはなくてもOKです。

Swift
func application(_ application: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable : Any],
                 fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void)
{
    if model.__background__ == false {
        model.updateAllModel()
        completionHandler(UIBackgroundFetchResult.newData)
        Deb.log("\(#function) called with bacground = \(model.__background__) \(userInfo)")
    } else {
        Task {
            do {
                try await model.backgroundSave()
            } catch {
                Deb.log("\(#function) \(error)")
            }
            completionHandler(UIBackgroundFetchResult.newData)
            Deb.log("\(#function) called with bacground = \(model.__background__) \(userInfo)")
        }
    }
}

通知を受ける関数がこれになります。ユーザに通知をする場合は登録時に設定したデータがuserInfoで流れてくるのですが、今回はサイレント通知なので必要な処理はありません。バックグラウンド時の処理とか色々と試している最中です。サイレントでバックグラウンドモードまで移行できる方法がないかと検討中。

CKDatabaseSubscriptionを使ってみた

CKDatabaseSubscription | Apple Developer Documentation
A subscription that generates push notifications when CloudKit modifies records in a database.
Swift
func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
  guard !UserDefaults.standard.bool(forKey: "didCreateBatteryInforSubscription") else {
      Deb.log("\(#function) \(deviceToken) already created subscription")
      return
  }
  let subscription = CKDatabaseSubscription(subscriptionID: "battery-info")
  subscription.recordType = "BatteryInfo"
  let notificationInfo = CKSubscription.NotificationInfo()
  notificationInfo.shouldBadge = false
  notificationInfo.shouldSendContentAvailable = true
  subscription.notificationInfo = notificationInfo
  
  let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription],
                                                 subscriptionIDsToDelete: nil)
  operation.modifySubscriptionsResultBlock = { result in
      UserDefaults.standard.set(true, forKey: "didCreateBatteryInforSubscription")
  }
  operation.qualityOfService = .utility
  CKContainer.default().privateCloudDatabase.add(operation)
  Deb.log("\(#function) subscription has been requested \(deviceToken)")
}

基本的に使い方はQuery版と同じ。登録時のAPIが異なるだけで、こっちはサンプルコードのままです。こっちのレコードタイプはCD_いらないんです。あと注意点としてはリザルトブロックがありますが、このresultがサーバーでエラー吐いててもsuccessが返ってくるからそのまま成功したと思っちゃうくらいですかね。このケースだと別のハンドラが呼ばれてるのかな。データベースサブスクだと自動追加サブスクと変わらないので実際使うのはQuery版を利用する予定です。サスペンドから通知受けたあとのデータ更新処理とWidgetへの強制更新ができるか調べてます。

雑感

今回はSwiftDataとRemote Notificationについて色々と調べた事をまとめました。フレームワークは常にアップデートしているので、昔からあるCoreDataとの絡みなど調べると色々と問題があった事がわかりますね。Swift自体は素人でもとっつきやすい言語ではありますが、隠されている場所など不明なところも多数あるので実機でのデバッグで気になるところが調べられないのが辛いです。昔からSwift環境で仕事している人の強みはこの辺りにあるのでしょう。

バッテリーをモニターするアプリ作成など「二週間で終わらせるぜ」と意気込んでいたのも束の間、あっという間に一ヶ月経過してしまいました(汗。Apple Watchの実装やWidget、Complicationなど現在も調査中の状況ではありますが、あと一ヶ月以内にはリリースまで行きたいと思っている今日この頃です。

コメント

タイトルとURLをコピーしました