ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
SwiftDataの履歴機能によるモデル変更のトラッキング
SwiftDataを使用して、モデルの変更履歴を明らかにしましょう。履歴APIを使用すると、データストアがいつ変更されたのかを把握することができます。この情報をもとに、リモートサーバの同期やアプリでのプロセス外変更処理など、さまざまな機能を構築する方法を説明します。履歴APIのサポートをカスタムデータストアに取り入れる方法も取り上げます。
関連する章
- 0:00 - Introduction
- 0:45 - Fundamentals
- 5:18 - Transactions and changes
- 12:37 - Custom stores
リソース
関連ビデオ
WWDC24
-
ダウンロード
こんにちは SwiftDataチームのエンジニア Davidです SwiftDataの履歴機能は アプリのデータに 加えられた変更を追跡するための 新しいテクノロジーです この履歴機能を使えば アプリのデータへの 様々な変更を処理する機能を構築できます 例えば サーバとの同期や App Extensionからの変更への対応などを 処理する機能です 本セッションでは SwiftDataの履歴機能の 基本を説明したあと サンプルアプリで 履歴のトランザクション および 変更を使って 新しい機能を構築します
最後に カスタムデータストアで 履歴機能をサポートする際の 考慮事項を説明します
まず SwiftDataの履歴機能とは何か そしてなぜ使用したいのかを 説明します
アプリを使い続けると SwiftDataに 保存される内容は時間とともに変化します 例えば アプリを起動すると モデルが作成されたり リモートサーバから取得した モデルが挿入されたりします
モデルコンテキストが保存されると 保留中のすべての変更が データストアに保存されます
時間が経つにつれて
ユーザーがアプリやその様々な機能を
使用し操作を行うことで これらのモデルの一部が 変更または削除されることがあります
アプリはいつでも ストアに対して データのクエリを実行できますが 結果として返されるのは データストアに その時点で保存されているデータのみです クエリを実行しても 履歴を調べるか手動で差分比較しなければ 前回のクエリ以降に どのモデルが 追加 削除 更新されたかはわかりません SwiftDataの履歴機能を使えば データストアが時間とともに どのように変化したかを 簡単に効率よく追跡できます
この機能を使って 様々な機能を構築できます 例えば アプリがオフラインの時に 追加された変更について 時系列にそったログを作成し 後で その変更をリモートサーバと 効率的に同期させることができます
ウィジェットの拡張機能などの アプリとは別のプロセスで発生した データの変更を検出し これらの変更を適切にアプリに反映させたい 場合もあるでしょう
または 前回のクエリ以降に どのモデルが挿入または削除されたかを 効率よく把握して 実行時にステータスを更新できます これらを実現する方法をご紹介します
SwiftDataの履歴機能を使うと アプリでクエリを実行して 発生した順に変更を処理できます
この機能では モデルが保存されるたびに すべての変更に関するメタデータを含む トランザクションを記録します
SwiftDataの履歴機能は トランザクションと変更で構成されています トランザクションには ModelContextの保存時などの 境界においてデータストアで発生した すべての変更がグループ化されます トランザクションは 発生時点に基づいて順序づけされます
トランザクション内の一連の変更の間でも 発生時点に基づき順序がつけられます 各変更は モデルの挿入 更新 削除を示しており PersistentModelによって パラメータ化されています そのため KeyPathによるPersistentModelの プロパティへの参照が可能です
SwiftDataの履歴機能では トークンという概念を使います トークンは 履歴内のトランザクションの ブックマークとして機能します トークンを使うことで 履歴の中で 最後に処理されたトランザクションを アプリで追跡できるようになります
トークンは 自身が関連づけられている データストアに対してのみ有効です SwiftDataの履歴情報は モデルコンテキストを通じて削除できます
履歴から削除した部分のトークンは 期限切れとなり 履歴の取得に使用できなくなります
SwiftDataの履歴機能で 期限切れのトークンを使おうとすると historyTokenExpiredエラーが スローされます このような場合は 無効になったトークンを破棄して 新しいトークンを取得します
モデルが削除されると モデル内のデータは破棄されます これにより IDなどの重要なデータが失われ アプリで履歴情報を処理するための 十分な情報が 得られなくなる可能性があります この問題に対処するために SwiftDataの履歴機能では モデルの 特定の属性を保持できるようになっています モデルが削除されると それらのモデルの属性が トゥームストーン値として保存されるため 削除されたモデルの履歴情報も 引き続き処理できます
.preserveValueOnDeletion モディファイアでマークされた PersistentModelの属性が トゥームストーンに保存されます トゥームストーンはPersistentModelにより パラメータ化もされるため 必要に応じ KeyPathを使って トゥームストーン値を取得したり 繰り返し履歴を処理したりできます
SwiftDataの履歴機能は使いやすく Swiftの充実した型システムを基盤として 構築されています 次に アプリでの 履歴機能の使い方を説明します 実際の動作を確認するために 履歴機能を 新機能として SampleTripsというアプリに追加してみます このアプリには お気に入りの旅行情報を すべて記録でき 次の休暇を計画する際に便利です
変更があったけれどまだ読めていない 旅行情報にバッジをつけ 未読であることを 示してくれる新機能を追加します バッジ追加は リモートサーバとの同期など アプリ外の状況によって起こることもあれば アプリのウィジェット内で 起こることもあります
さらにウィジェットに ホーム画面から直接 個々の宿泊施設の予約を 確認できる機能を追加します この機能を構築するには SwiftDataの履歴機能を使って データの変更日と変更した人物を確認し UIを更新できるようにします
そのための手順は次の3つです まず SwiftDataの履歴情報を取得し
次に 変更のプロパティを確認して 変更を処理し 最後に ユーザーインターフェイスを更新します まず tokenパラメータとauthorに基づいて データストア内のトランザクションを 取得する関数を作成します この例では トークンは DefaultHistoryToken型ですが これは このアプリがSwiftDataで DefaultStoreを使っているためです
次に HistoryDescriptorを作成して リクエストの制約事項を 構成できるようにします
predicateを作成して 提供されたトークン以降に発生した トランザクションを制約します
この関数では ウィジェットでの変更のみを 確認できるようにしたいので 指定したauthorによる変更のみを 取得するという制約を追加します トークンを使わずにこの関数を呼び出すと 取得可能なすべての履歴情報を 取得することになります
次に 処理が必要なトランザクションを 含めるための 配列を作成します 次に 記述子を使って ModelContextでfetchHistoryを呼び出します これで 一連の DefaultHistoryTransactionsが 提供され 以降の反復処理に使用できます 以上で 目的のトランザクションを 取得できるようになりました 次に これらを処理するための 別の関数を定義します この関数は トランザクションの配列を受け取り 未読の変更とトークンの存在を示す バッジの付与が必要な 旅行情報のセットを返します 関数を実行するたびに 新しいトークンが返されます このトークンは 次回に 変更を確認する際に使用できます
まず ModelContextと 未読の変更が含まれている旅行情報を 保存するセットを定義します
各トランザクションと トランザクション内の各変更に対して History APIにより 永続的なモデルIDが割り当てられます 旅行情報の モデルインスタンスを取得するために この永続的なモデルIDを使って livingAccommodationの fetchDescriptorを作成します 次に モデルのコンテキストから このモデルを取得し LivingAccommodationに 関連づけられている旅行情報を保存します
トランザクションに含まれる変更が 挿入 追加 削除のどれか判断するために switch文を使って型を確認します
アプリで ウィジェットによって LivingAccommodationモデルが 挿入 変更 削除された場合は UIにバッジが表示されるようにします そのためには まずLivingAccommodationの DefaultHistoryInsert型が 変更されているか確認します このケースが該当する場合 LivingAccommodationが挿入されたと いうことであり この旅行情報がセットに追加されます 先ほども触れましたが この型が DefaultHistoryInsertという名前なのは この例では このモデルに DefaultStoreを使用しているためです さらに LivingAccommodationの DefaultHistoryUpdate型の ケースを追加することで 更新がないか確認します 変更が更新によるものの場合は セットに含まれる旅行情報を更新します
旅行情報が削除されている場合 アプリの インターフェイスでの操作は不要です これを処理するために LivingAccommodationの DefaultHistoryDelete型のケースを追加して セットから削除します
最後に 旅行情報のセットとともに 最後のトークンを返します これで 今後この関数が呼び出された場合は このトランザクション以降の変更のみが 返されるようになります
SwiftDataの履歴機能をアプリに導入すると 前回に変更を確認した時点以降に どの旅行情報がウィジェットによって 変更されたか 把握できるようになります 次に このトークンを保存する必要があります これにより 前回変更を確認した時点以降の 変更のみが確認の対象となります そのためには 3つ目の関数を定義し UserDefaultsを使って 最新のトークンを保存します 利用可能なトークンがある場合は findUnreadTripsという関数で このトークンを取得し JSONからデコードしたあと このトークンを使って findTransactions関数を呼び出します ここでは ウィジェットを authorとして指定するために ウィジェットで ModelContextの.authorプロパティを TransactionAuthor.widgetに設定します
findTripsを呼び出して 返されたトークンを UserDefaultsに保存します これで findUnreadTripsを呼び出すたびに 前回の呼び出し以降に バッジをつける必要がある旅行情報のみが 返されるようになります
もう少しで完成です あと2つだけ 追加が必要な要素があります
1つ目は アプリを開いた時に 未読の旅行情報をチェックすることであり 2つ目は 旅行情報がタップされた場合 未読情報のセットから削除して バッジが消えるようにすることです SwiftUIビューで scenePhaseがアクティブになるたびに findUnreadTripIdentifiersを呼び出すと バッジが必要な新しい旅行情報を反映して インターフェイスが更新されます
さらに 旅行情報が選択されたら unreadTripIdentifiersセットから その情報のIDを削除して バッジが消えるようにします
最後に unreadTripIdentifiersセットに 含まれている旅行情報に バッジを追加します
以上で 必要なコードを すべて記述できたので ビルドしてアプリを実行しましょう アプリには旅行情報がすでに入力済みなので ウィジェットの宿泊施設の予約を 直接ホーム画面から 確認してみましょう
をタップすると UIが更新され 予約が確認済みになりました 次回この旅行アプリを開くと Formation Flyoverの旅行情報に 青色の未読バッジが表示され 旅行情報に変更があったことが示されます 旅行情報を確認するとこのバッジは消えます
SwiftDataを使って カスタムデータストアを構築している場合も 履歴機能を利用できます 基盤となるモデルが対応している場合 カスタムデータストアでも 同じワークフローをサポートできます カスタムデータストアに 履歴機能を追加するには そのデータストアで使用する SwiftData History APIの基本要素を 指定するために 独自の型を実装する必要があります この基本要素には トランザクションや 各変更の型のほか 複数の トランザクションの間でブックマークとして 機能するトークンが含まれます カスタムデータストアはHistoryProvidingに 準拠している必要もあります
トランザクションの境界は 明確に定義する必要があります データストア内の書き込み操作を統合し 順序づける必要があるためです デフォルトのストアでは ModelContextのモデルインスタンスへの すべての変更は 保存時に1つの トランザクションとしてグループ化されます
トランザクションの型を作成する際は 永続性バックエンドにおいて トランザクションを一意に識別する方法を 定義する必要があります トランザクションの境界と同様に 変更の境界を定義する基準も 明確に定義する必要があります DefaultStoreでは 個々のモデルインスタンスの間が 変更の境界になります
細かい変更を追跡できる 識別子を選択しましょう
既存のすべての変更型を アプリで使う必要がない場合もあれば 別の変更型が必要な場合もあります 例えば アプリで挿入されるモデルが 常に時系列ログのみである場合 更新や削除の変更型は 必要ないかもしれません ほかに考慮すべき事項としては 値が削除された時 アプリにその値を 保存する必要があるかどうかの判断や 削除された値の保存方法があります
履歴を提供するには カスタムストアで HistoryProvidingプロトコルを 実装する必要があります そのためには トランザクションと変更を 定義しているストアから データ行をまとめて 取得できなければなりません
トランザクションの一部である 行が識別できたら 個々のモデルセットを 構築する必要があります
デフォルトのデータストアの履歴情報には 有効期限があります 履歴をいつ削除するかは デベロッパ側で決定する必要があります SwiftDataの履歴機能は堅牢であり 大量の履歴データを処理できますが 状況によっては 履歴データを 削除した方がよい場合もあります 例えば アプリからモデルを削除した場合 そのモデルに関する履歴データは 今後使うことのないものである 場合があります この場合は データストアの履歴を 削除した方がよいでしょう
最後に カスタムストアに 履歴機能を追加する際は カスタムの型のトークンを 作成する必要があります HistoryTokenは トークンの基本プロトコルです トランザクション内の一連の処理の中で 特定の位置を一意に識別するには ステータスが必要です
アプリで 関連する複数のストアを使用する 場合は トランザクションで使用される 全ストアのステータスを カスタムトークンに含める必要があります
履歴機能は 変更に関するクエリを 実行できるパワフルな機能です これにより ウィジェットの旅行情報が 更新されたことなどを確認できます SwiftDataでは 表現力に優れた Swiftの型システムによって 各モデルの変更がアプリ内で どう使用されるか簡単に把握できます SwiftDataの履歴機能を活用すれば アプリで素晴らしい体験を構築できます 永続的な履歴を利用するために アプリとCore Dataを併用している場合は SwiftDataの履歴機能への移行を おすすめします カスタムストアを構築している場合は 履歴のための独自の型を作成することで 履歴追跡のすべての機能を 利用できるようになります ご視聴ありがとうございました
-
-
4:57 - Preserve values in history on deletion
// Add .preserveValueOnDeletion to capture unique columns import SwiftData @Model class Trip { #Unique<Trip>([\.name, \.startDate, \.endDate]) @Attribute(.preserveValueOnDeletion) var name: String var destination: String @Attribute(.preserveValueOnDeletion) var startDate: Date @Attribute(.preserveValueOnDeletion) var endDate: Date var bucketList: [BucketListItem] = [BucketListItem]() var livingAccommodation: LivingAccommodation? }
-
6:26 - Fetch transactions from history
private func findTransactions(after token: DefaultHistoryToken?, author: String) -> [DefaultHistoryTransaction] { var historyDescriptor = HistoryDescriptor<DefaultHistoryTransaction>() if let token { historyDescriptor.predicate = #Predicate { transaction in (transaction.token > token) && (transaction.author == author) } } var transactions: [DefaultHistoryTransaction] = [] let taskContext = ModelContext(modelContainer) do { transactions = try taskContext.fetchHistory(historyDescriptor) } catch let error { print(error) } return transactions }
-
7:34 - Process history changes
private func findTrips(in transactions: [DefaultHistoryTransaction]) -> (Set<Trip>, DefaultHistoryToken?) { let taskContext = ModelContext(modelContainer) var resultTrips: Set<Trip> = [] for transaction in transactions { for change in transaction.changes { let modelID = change.changedPersistentIdentifier let fetchDescriptor = FetchDescriptor<Trip>(predicate: #Predicate { trip in trip.livingAccommodation?.persistentModelID == modelID }) let fetchResults = try? taskContext.fetch(fetchDescriptor) guard let matchedTrip = fetchResults?.first else { continue } switch change { case .insert(_ as DefaultHistoryInsert<LivingAccommodation>): resultTrips.insert(matchedTrip) case .update(_ as DefaultHistoryUpdate<LivingAccommodation>): resultTrips.update(with: matchedTrip) case .delete(_ as DefaultHistoryDelete<LivingAccommodation>): resultTrips.remove(matchedTrip) default: break } } } return (resultTrips, transactions.last?.token) }
-
10:19 - Save and use a history token
private func findUnreadTrips() -> Set<Trip> { let tokenData = UserDefaults.standard.data(forKey: UserDefaultsKey.historyToken) var historyToken: DefaultHistoryToken? = nil if let tokenData { historyToken = try? JSONDecoder().decode(DefaultHistoryToken.self, from: tokenData) } let transactions = findTransactions(after: historyToken, author: TransactionAuthor.widget) let (unreadTrips, newToken) = findTrips(in: transactions) if let newToken { let newTokenData = try? JSONEncoder().encode(newToken) UserDefaults.standard.set(newTokenData, forKey: UserDefaultsKey.historyToken) } return unreadTrips }
-
11:30 - Update the user interface
struct ContentView: View { @Environment(\.scenePhase) private var scenePhase @State private var showAddTrip = false @State private var selection: Trip? @State private var searchText: String = "" @State private var tripCount = 0 @State private var unreadTripIdentifiers: [PersistentIdentifier] = [] var body: some View { NavigationSplitView { TripListView(selection: $selection, tripCount: $tripCount, unreadTripIdentifiers: $unreadTripIdentifiers, searchText: searchText) .toolbar { ToolbarItem(placement: .topBarLeading) { EditButton() .disabled(tripCount == 0) } ToolbarItemGroup(placement: .topBarTrailing) { Spacer() Button { showAddTrip = true } label: { Label("Add trip", systemImage: "plus") } } } } detail: { if let selection = selection { NavigationStack { TripDetailView(trip: selection) } } } .task { unreadTripIdentifiers = await DataModel.shared.unreadTripIdentifiersInUserDefaults } .searchable(text: $searchText, placement: .sidebar) .sheet(isPresented: $showAddTrip) { NavigationStack { AddTripView() } .presentationDetents([.medium, .large]) } .onChange(of: selection) { _, newValue in if let newSelection = newValue { if let index = unreadTripIdentifiers.firstIndex(where: { $0 == newSelection.persistentModelID }) { unreadTripIdentifiers.remove(at: index) } } } .onChange(of: scenePhase) { _, newValue in Task { if newValue == .active { unreadTripIdentifiers += await DataModel.shared.findUnreadTripIdentifiers() } else { // Persist the unread trip names for the next launch session. await DataModel.shared.setUnreadTripIdentifiersInUserDefaults(unreadTripIdentifiers) } } } #if os(macOS) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in Task { unreadTripIdentifiers += await DataModel.shared.findUnreadTripIdentifiers() } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in Task { await DataModel.shared.setUnreadTripIdentifiersInUserDefaults(unreadTripIdentifiers) } } #endif } }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。