0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WWDC25「SwiftData: Dive into inheritance and schema migration」についてまとめる

Posted at

はじめに

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のみを取得)の利用する場合、継承は不要。
ディープサーチのみ利用する場合はPersonalTripBusinessTripはサブクラスではなくTripクラスのプロパティと見なすべき。

@Model class Trip {
    var category: Category
    enum Category: Codable {
        case personal
        case business
    }
}

シャローサーチのみ利用する場合はPersonalTripBusinessTripでそれぞれモデルを作成するべき。

@Model class PersonalTrip { ... }

@Model class BusinessTrip { ... }

ディープサーチとシャローサーチの両方を利用する場合は継承が役にたつ。

Evolving data with migration

スキーマ移行についての話です。
内容としてはWWDC23の「Model your schema with SwiftData」で話されてることから変わらず、今回のサブクラスが追加されたバージョンへの移行方法について話されています。

VersionedSchemamodelsプロパティには追加したサブクラスも全て指定する。
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
}

ウィジェットなどから変更が加えられると新しい履歴が追加されるため、アプリ側では取得して保存しておいた履歴トークン以降の履歴だけを取得することができる。

このように履歴トークンをマーカーとして使用することで再フェッチが必要かどうかを判断し、不要なデータ取得を回避してパフォーマンスを向上させることが可能になる。

ただ、セッション中だと対象エンティティをLivingAccommodationTripに絞るPredicateも定義しているが同様のコードだとコンパイルエラーになってしまう。
サンプルプロジェクトではトークン比較のみのPredicateDefaultHistoryTransactionを取得して、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モデルの継承が使われてるサンプルコードは以下

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?