【SwiftUI】永続データモデルのアプリを作成する

# この投稿は何？

iOSアプリケーションのデータモデルを永続化する方法について、解説する。
なお、SwiftUIと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)」をお勧めします。

### SwiftData

SwiftDataには2つのキー・オブジェクトがある。

- [モデルコンテナ](https://developer.apple.com/documentation/swiftdata/modelcontainer)
- [モデルコンテキスト](https://developer.apple.com/documentation/swiftdata/modelcontext)

コンテナの役割は、モデルデータの保管場所を提供することです。
コンテナはモデルとなる型の定義を理解して、モデルデータの一貫性を維持します。
そして、コンテキストは「メモリ上のデータとコンテナとの間で調整する役」を担います。

## DiceRollアプリ

SwiftUIで開発した、データ永続化を行う対象となるアプリ。
ボタンをタップすることでサイコロを振り、「出た目の数と日付」をリスト表示する。

![スクリーンショット 2024-06-16 13.53.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/cb773ee6-84bf-5952-2663-0df859e6c274.png)


```swift: Record.swift
import Foundation

struct Record {
    let number: Int
    let timeStamp: Date
}
```

```swift: ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var records: [Record] = [
        Record(number: 1, timeStamp: .now),
    ]
    
    var body: some View {
        NavigationStack {
            List(records, id: \.timeStamp) { record in
                RowView(record: record)
            }
            .navigationTitle("Records")
            .safeAreaInset(edge: .bottom) {
                Button {
                    let newRecord = Record(number: .random(in: 1...6), timeStamp: .now)
                    records.append(newRecord)
                } label: {
                    Text("Dice Roll")
                        .padding(.horizontal)
                        .bold()
                }
                .buttonStyle(.borderedProminent)
            }
        }
    }
}

#Preview {
    ContentView()
}
```

```Swift: RowView.swift
import SwiftUI

struct RowView: View {
    let record: Record
    
    var body: some View {
        HStack {
            Image(systemName: "die.face.\(record.number)")
                .resizable()
                .frame(maxWidth: 44, maxHeight: 44)
                .aspectRatio(1, contentMode: .fit)
            Spacer()
            Text(record.timeStamp, format: .dateTime.hour().minute().second().month(.wide).day())
                .foregroundStyle(.secondary)
        }
    }
}

#Preview {
    RowView(record: Record(number: 1, timeStamp: .now))
}
```

## アプリのデータを永続化するには

DiceRollアプリにSwiftDataを導入してリストのコンテンツを永続化する作業は、以下のステップに分けられる。

1. クラスをSwiftDataモデルに変換して、永続可能にする
2. コンテナを作成して、アプリのストレージを設定する
3. 後で使用するためのモデルをセーブする
4. 表示や追加処理のためにモデルをフェッチする

> なお、アプリにSwiftDataを導入するにあたっては、作業が完了するまでSwiftUIプレビューは正しく機能しないので、キャンバス上で発生するエラーは無視して良い。

### クラスをSwiftDataモデルに変換して、永続可能にする

SwiftDataでモデルクラスのインスタンスを保存するには、フレームワークをインポートし、`Model()`マクロでそのクラスにアノテーションをマークすることから始めます。

DiceRollアプリのデータモデルは、「サイコロの出た目と日付」を保持する`Record`構造体です。
これを基にして、SwiftDataモデルを作成します。

- *Record.swift* ファイルにSwiftDataフレームワークをインポートする
- `Record`型に`@Model`マクロを適用する
- `@Model`マクロはクラスに対してのみ有効なので、`Record`型を構造体からクラスに変更する
- Swiftはクラスにイニシャライザを提供しないので、イニシャライザを実装する

```swift: Record.swift
import Foundation
import SwiftData

@Model
class Record {
    let number: Int
    let timeStamp: Date
    
    init(number: Int, timeStamp: Date) {
        self.number = number
        self.timeStamp = timeStamp
    }
}
```

### コンテナを作成して、アプリのストレージを設定する

SwiftDataがモデルを調べてスキーマを生成するには、事前に以下の設定を行います。

- 実行時にどのモデルを永続させるか
- 基礎となるストレージの構成（オプション）

実行時に永続するモデルは、**モデルコンテナ** に指定します。

- *DiceRollApp.swift* ファイルにSwiftDataフレームワークをインポートする
- ビュー階層のトップに、[.modelContainer(for:)モディファイア](https://developer.apple.com/documentation/swiftui/view/modelcontainer(for:inmemory:isautosaveenabled:isundoenabled:onsetup:)-18hhy)でモデルコンテナを設定する

```swift: DiceRollApp.swift
import SwiftUI
import SwiftData

@main
struct DiceRollApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(for: Record.self)
                //`Record.self`は特定のインスタンスではなく、型の定義を指す
        }
    }
}
```

#### プレビューの修復

プレビューとモデルコンテナを接続するが、プレビューは常に更新するたびに同じ初期状態で開始される必要がある。
`.modelContainer(for:)`モディファイアの`inMemory`引数に`true`を指定すると、モデルコンテナはインメモリコンテナ方式で動作する。
つまり、アプリがメモリ上にある場合にのみ、データが保存されるようになる。

- *ContentView.swift* ファイルにSwiftDataフレームワークをインポートする
- プレビューにモデルコンテナを接続する

```swift: ContentViewのプレビュー
#Preview {
    ContentView()
        .modelContainer(for: Record.self, inMemory: true)
}
```

### 後で使用するためのモデルをセーブする

実行時にモデルクラスのインスタンスを管理するには、**モデルコンテキスト** を使用します。
モデルコンテキストは、「メモリ上にあるデータとモデルコンテナ間の調整役」を担うオブジェクトです。
コンテキストを取得するには、`modelContext`環境変数を使用します。
すでに、アプリにはモデルコンテナが設定済みなので、ビューの環境値からモデルコンテキストを取得できます。
この方法で、「コンテナより下層のすべてのビュー」からモデルコンテキストへのアクセスが可能になります。

```swift:ContentView.swift
struct ContentView: View {
    @State private var records: [Record] = [
        Record(number: 1, timeStamp: .now),
    ]
    @Environment(\.modelContext) private var context

    ...
}
```

> この時点で、配列の`append(_:)`メソッドがエラー（*Cannot use mutating member on immutable value: 'records' is a get-only property*）を報告します。

ボタンのコードでは「新しい`Record`インスタンスを追加する」ために、`append(_:)`メソッドを呼び出しています。
SwiftDataモデルのデータは、SwiftUIのコードから直接操作できません。

- 配列の追加メソッドを、モデルコンテキストの[`insert(_:)`メソッド](https://developer.apple.com/documentation/swiftdata/modelcontext/insert(_:))に置き換える

```swift: ボタンアクションを変更
Button {
    let newRecord = Record(number: .random(in: 1...6), timeStamp: .now)
    // records.append(newRecord)
    context.insert(newRecord)
} label: {
    Text("Dice Roll")
        .padding(.horizontal)
        .bold()
}
.buttonStyle(.borderedProminent)
```

### 表示や追加処理のためにモデルをフェッチする

永続化したモデルデータに対しては、次のようなアクションが可能です。

- データを取得
- モデルインスタンスとして具体化してビューに表示
- その他のアクション

SwiftDataは、フェッチを実行するための`Query`プロパティラッパーと`FetchDescriptor`型を提供します。

- `records`プロパティの属性を`@State`から`@Query`に変更する
- `records`プロパティの既定値を削除する

```swift:ContentView.swift
struct ContentView: View {
    @Query private var records: [Record]
    @Environment(\.modelContext) private var context

    ...
}
```

SwiftDataモデルの各インスタンスには、自動的にアイデンティティが提供されます。
この`@Model`マクロによるアイデンティティを使用すると、リストのイニシャライザから明示的な`id`指定を削除できます。

```swift: リストのコンテンツ識別を自動化する
// List(records, id: \.timeStamp) { record in
List(records) { record in
    RowView(record: record)
}
.navigationTitle("Records")
.safeAreaInset(edge: .bottom) {
```