WWDC2023 SwiftDataセッションまとめ

## はじめに

この記事はWWDC2023で紹介があったSwiftDataについてのセッションまとめです。

https://developer.apple.com/videos/play/wwdc2023/10187/

https://developer.apple.com/videos/play/wwdc2023/10154/

https://developer.apple.com/videos/play/wwdc2023/10195/

SwiftDataはデータ永続化のためのフレームワークです。iOS17以上でしか使用できませんが、SwiftUIとの連携も簡単にできるため、将来的にはCoreDataからの置き換えが進められそうです。
同じくWWDC2023で紹介のあったマクロもゴリゴリに使われています。

はじめに、↓の公式サイトからSwiftDataを使用したサンプルプロジェクトのダウンロードをおすすめします。セッション内でもサンプルコードとして出てくるため、手元に置いておくと分かりやすいと思います。

https://developer.apple.com/documentation/SwiftUI/Building-a-document-based-app-using-SwiftData

## スキーマの定義方法

スキーマの定義には`@Model`マクロを使います。百聞は一見に如かずとういことで、旅行プランをスキーマにした例を見てみましょう。

```swift
@Model
class Trip {
    @Attribute(.unique) var name: String
    var destination: String
    @Attribute(originalName: "start_date") var startDate: Date
    @Attribute(originalName: "end_date") var endDate: Date

    @Relationship(.cascade) var bucketList: [BucketListItem]? = []
    @Relationship(.cascade) var livingAccommodation: LivingAccommodation?

    @Transient var tripViews: Int = 0
}
```

順を追って説明します。

`@Model`マクロは`Observable`プロトコルに準拠しています。そのため、各プロパティに`@Published`を付与する必要はなく、モデルが更新されると自動でViewに反映されます。

ユニーク制約は`@Attribute(.unique)`表せます。また、`@Attribute(originalName: "start_date")`によってスキーマとコードでの命名を変えられます。

Tripが削除された際に他の関連するデータも同時に削除したい場合は`@Relationship(.cascade)`を付与します。

最後に、一時的なプロパティでデータとして保存したくない場合は`@Transient`を使います。

## 使い方

実際にデータを操作するためには`ModelContainer`と`ModelContext`の2つが必要になります。

```swift
@main
struct TripsApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [
            Trip.self,
            LivingAccommodation.self,
        ])
    }
}

struct ContentView: View {
    @Query(filter: #Predicate<Trip> {
        $0.destination == "New York" &&
        $0.name.contains("birthday")
    }, sort: \.startDate, order: .reverse) var trips: [Trip]

    @Environment(\.modelContext) var modelContext

    var body: some View {
        NavigationStack() {
            List {
                ForEach(trips) { trip in
                    // ...
                }
            }
        }
    }
}

// データの追加や削除
modelContext.insert(object: trip)
modelContext.delete(object: trip)
```

SwiftUIの場合、Viewから直接`.modelContainer(for:)`を呼び出すことで、宣言した型のデータをそのサブView内で呼び出せるようになります。データの追加や削除をするためには`ModelContext`が必要になりますが、環境変数として用意されているため自分で作る必要はなさそうです。
`ModelContainer`と`ModelContext`はアプリ内で必要な分だけ生成して良いみたいです。(Mac版アプリで別Windowを開く場合などに必要になりそう)

View内のプロパティには`@Query`を付与するだけでデータのフィルタリング、ソート、オーダーなどが指定できます。fetchの処理などは必要なく、Viewが生成された際に自動でデータを取ってきてくれます。さらに`@State`のように、データの更新に合わせて自動でViewを再描画してくれます。

## Previewにテストデータを表示させたい！

Previewにテストデータを表示させたい場合は、モックデータと`ModelContainer`が必要になります。

```swift
@MainActor
let previewContainer: ModelContainer = {
    do {
        let container = try ModelContainer(
            for: Trip.self, ModelConfiguration(inMemory: true)
        )
        for card in Trip.mockDatas {
            container.mainContext.insert(object: card)
        }
        return container
    } catch {
        fatalError("Failed to create container")
    }
}()

#Preview {
    ContentView()
        .frame(minWidth: 500, minHeight: 500)
        .modelContainer(previewContainer)
}
```

手順としては、`ModelConteiner`の初期化→`ModelContext`を取り出してモックデータを挿入して、それをPreviewの`ModelContainer`に突っ込めばOKです。
あ、Previewもマクロが新しく定義されてめっちゃ簡単に書けるようになりましたね！

## マイグレーション時の対応

スキーマに変更があった際、データのマイグレーションを行う必要があります。SwiftDataでは、`VersionedSchema`と`SchemaMigrationPlan`を使用することで対応ができます。
今回は、2段階でスキーマを変更する場合を考えます。

```swift
enum SampleTripsSchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [Trip.self, BucketListItem.self, LivingAccommodation.self]
    }

    static var versionIdentifier: String?

    @Model
    final class Trip {
        var name: String
        var destination: String
        var start_date: Date
        var end_date: Date

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

    // Define the other models in this version...
}

enum SampleTripsSchemaV2: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [Trip.self, BucketListItem.self, LivingAccommodation.self]
    }

    static var versionIdentifier: String?

    @Model
    final class Trip {
        @Attribute(.unique) var name: String
        var destination: String
        var start_date: Date
        var end_date: Date

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

    // Define the other models in this version...
}

enum SampleTripsSchemaV3: VersionedSchema {

    // ...

    @Model
    final class Trip {
        @Attribute(.unique) var name: String
        var destination: String
        @Attribute(originalName: "start_date") var startDate: Date
        @Attribute(originalName: "end_date") var endDate: Date

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

    // Define the other models in this version...
}
```

まず、`VersionedSchema`でそれぞれのバージョンのスキーマをカプセル化します。↑の例では、

- `SampleTripsSchemaV1`→`SampleTripsSchemaV2`でnameにユニーク制約を追加
- `SampleTripsSchemaV2`→`SampleTripsSchemaV3`でstartDateとendDateにoriginalNameを追加して命名変更

という変更を加えています。

次に、`SchemaMigrationPlan`を定義します。

```swift
enum SampleTripsMigrationPlan: SchemaMigrationPlan {
    static var schemas: [VersionedSchema.Type] {
        [SampleTripsSchemaV1.self, SampleTripsSchemaV2.self, SampleTripsSchemaV3.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2, migrateV2toV3]
    }

    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: SampleTripsSchemaV1.self,
        toVersion: SampleTripsSchemaV2.self,
        willMigrate: { context in
            let trips = try? context.fetch(FetchDescriptor<SampleTripsSchemaV1.Trip>())
            // De-duplicate Trip instances here...
            try? context.save()
        },
        didMigrate: nil
    )

    static let migrateV2toV3 = MigrationStage.lightweight(
        fromVersion: SampleTripsSchemaV2.self,
        toVersion: SampleTripsSchemaV3.self
    )
}
```

ここでは先ほど定義した全ての`VersionedSchema`を渡します。そして、それぞれのマイグレーションで必要な処理を記述します。
`SampleTripsSchemaV1`→`SampleTripsSchemaV2`では、ユニーク制約を追加したため、マイグレーションの前に重複削除が必要になります。この場合、`MigrationStage.custom`を使用します。
一方で`SampleTripsSchemaV2`→`SampleTripsSchemaV3`ではoriginalNameを追加しただけなのでスキーマ自体に変更はなく、`MigrationStage.lightweight`を使用するだけで特に追加の処理は必要ありません。
`MigrationStage`では、`custom`と`lightweight`の2種類を適切に使い分ける必要があります。

最後に、アプリにこのマイグレーションを適用します。

```swift
@main
struct TripsApp: App {
    let container = ModelContainer(
        for: Trip.self,
        migrationPlan: SampleTripsMigrationPlan.self
    )

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }
}
```

`ModelContainer`を生成してそれを突っ込めば終了です！

以上です。数日に分けて編集したのでテンションに起伏がありますがご了承いただければと...