【SwiftData】SwiftDataプロジェクトのテンプレートを解説する

# この記事は何？

XcodeのiOSプロジェクトについて、ストレージ設定で「SwiftData」を選択した際のテンプレートコードを解説する。

> Swiftを基礎から学ぶには
自著、工学社より発売中の「[まるごと分かるSwiftプログラミング](https://amzn.asia/d/7YPWJwl)」をお勧めします。変数、関数、フロー制御構文、データ構造はもちろん、構造体からクロージャ、エクステンション、プロトコル、クロージャまでを基礎からわかりやすく解説しています。
> また、Swiftプログラミングを基礎から動画で学びたい方には、Udemyコース「[今日からはじめるプログラミング](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&cad=rja&uact=8&ved=2ahUKEwii1qTuj_2BAxV4hlYBHbj5BqEQFnoECBYQAQ&url=https%3A%2F%2Fwww.udemy.com%2Fcourse%2Fstart-programming-today%2F&usg=AOvVaw2vs4Sccq-bpfA4ZD2rCyNm&opi=89978449)」をお勧めします。

### 実行環境

- Swift 6
- macOS Sonoma 14.5
- Xcode 15.3

## テンプレートのコード

プロジェクトのストレージ設定でSwiftDataを選択すると、ナビゲーション構造を持ったリスト表示のアプリが構築される。
この時点で、リストの行データは追加と削除が可能。
下の画像は、リストに5つの行データを追加して、4つ目の行を削除しようとしているところ。

![スクリーンショット 2024-06-23 18.39.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/b3b753bc-e849-3f5e-2d1e-79a6918de043.png)

以下では、SwiftData向けのコードが追加されている3つのファイルを解説する。

### SwiftDataTemplateApp.swift

**モデルコンテナ** を作成して、アプリ全域で共有できるようにSwiftUIビューと接続する。
モデルコンテナは「SwiftDataモデルの倉庫」として機能する。

```swift: SwiftDataTemplateApp.swift
import SwiftUI
import SwiftData    // フレームワークを導入

@main
struct SwiftDataTemplateApp: App {
    // アプリ全域で使用するためのモデルコンテナを作成する
    var sharedModelContainer: ModelContainer = {
        // スキーマとモデルコンフィグを作成する
        let schema = Schema([Item.self,])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

        // モデルコンテナを作成できなければ、致命的なエラーでアプリを終了
        do {
            // スキーマとモデルコンフィグを指定して、モデルコンテナを初期化して返す。
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)    // アプリのビュー階層トップで、作成したモデルコンテナを接続
    }
}
```

### ContentView.swift

リスト形式でデータを表示する、アプリの基本画面。
ビューは「環境値のモデルコンテキスト」を介して、永続ストレージにアクセスしている。
`@Query`プロパティラッパーがマークされたクエリ変数の`items`プロパティはSwiftUIによって追跡されているので、変更が発生するとビューは再描画される。

```swift: ContentView.swift
import SwiftUI
import SwiftData    // フレームワークを導入

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext    // ビュー環境からモデルコンテキストにアクセス
    @Query private var items: [Item]                         // SwiftDataモデルからデータをフェッチするクエリ

    var body: some View {
        NavigationSplitView {
            List {
                // 行コンテンツのクロージャは、クエリ変数のitems配列にアクセスする
                ForEach(items) { item in
                    NavigationLink {
                        Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
                    } label: {
                        Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        } detail: {
            Text("Select an item")
        }
    }

    // モデルコンテキストにデータを挿入する
    private func addItem() {
        withAnimation {
            let newItem = Item(timestamp: Date())
            modelContext.insert(newItem)
        }
    }

    // モデルコンテキストのデータを削除する
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(items[index])
            }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Item.self, inMemory: true)  // プレビュー向けのモデルコンテナを指定
}
```

ここで、プレビューに適用したモデルコンテナは、「*SwiftDataTemplateApp.swift* で作成したモデルコンテナ」とは別物である点に注意すること。
*SwiftDataTemplateApp.swift* では、`.modelContainer(_:)`モディファイアを使用した。
*ContentView.swift* のプレビューには`.modelContainer(for:inMemory:)`モディファイアを使用している。

### Item.swift

`Item`クラスは「SwiftDataモデルのオブジェクト」を定義している。
クラスに`@Model`マクロをマークすると、`PersistentModel`プロトコルの適合性が追加されてオブジェクトを永続化できる。

```swift: Item.swift
import Foundation
import SwiftData    // フレームワーク

// @Modelマクロをマーク
@Model
final class Item {
    var timestamp: Date
    
    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}
```

## プレビュー向けのサンプルデータを永続化する

ContentViewでは、プレビューを操作して行データを追加できる。
ただし、プレビューがリロードされると、追加したアイテムは消えてしまう。
これは、プレビューのモデルコンテナに永続性がないから、リロードされるたびにモデルコンテナが空のデータセットを作成してしまう。
「本番アプリ向けの永続的なデータストア」に干渉しない、「サンプルの永続データ」を作成すれば解決できる。
すべてのプレビューにこのデータを使用させて、データセットの変更を確認できるようにする。

### サンプルデータのクラス

- 新しいSwiftファイル「 *SampleData.swift* 」を新規作成して、`SampleData`クラスを定義する
- ファイルにSwiftDataフレームワークをインポートする

```swift: SampleData.swift
import Foundation
import SwiftData  // フレームワークの導入

class SampleData {

}
```

### サンプルデータのモデルコンテナ

ビューのモディファイアを使用せずに、モデルコンテナを明示的に構築する。
そうすれば、サンプルデータを1か所で作成して管理し、すべてのプレビューで使用できる。

- モデルコンテナを保持する「定数の`modelContainer`プロパティ」を作成する

```swift:
import Foundation
import SwiftData  // フレームワークの導入

class SampleData {    // error; Class 'SampleData' has no initializers
    let modelContainer: ModelContainer
}
```

このモデルコンテナはデータを永続化せずに、メモリに保存させたい。
そのためには、クラスのイニシャライザでSwiftDataに必要なすべてのセットアップを実行する。
例えば、ディスクに保存することなく、データをメモリにのみ保存するようにモデル構成を設定する

- クラスのデフォルトイニシャライザを実装する

```swift:
class SampleData {
    let modelContainer: ModelContainer

    init() {
        // スキーマとモデル構成を作成
        let schema = Schema([Item.self])  
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)

        // モデルコンテナを初期化
        self.modelContainer = try! ModelContainer(for: schema, configurations: [modelConfiguration])
    }    
}
```

### コンテナへのアクセスを管理するコード

SampleDataのインスタンスをSampleDataクラス自体からのみ作成できるようにする。
これによって、`shared`が作成するインスタンスは唯一無二であることを保証される。
これは、グローバルな共有オブジェクトを作成するシングルトンパターン。

- `shared`共有インスタンスを作成する
- イニシャライザに`private`をマークする

```swift:
class SampleData {
    static let shared = SampleData()    // シングルトンのインスタンス
    let modelContainer: ModelContainer

    private init() {    // 外部からのインスタンス作成を禁止
        let schema = Schema([Item.self])  
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)

        self.modelContainer = try! ModelContainer(for: schema, configurations: [modelConfiguration])
    }    
}
```


### コンテキストを作成する

ビューからのSwiftDataモデル操作は、モデルコンテキストを介して行われる。
コードをより簡潔にするため、モデルコンテナのメインコンテキストにアクセスするための計算プロパティを作成する。

```swift:
@MainActor
class SampleData {
    static let shared = SampleData()
    let modelContainer: ModelContainer

    var context: ModelContext {
        return modelContainer.mainContext  // モデルコンテナはコンテキストを提供する
    }

    private init() {
        let schema = Schema([Item.self])  
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
    }    
}
```

Xcodeは「*メインアクターから`ModelContainer`の`mainContext`プロパティにアクセスすべき*」というエラーを報告する。
このエラーは、複数のタスクを同時に実行する方法であるSwiftの並行性に関連している。
Swiftは、アクターを使用して同時に実行されるコードを管理する。
SampleDataクラスに`@MainActor`をマークすると、エラーを解消できる。
これによって、`mainContext`プロパティへのアクセスを含めた、このクラスのコードがメインアクターで実行される。
なお、SwiftUIコードは原則、メインアクターで実行される。

```swift:
@MainActor  // メインアクターでデータレースを回避
class SampleData {
    static let shared = SampleData()
    let modelContainer: ModelContainer

    var context: ModelContext {
        return modelContainer.mainContext
    }

    private init() {
        // スキーマとモデル構成を作成
        let schema = Schema([Item.self])  
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
    }    
}
```

### サンプルの行データインスタンス

SwiftDataモデルで、適当なサンプルデータの配列を型プロパティとして定義する。

```swift: Item.swift
@Model
final class Item {
    var timestamp: Date
    
    init(timestamp: Date) {
        self.timestamp = timestamp
    }

    static let samples = [
        Item(timestamp: Date(timeIntervalSinceReferenceDate: -402_000_000)),
        Item(timestamp: Date(timeIntervalSinceReferenceDate: 300_000_000)),
        Item(timestamp: Date(timeIntervalSinceReferenceDate: -700_000_000)),
    ]
}
```

サンプルデータを提供するオブジェクトのイニシャライザで、コンテキストにデータの挿入を実行する。

```swift: SampleData.swift
@MainActor
class SampleData {
    static let shared = SampleData()
    
    let modelContainer: ModelContainer
    
    var context: ModelContext {
        return modelContainer.mainContext
    }

    private init() {
        let schema = Schema([Item.self])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)  

        self.modelContainer = try! ModelContainer(for: schema, configurations: [modelConfiguration])
        insertSampleData    // 挿入メソッドの呼び出し
    }

    // コンテキストがSwiftDataモデルにデータを挿入する
    func insertSampleData() {
        for item in Item.samples {
            context.insert(item)
        }
        try! context.save()
    }
}
```

### プレビューに永続化されたサンプルデータを接続する

ContentViewのプレビューにモデルコンテナを接続する。
そのためには、モディファイアを変更して「シングルトンのサンプルデータオブジェクト」を指定する。

```swift: ContentViewのプレビュー
#Preview {
    ContentView()
        .modelContainer(SampleData.shared.modelContainer)
}
```

プレビューがリロードされても、リストにはサンプルの行コンテンツが配置されるようになった。

### SampleDataの全体

```swift: SampleDataクラスの全体
import Foundation
import SwiftData

@MainActor
class SampleData {
    static let shared = SampleData()
    let modelContainer: ModelContainer
    
    var context: ModelContext {
        return modelContainer.mainContext
    }
    
    private init() {
        let schema = Schema([Item.self])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
        
        self.modelContainer = try! ModelContainer(for: schema, configurations: [modelConfiguration])
        insertSampleData()
    }
    
    func insertSampleData() {
        for item in Item.samples {
            context.insert(item)
        }
        try! context.save()
    }
}
```