SwiftData iOS 26実践ガイド:クラス継承・#Index・スキーマ移行で堅牢なデータモデルを構築する

SwiftData iOS 26のクラス継承、#Indexマクロ、スキーマ移行を実践コードで解説。基底モデルの設計からサブクラスの作成、クエリ最適化まで、プロダクションアプリで必要な知識を網羅します。

はじめに:SwiftData iOS 26で何が変わったのか

SwiftDataがWWDC 2023で登場してから、もう3年が経ちました。当初は「Core Dataの置き換え」として大きな注目を集めましたが、正直なところ、初期バージョンにはけっこう制限がありましたよね。特に、オブジェクト指向プログラミングの基本中の基本であるクラス継承がサポートされていなかったのは、個人的にもかなり痛かったです。

それがiOS 26で、ようやく解禁されました。

SwiftDataがクラス継承を正式にサポートし、さらに#Indexマクロによるクエリ高速化や、より実用的なスキーマ移行の仕組みも整っています。「やっと来たか」という感じです。

この記事では、SwiftData iOS 26の新機能を実践的なコード例とともに解説していきます。基底モデルの設計からサブクラスの作成、クエリの最適化、そしてスキーマ移行まで——実際のアプリ開発で必要になる知識をひと通りカバーするので、ぜひ手を動かしながら読んでみてください。

クラス継承が必要だった理由

iOS 25まで、SwiftDataのモデルは他のモデルから継承できませんでした。

たとえば、ECアプリで「商品」という共通概念と、「デジタル商品」「物理商品」という派生概念を表現したい場合、プロトコルやコンポジションで無理やり回避する必要がありました。これが地味に面倒で、冗長なコードが増え、共通プロパティの同期維持にも手間がかかっていたんです。

iOS 26のクラス継承サポートにより、自然な「is-a」関係をそのままモデルに反映できるようになりました。コードの見通しも格段に良くなります。

Step 1:基底モデルを定義する

まず、すべてのサブクラスが共有するプロパティを持つ基底モデルを作りましょう。ここではイベント管理アプリを例に進めていきます。

import SwiftData

@Model
class Event {
    var title: String
    var date: Date
    var location: String
    var notes: String?
    
    init(title: String, date: Date, location: String, notes: String? = nil) {
        self.title = title
        self.date = date
        self.location = location
        self.notes = notes
    }
}

このEventクラスが基底モデルです。タイトル、日付、場所、メモという、どんなイベントにも共通するプロパティを定義しています。シンプルですが、ここが土台になるのでしっかり設計しておきたいところです。

Step 2:サブクラスを作成する

では、iOS 26のクラス継承を使ってEventを拡張するサブクラスを作っていきます。ここで重要なポイントが1つ——サブクラスには@available(iOS 26, *)を付ける必要があります。忘れるとコンパイルエラーになるので注意してください。

@available(iOS 26, *)
@Model
class WorkEvent: Event {
    var organizer: String
    var isRequired: Bool
    var meetingUrl: String?
    
    init(title: String, date: Date, location: String,
         organizer: String, isRequired: Bool = false,
         meetingUrl: String? = nil, notes: String? = nil) {
        self.organizer = organizer
        self.isRequired = isRequired
        self.meetingUrl = meetingUrl
        super.init(title: title, date: date,
                   location: location, notes: notes)
    }
}

@available(iOS 26, *)
@Model
class SocialEvent: Event {
    enum Category: String, Codable {
        case party = "パーティー"
        case dinner = "食事会"
        case outdoor = "アウトドア"
        case other = "その他"
    }
    
    var category: Category
    var maxAttendees: Int?
    
    init(title: String, date: Date, location: String,
         category: Category, maxAttendees: Int? = nil,
         notes: String? = nil) {
        self.category = category
        self.maxAttendees = maxAttendees
        super.init(title: title, date: date,
                   location: location, notes: notes)
    }
}

WorkEventには主催者やミーティングURLなどビジネス寄りのプロパティを、SocialEventにはカテゴリや最大参加人数などソーシャル固有のプロパティを追加しています。どちらもEventtitledatelocationnotesをそのまま受け継いでいるので、重複コードがゼロです。

Step 3:ModelContainerの設定

サブクラスを作っただけでは実は動きません。ModelContainerにサブクラスを登録してあげる必要があります。

@main
struct EventPlannerApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [
            Event.self,
            WorkEvent.self,
            SocialEvent.self
        ])
    }
}

基底クラスのEvent.selfだけでなく、すべてのサブクラスも明示的に含めることがポイントです。これを忘れると、サブクラスのデータが正しく永続化されません(筆者も最初ハマりました)。

クエリの実践:継承を活かしたデータ取得

継承のメリットが一番実感できるのがクエリです。基底クラスで全件取得し、サブクラスで絞り込む——この使い分けがとても直感的にできます。

全イベントを取得する

struct AllEventsView: View {
    @Query(sort: \Event.date) var events: [Event]
    
    var body: some View {
        List(events) { event in
            VStack(alignment: .leading) {
                Text(event.title)
                    .font(.headline)
                Text(event.date, style: .date)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                
                // 型チェックでサブクラス固有の情報を表示
                if let workEvent = event as? WorkEvent {
                    Label(workEvent.organizer, systemImage: "briefcase")
                        .font(.caption)
                } else if let socialEvent = event as? SocialEvent {
                    Label(socialEvent.category.rawValue,
                          systemImage: "person.3")
                        .font(.caption)
                }
            }
        }
    }
}

サブクラスだけを取得する

iOS 26では#Predicate内でisキーワードによる型チェックが使えるようになりました。これがかなり便利です。

struct WorkEventsView: View {
    @Query(
        filter: #Predicate<Event> { $0 is WorkEvent },
        sort: \Event.date
    ) var workEvents: [Event]
    
    var body: some View {
        List(workEvents) { event in
            if let work = event as? WorkEvent {
                WorkEventRow(event: work)
            }
        }
    }
}

あるいは、もっとシンプルに、サブクラスの型で直接@Queryを使う方法もあります。

struct SocialEventsView: View {
    @Query(sort: \SocialEvent.date) var socialEvents: [SocialEvent]
    
    var body: some View {
        List(socialEvents) { event in
            SocialEventRow(event: event)
        }
    }
}

用途に応じて使い分けてみてください。個人的には、後者のシンプルな書き方のほうが好みです。

#Indexマクロでクエリを高速化する

データが増えてくると、じわじわとクエリのパフォーマンスが気になり始めます。iOS 18で導入された#Indexマクロを使えば、データベースにインデックスを作成して、特定のプロパティに対するクエリを大幅に高速化できます。

単一インデックス

@Model
class Event {
    #Index<Event>([\.date])
    
    var title: String
    var date: Date
    var location: String
    var notes: String?
    
    // ... init省略
}

複合インデックス

複数のプロパティを組み合わせた複合インデックスも作れます。よく使うフィルタ+ソートの組み合わせに合わせて定義するのがコツです。

@Model
class Event {
    #Index<Event>([\.date])
    #Index<Event>([\.title, \.date])
    #Index<Event>([\.location, \.date])
    
    var title: String
    var date: Date
    var location: String
    var notes: String?
    
    // ... init省略
}

いつインデックスを使うべきか?ざっくり言うと、数百件以上のレコードがあり、特定のプロパティでソートやフィルタを頻繁に行う場合に効果が大きいです。ただし、インデックスには書き込み時のオーバーヘッドがあるので、むやみに追加するのはNG。実際のクエリパターンに合わせて設計しましょう。

スキーマ移行:データを失わずにモデルを進化させる

クラス継承の導入は、けっこう大きなスキーマ変更です。既存のアプリにこの変更を加えるなら、ユーザーのデータを守るためにスキーマ移行は避けて通れません。

バージョン付きスキーマの定義

まず、変更前のスキーマをバージョンとして記録します。

enum EventSchemaV1: VersionedSchema {
    static var versionIdentifier: Schema.Version {
        Schema.Version(1, 0, 0)
    }
    
    static var models: [any PersistentModel.Type] {
        [Event.self]
    }
    
    @Model
    class Event {
        var title: String
        var date: Date
        var location: String
        var notes: String?
        
        init(title: String, date: Date,
             location: String, notes: String? = nil) {
            self.title = title
            self.date = date
            self.location = location
            self.notes = notes
        }
    }
}

新バージョンのスキーマ

enum EventSchemaV2: VersionedSchema {
    static var versionIdentifier: Schema.Version {
        Schema.Version(2, 0, 0)
    }
    
    static var models: [any PersistentModel.Type] {
        [Event.self, WorkEvent.self, SocialEvent.self]
    }
    
    // Event、WorkEvent、SocialEventの定義は先ほどと同じ
}

移行プランの作成

enum EventMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [EventSchemaV1.self, EventSchemaV2.self]
    }
    
    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }
    
    // サブクラスの追加は軽量マイグレーションで対応可能
    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: EventSchemaV1.self,
        toVersion: EventSchemaV2.self
    )
}

サブクラスの追加は既存データの構造を壊さないので、多くの場合軽量マイグレーションで対応できます。既存のEventレコードはそのまま残り、新しいWorkEventSocialEventは新規作成時に使われるだけ。ここは安心ポイントですね。

ModelContainerへの移行プラン適用

@main
struct EventPlannerApp: App {
    let container: ModelContainer
    
    init() {
        do {
            container = try ModelContainer(
                for: Event.self, WorkEvent.self, SocialEvent.self,
                migrationPlan: EventMigrationPlan.self
            )
        } catch {
            fatalError("ModelContainerの初期化に失敗: \(error)")
        }
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}

カスタムマイグレーションが必要な場合

プロパティのリネームや型変更など、軽量マイグレーションでは対応できないケースもあります。そんなときはカスタムマイグレーションの出番です。

static let migrateV2toV3 = MigrationStage.custom(
    fromVersion: EventSchemaV2.self,
    toVersion: EventSchemaV3.self,
    willMigrate: { context in
        // マイグレーション前の処理
        // 例:古いプロパティの値を一時保存
    },
    didMigrate: { context in
        // マイグレーション後の処理
        // 例:データの変換や初期値の設定
        let events = try context.fetch(
            FetchDescriptor<EventSchemaV3.Event>()
        )
        for event in events {
            // 新しいプロパティにデフォルト値を設定
        }
        try context.save()
    }
)

#Predicateの高度な活用テクニック

iOS 26のSwiftDataでは#Predicateがさらにパワーアップしています。継承と組み合わせると、かなり表現力の高いクエリが書けるようになりました。

複合条件のフィルタ

// 今週の必須ワークイベントのみ取得
let now = Date()
let weekFromNow = Calendar.current.date(
    byAdding: .day, value: 7, to: now
)!

let predicate = #Predicate<WorkEvent> { event in
    event.isRequired == true &&
    event.date >= now &&
    event.date <= weekFromNow
}

let descriptor = FetchDescriptor<WorkEvent>(
    predicate: predicate,
    sortBy: [SortDescriptor(\.date)]
)

let urgentWork = try context.fetch(descriptor)

#Expressionマクロとの組み合わせ(iOS 18+)

#Expressionマクロを使うと、カウントや集計を含むような複雑なクエリ条件も表現できます。

// 参加者数が多いソーシャルイベントを取得
let popularFilter = #Predicate<SocialEvent> { event in
    event.maxAttendees != nil &&
    event.maxAttendees! >= 50
}

パフォーマンス最適化のベストプラクティス

せっかくSwiftData iOS 26を使うなら、パフォーマンスも意識しておきたいですよね。ここでは実践的なポイントをまとめます。

1. フェッチ量を制限する

var descriptor = FetchDescriptor<Event>(
    sortBy: [SortDescriptor(\.date, order: .reverse)]
)
descriptor.fetchLimit = 20 // 最新20件だけ取得

全件取得は楽ですが、データが増えると確実にボトルネックになります。必要な分だけ取得する癖をつけましょう。

2. バイナリデータは外部ストレージに

@Model
class Event {
    var title: String
    @Attribute(.externalStorage) var thumbnailData: Data?
    // ...
}

画像やファイルなどの大きなバイナリデータは@Attribute(.externalStorage)で外部ストレージに逃がすのが鉄則です。データベース本体が肥大化すると、すべてのクエリに影響が出てしまいます。

3. 継承の深さを抑える

継承階層が深くなるほどクエリのコストが上がります。2〜3階層までに抑えるのが現実的なラインです。「共通プロパティが1つしかない」程度なら、継承ではなくプロトコルやコンポジションのほうが適切かもしれません。

4. 適切なインデックス設計

インデックスはクエリを高速化しますが、書き込みコストとのトレードオフがあります。読み取り頻度の高いプロパティにだけ設定し、書き込みが多いテーブルではインデックスの数を最小限に。闇雲に追加するのは逆効果です。

避けるべきアンチパターン

最後に、SwiftData iOS 26で継承を使う際に陥りがちなアンチパターンをまとめておきます。

  • 過度な継承:1つのプロパティを共有するためだけにサブクラスを作らない。プロトコルで十分なケースが多いです
  • 移行プランの省略:開発中は問題なくても、リリース後のアップデートでユーザーデータが消える原因になります
  • サブクラスの登録忘れModelContainerにサブクラスを追加し忘れると、データが保存されません(本当によくあるミスです)
  • 継承と#Uniqueの混在:サブクラス間でユニーク制約が衝突するケースがあるので注意
  • 大量データの一括フェッチfetchLimitを設定せずに全件取得すると、メモリ圧迫の原因に

まとめ:SwiftData iOS 26はプロダクション品質に到達した

iOS 26のSwiftDataは、クラス継承、成熟したスキーマ移行、#Indexによるパフォーマンス最適化が揃い、プロダクションアプリで安心して使えるレベルになりました。Core Dataからの移行を検討しているなら、正直なところ、今が一番いいタイミングだと思います。

この記事のポイントをおさらいしておきましょう。

  1. クラス継承@available(iOS 26, *)を付けてサブクラスを定義し、ModelContainerに全クラスを登録する
  2. #Index:頻繁にフィルタ・ソートするプロパティにインデックスを設定してクエリを高速化
  3. スキーマ移行VersionedSchemaSchemaMigrationPlanでバージョン管理し、ユーザーデータを安全に保つ
  4. #Predicate:型安全なクエリで、isキーワードによるサブクラスフィルタリングを活用

よくある質問(FAQ)

SwiftDataのクラス継承はiOS 25以前でも使えますか?

残念ながら、使えません。クラス継承はiOS 26以降でのみ利用可能で、サブクラスには@available(iOS 26, *)を付ける必要があります。iOS 25以前もサポートする場合は、基底クラスのみを使い、プロトコルやenumで型の区別を行う従来のアプローチで対応してください。

既存のCore DataプロジェクトからSwiftDataに移行すべきですか?

iOS 26時点で、SwiftDataはほとんどのユースケースに対応できる成熟度に達しています。ただし、NSFetchedResultsControllerの高度な機能やCloudKitの共有データベースなど、一部Core Data固有の機能はまだ未サポートです。新規プロジェクトならSwiftData一択、既存プロジェクトは段階的な移行を検討するのが現実的でしょう。

クラス継承とプロトコルのどちらを使うべきですか?

「is-a」関係(AはBの一種である)が成り立つならクラス継承が自然です。たとえば「ワークイベントはイベントの一種」はまさに継承向き。一方、「検索可能」「エクスポート可能」のように共通のインターフェースを定義したいだけなら、プロトコルを使いましょう。もちろん、SwiftDataモデルでは両方を組み合わせることも可能です。

スキーマ移行でデータが失われることはありますか?

正しくVersionedSchemaSchemaMigrationPlanを設定していれば、データは安全です。ただし、移行プランを省略してスキーマを変更すると、SwiftDataがデータベースを再作成してしまい、既存データが消えるリスクがあります。開発の早い段階から移行プランを用意しておく習慣をつけることを強くおすすめします。

#Indexを追加するとアプリサイズに影響しますか?

インデックスはデータベースファイル内に追加のメタデータを生成するので、ストレージ使用量はわずかに増えます。とはいえ、インデックス自体のサイズは元データに比べてごく小さいので、実用上は気にならないレベルです。数千件以上のレコードを扱うアプリなら、クエリパフォーマンスの向上メリットのほうがはるかに大きいので、積極的に活用してください。

著者について Editorial Team

Our team of expert writers and editors.