WWDC Quick Look 💓 By SwiftGGTeam
SwiftData: Dive into inheritance and schema migration

SwiftData: Dive into inheritance and schema migration

元の動画を見る

Highlight

iOS 26 から SwiftData は @Model class 間の継承をサポートし、lightweight migration stage と組み合わせることで、既存の schema をサブクラス入りの V4 へとスムーズに移行できます。

主な内容

サンプル app である SampleTrips は、iOS 17 から iOS 26 まで一貫して進化してきました。最初のモデルは Trip クラスひとつだけで、家族旅行・出張・療養など、あらゆる旅行を同じテーブルに詰め込んでいました。時間が経つにつれ、問題が浮き彫りになってきます。出張には perdiem(日当)フィールドが必要で、個人旅行には Reason enum(family/reunion/wellness)が必要ですが、それらすべてを Trip に追加すると基底クラスはどんどん肥大化し、しかも大半のフィールドはもう一方の用途では完全に無意味になってしまいます。一方で、フラットな複数テーブル構成(PersonalTripBusinessTrip をそれぞれ独立させる形)に変えると、「自分のすべての trip を一覧する」という、基底クラス視点での統一的なクエリ能力が失われてしまいます。

iOS 26 では SwiftData に class inheritance が追加されました。@Model クラス同士で継承できるようになります。Trip を基底クラスとして共通フィールドを提供し、PersonalTripBusinessTrip をサブクラスとしてそれぞれ固有のプロパティを追加できます。@Query で基底クラス型を指定すれば、結果にはすべてのサブクラスのインスタンスが含まれます(deep search)。一方で is キーワードを使って predicate を書けば、特定のサブクラスだけを絞り込めます(shallow search)。Rishi Verma 氏のセッションでは、V1 から V4 までの完全な移行チェーンも示されました。V1→V2 ではカスタムの重複排除、V2→V3 では unique/index の追加、V3→V4 では lightweight migration を使ってサブクラスをそのまま接続します。

詳細

サブクラスの書き方。 サブクラスには @available(iOS 26, *) を付ける必要があります。そうしないと低バージョンではコンパイルが通りません。

@Model
class Trip {
  var name: String
  var destination: String
  var startDate: Date
  var endDate: Date

  var bucketList: [BucketListItem] = [BucketListItem]()
  var livingAccommodation: LivingAccommodation?
}

@available(iOS 26, *)
@Model
class BusinessTrip: Trip {
  var perdiem: Double = 0.0
}

@available(iOS 26, *)
@Model
class PersonalTrip: Trip {
  enum Reason: String, CaseIterable, Codable {
    case family
    case reunion
    case wellness
  }
  var reason: Reason
}

ポイント(03:28):

  • BusinessTrip: Trip は基底クラスのプロパティをすべて継承するため、再宣言は不要です。
  • サブクラスの新しいプロパティには必ずデフォルト値を与える必要があります(perdiem = 0.0)。そうしないと migration 時、既存データに値を埋められません。
  • ReasonCodable を実装していないと SwiftData で永続化できません。

サブクラスの登録。 schema 登録時には modelContainer にサブクラスも列挙します:

.modelContainer(for: [Trip.self, BusinessTrip.self, PersonalTrip.self])

ポイント(04:03): 基底クラスだけを登録すると、サブクラスは fetch で取得できません。

is を使った shallow search。 Segmented control で種類別に絞り込むときは、predicate に $0 is PersonalTrip を書きます:

let classPredicate: Predicate<Trip>? = {
  switch segment.wrappedValue {
  case .personal:
    return #Predicate { $0 is PersonalTrip }
  case .business:
    return #Predicate { $0 is BusinessTrip }
  default:
    return nil
  }
}()
_trips = Query(filter: classPredicate, sort: \.startDate, order: .forward)

ポイント(07:06):

  • Predicate<Trip> の型パラメータが基底クラスなので、同じ @Query で全 Trip を取得することも、ランタイムで型ごとに絞り込むこともできます。
  • default 分岐では nil を返し、クエリに型フィルタを付けないことで、結果的に deep search と等価になります。

Migration plan の全チェーン。 V1 から V4 までは SchemaMigrationPlan でつなぎます:

enum SampleTripsMigrationPlan: SchemaMigrationPlan {
  static var schemas: [any VersionedSchema.Type] {
    var currentSchemas: [any VersionedSchema.Type] =
      [SampleTripsSchemaV1.self, SampleTripsSchemaV2.self, SampleTripsSchemaV3.self]
    if #available(iOS 26, *) {
      currentSchemas.append(SampleTripsSchemaV4.self)
    }
    return currentSchemas
  }

  static var stages: [MigrationStage] {
    var currentStages = [migrateV1toV2, migrateV2toV3]
    if #available(iOS 26, *) {
      currentStages.append(migrateV3toV4)
    }
    return currentStages
  }
}

ポイント(10:24):

  • schemas 配列にはリリース順に各バージョンの schema を列挙します。SwiftData はこれを手がかりにユーザーが現在どのバージョンにいるかを判定します。
  • stages は移行ロジックの集合で、V1→V2 と V2→V3 はそれぞれ重複排除、V3→V4 は lightweight です。
  • #available で V4 をくくっておくことで、古い OS に古い app を入れた場合に未対応のサブクラス型に触れずに済みます。

lightweight stage は一行で十分。 サブクラスの追加は schema 互換変更にあたるため、willMigrate を書く必要はありません:

@available(iOS 26, *)
static let migrateV3toV4 = MigrationStage.lightweight(
  fromVersion: SampleTripsSchemaV3.self,
  toVersion: SampleTripsSchemaV4.self
)

ポイント(10:03): lightweight migration は開始バージョンと終了バージョンを宣言するだけで、ストレージエンジンが自動的に schema を変更し、既存データはすべて維持されます。

fetch 高速化の三点セット。 カスタムの migration stage でも widget でも、同じ FetchDescriptor のチューニングが効きます:

var fetchDesc = FetchDescriptor<SampleTripsSchemaV1.Trip>()
fetchDesc.propertiesToFetch = [\.name]
fetchDesc.relationshipKeyPathsForPrefetching = [\.livingAccommodation]

ポイント(13:11):

  • propertiesToFetch を指定すると、本当に使うカラムだけを読み込めます。重複排除で name しか必要ないケースでは IO を大きく削減できます。
  • relationshipKeyPathsForPrefetching を使うと、関連オブジェクトを一括で取得し、後続の N+1 クエリを防げます。
  • fetchLimit = 113:28)と組み合わせれば、widget で直近の trip 1件だけを取得できます。

History token の効率的な取得。 プロセスをまたいだ変更検知は SwiftData History に頼ります。新しく追加された sortByfetchLimit により、最新 token の特定で全件スキャンが不要になりました:

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
}

ポイント(16:24): transactionIdentifier の降順、limit 1 という指定は SQL の ORDER BY ... DESC LIMIT 1 と等価で、O(log n) で最新 token を取得できます。これにより、起動のたびに履歴を全スキャンする無駄がなくなります。

重要なヒント

  • やること: 既存の SwiftData app の中から「is-a」関係にあるモデル群を見つけ出し、サブクラスを導入する。

    • なぜ価値があるか: 業務フィールドが増えていくほど、optional フィールドよりサブクラスのほうがドメインのセマンティクスを表現できますし、if let perdiem のような散在する nil チェックも不要になります。
    • 始め方: 現在のモデルで optional かつ一部のインスタンスにしか意味を持たないフィールドを洗い出し、利用シーンごとにクラスタリングしてみましょう。それらがサブクラスの候補です。
  • やること: 「deep search vs shallow search」を継承導入の判断基準にする。

    • なぜ価値があるか: 何でも継承にしてクエリ性能を悪化させるのを避けるためです。基底クラスからしかクエリしないなら、サブクラスではなくプロパティに格上げするべきですし、サブクラスからしかクエリしないなら、モデルをフラットに保つべきです。
    • 始め方: コード中のすべての @QueryFetchDescriptor を見直し、基底クラスでのクエリとサブクラスでのクエリの比率を集計します。両方ともゼロでない場合に初めて、継承を検討する価値があります。
  • やること: V1 から最新版までを網羅した完全な SchemaMigrationPlan を整備する。

    • なぜ価値があるか: ユーザーは必ずしもバージョン順にアップデートするとは限らず、2年前のバージョンから一気に最新版へジャンプする可能性もあります。stage が一段でも欠けると、データ消失やクラッシュにつながります。
    • 始め方: VersionedSchema でリリースごとに Schema.Version を打ち、対応する MigrationStage を plan の stages 配列にきっちりつなげましょう。
  • やること: widget や share extension のような読み取り専用・低メモリのシーンでは propertiesToFetch + fetchLimit を使う。

    • なぜ価値があるか: widget のメモリ予算は数十 MB しかないため、Trip を全件ロードすると OOM になりかねません。必要なカラムと直近の 1件だけを取得すれば、カードのレンダリングには十分です。
    • 始め方: すべての FetchDescriptor を確認し、UI で表示するフィールドが限られているなら propertiesToFetch に書きます。件数の表示が必要な場合は fetchLimit も合わせて指定しましょう。

関連セッション

コメント

GitHub Issues · utterances