はじめに
WWDC25のセッション「SwiftData: Dive into inheritance and schema migration」についてのまとめです。
セッションでは以下の4つのトピックについて話されていました。
- Harness class inheritance
- Evolving data with migration
- Tailoring fetched data
- Observing changes to data
Harness class inheritance
おそらく今回の目玉機能(?)の継承についてです。
iOS 26からSwiftDataモデルのクラス継承がサポートされました。
@Model class Trip { ... }
@available(iOS 26, *)
@Model
class PersonalTrip: Trip {
var reason: Reason
}
@available(iOS 26, *)
@Model
class BusinessTrip: Trip {
var perdiem: Double = 0.0
}
SwiftDataモデルのクラス継承は、モデルが階層構造を持ち、共通の特性を有している場合に有用。
サブクラスはより広いドメインに適合する自然なサブドメインである必要がある。
追加したサブクラスはschemaに追加で指定することで使用することができる。
// Before
WindowGroup {
ContentView()
}
.modelContainer(for: Trip.self)
// After
WindowGroup {
ContentView()
}
.modelContainer(for: [Trip.self, PersinalTrip.self, BusinessTrip.self])
注意点
クラス継承は強力なツールだが、単に共通のプロパティを持つだけの場合は適さない。
親クラスとサブクラスが階層構造を持ち、「is-a」の関係が成り立つことが重要。
階層構造を持たず、単一プロパティの共有が目的の場合はプロトコルに準拠させるべき。
// ❌
@Model class NamedModel {
var name: String
}
@Model class Trip: NamedModel { ... }
@Model class PersonalTrip: Trip { ... }
// ✅
protocol NamedModel {
var name: String { get }
}
@Model class Trip: NamedProtocol { ... }
@Model class PeronalTrip: Trip { ... }
また、ディープサーチのみ(常にTrip
クラスのみを取得)、もしくはシャローサーチのみ(PersonalTrip
or BusinessTrip
のみを取得)の利用する場合、継承は不要。
ディープサーチのみ利用する場合はPersonalTrip
とBusinessTrip
はサブクラスではなくTrip
クラスのプロパティと見なすべき。
@Model class Trip {
var category: Category
enum Category: Codable {
case personal
case business
}
}
シャローサーチのみ利用する場合はPersonalTrip
とBusinessTrip
でそれぞれモデルを作成するべき。
@Model class PersonalTrip { ... }
@Model class BusinessTrip { ... }
ディープサーチとシャローサーチの両方を利用する場合は継承が役にたつ。
Evolving data with migration
スキーマ移行についての話です。
内容としてはWWDC23の「Model your schema with SwiftData」で話されてることから変わらず、今回のサブクラスが追加されたバージョンへの移行方法について話されています。
VersionedSchema
のmodels
プロパティには追加したサブクラスも全て指定する。
SwiftDataモデルのクラス継承はiOS 26以降で有効なため、VersionedSchema
などSwiftDataのクラス継承を使用しているバージョンに関連する処理には@available(iOS 26, *)
を付ける。
Tailoring fetched data
SwiftDataのセッションでよく使われてるSmapleTripアプリに検索バーを追加された。
検索はPredicate
マクロを使用しており、モデルクラスと検索語を組み合わせてフィルタリングしている。
モデルクラスのフィルタリングは以下のように行われている。
let classPredicate: Predicate<Trip>? = {
switch segment.wrappedValue {
case .all:
return nil
case .personal:
return #Predicate { $0 is PersonalTrip }
case .business:
return #Predicate { $0 is BusinessTrip }
}
}()
また、バージョン移行中の効率化についても同じトピックで話されている。
マイグレーション中の重複排除処理などで特定のプロパティのみを取得したい場合はpropertiesToFetch
を使用して取得したいプロパティを指定することで必要最小限のデータを持ったモデルを取得することができる。
また特定のリレーションを辿る場合はrelationshipKeyPathsForPrefetching
を使用することで最適化が図れる。
ウィジェットではフェッチした最初の結果のみを使用しているため、fetchLimit = 1
にすることで効率化している。
Observing changes to data
主にモデルの変更を検知する方法について話されています。
全てのPersistentModel
はObservableなので、withObservationTracking
を使用することで、モデルのプロパティに加えられた変更を監視することができる。
Observableに関する最新情報には「What`s new in Swift」を参照。
ただし全ての変更がObservableではなく、観測できるのは現在のプロセス内で行われたモデルへの変更のみ。
ウィジェットやエクステンション、アプリ内の別ModelContainerからデータストアに加えられた変更はObservableではない。
アプリ内でのローカルな変更は同一のModelContainer
を使い、複数のModelContext
が存在する場合、それらのコンテキスト間で行われた変更はお互いに認識でき、Query
を使用している場合は変更が自動的に反映される。
しかし、ModelContext
のフェッチAPIなどを利用している場合、別のModelContext
で行われた変更は明示的に再フェッチしないと反映されない。
// 別ModelContextでの変更が自動で反映される
@Query
var trips: [Trip]
// 別ModelContextで変更が加えられた場合、再フェッチしないと変更が反映されない
var trips = context.fetch(...)
さらにウィジェットや別アプリが共有のApp Groupコンテナに書き込んだりするような外部からの変更もQuery
は自動で反映されるが、フェッチAPIを使用している箇所では明示的に再フェッチが必要になる。
再フェッチはコストが高いためSwiftDataの履歴機能(persistent history)を利用することで再フェッチが必要かどうかを判断する。
履歴機能についてはWWDC24の「Track model changes with SwiftData history」で詳しく話されている。
iOS 26から履歴をsortBy
で並べ替えて取得できるようになったため、今までは一番新しい履歴トークンを取得するために全ての履歴を取得してしまう可能性があったが、sortBy
を使用することで効率的に最新の履歴トークンを取得できるようになった。
// Before
let historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
let transactions = try context.fetchHistory(historyDesc)
if let transaction = transactions.last {
historyToken = transaction.token
}
// After
var historyDesc = HistoryDescriptor<DefaultHistoryTransaction>()
historyDesc.sortBy = [.init(\.transactionIdentifier, order: .reverse)]
historyDesc.fetchLimit = 1
let transactions = try context.fetchHistory(historyDesc)
if let transaction = transactions.last {
historyToken = transaction.token
}
ウィジェットなどから変更が加えられると新しい履歴が追加されるため、アプリ側では取得して保存しておいた履歴トークン以降の履歴だけを取得することができる。
このように履歴トークンをマーカーとして使用することで再フェッチが必要かどうかを判断し、不要なデータ取得を回避してパフォーマンスを向上させることが可能になる。
ただ、セッション中だと対象エンティティをLivingAccommodation
とTrip
に絞るPredicate
も定義しているが同様のコードだとコンパイルエラーになってしまう。
サンプルプロジェクトではトークン比較のみのPredicate
でDefaultHistoryTransaction
を取得して、HistoryChange
をチェックするようにしている。
セッション
let entityNames = [LivingAccommodation.self, Trip.self]
let changesPredicate = #Predicate<DefaultHistoryTransaction> {
$0.changes.contains { change in
entityNames.contains(change.changedPersistentIdentifier.entityName) // ❌
}
}
サンプルプロジェクト
private func isLivingAccommodationChange(change: HistoryChange) -> Bool {
switch change {
case .insert(let historyInsert):
if historyInsert is any HistoryInsert<LivingAccommodation> {
return true
}
case .update(let historyUpdate):
if historyUpdate is any HistoryUpdate<LivingAccommodation> {
return true
}
case .delete(let historyDelete):
if historyDelete is any HistoryDelete<LivingAccommodation> {
return true
}
default:
break
}
return false
}
参考
今回のSwiftDataモデルの継承が使われてるサンプルコードは以下