SwiftData で MVVM は虹の橋を渡ったのか？

## はじめに

SwiftData に入門してみた。非常に便利である。便利ではあるが、Preview どうしたらうまいのか分からない。とか、SwiftData は View の中で使うものなような気もするが、込み入ったことをしようとすると、View が汚れて気分が悪い。など、気に入らない点が出てきた。

というわけで、[Develop in Swift Tutorials: Welcome to data modeling](https://developer.apple.com/tutorials/develop-in-swift/welcome-to-data-modeling) の Friend Birthday アプリのサンプルを元に考えてみたい。

いったん、コードは、Develop in Swift Tutorials の最終形まで出来ているとしてスタートする。

### 初期構造
```
FriendBirthday/FriendBirthday
 ├── ContentView
 ├── Friend
 └── FriendBirthdayApp
```

今回は、これを ViewModel を使って良い感じに仕上げてみたいと思う。

結論としては、今回のようなシンプルなアプリでは明らかに ViewModel を使わない方が良い。もっと複雑なアプリでも微妙な気がする。SwiftData は MVVM を潰しにかかってきたのか？！と思わなくもないが、iOS 18 が出たばかりの 2024年12月は、まだまだ過渡期ということで、SwiftData を MVVM でやるならこんな感じ？という参考までに。

## セットアップ

まず、このサンプルでは過剰だが、実際のアプリ開発は様々な画面を作ることになることが多く、このようなフラットな構造はメンテナンス性が悪いので、実際のアプリ開発を念頭に、ディレクトリを掘ることにする。掘り方は好みの問題な気がするので、私はこうするという例だと思っていただければと。

### 新しい階層構造
```
FriendBirthday/FriendBirthday
├── App
│   └── FriendBirthdayApp
└── Core
    ├── Friend
    │   ├── Model
    │   │   └── Friend
    │   └── View
    │       └── FriendView
    └── Root
        └── View
            └── ContentView
```

`ContentView` の内容を `FriendView` にまるっと切り出し、`ContentView` の Body では `FriendView()` と呼び出すだけにした。実際のアプリでは、ContentView を TabView したり、NavigationLink などでページの切り替えがあって、様々なページを実装する。内容にもよるが、ページごとに Core / Settings のように掘っていく。その下は、Model, View, ViewModel ディレクトリを基本とし、必要に応じて Service とか Component などのディレクトリを掘る。s をつけるかどうかは、付いてた方が正しい気がするが、今回は付けなかった。いつもつけない。View ファイルには、◯◯View という命名にしている。そんなつもり。

Build してちゃんと動くことを確認して次に進む。

## ViewModel する？

`@Query` マクロと ModelContext でもう十分では？ ModelContext に View 以外からアクセスするの？などなど SwiftData って、View で使うものでは？と悩ましい。私は、必要ないんじゃないかなー？そして、もし、なんか複雑なことをやりたくなったときに、それが ViewModel である必要はないかも知れないなーなどと思っている。が、まぁいったん、ViewModel にしてみたい。

```
FriendBirthday/FriendBirthday
├── App
│   └── FriendBirthdayApp <- 変更
└── Core
    ├── Friend
    │   ├── Model
    │   │   └── Friend
    │   ├── ViewModel
    │   │   └── FriendViewModel <- 追加
    │   └── View
    │       └── FriendView <- 変更
    └── Root
        └── View
            └── ContentView
```

[SwiftUI: Use SwiftData outside a View (In a Manager Class/ViewModel)](https://levelup.gitconnected.com/swiftui-use-swiftdata-outside-a-view-in-a-manager-class-viewmodel-d6659e7d3ad9) を参考に、というかほぼそのまま利用させていただいた。

なお、[How to use MVVM to separate SwiftData from your views](https://www.hackingwithswift.com/quick-start/swiftdata/how-to-use-mvvm-to-separate-swiftdata-from-your-views) では、普通の？ ViewModel ではなく、View の Extension として ContentViewViewModel な実装をしていて、苦しみが深いので採用しなかった。

```swift:./Core/Friend/ViewModel/FriendViewModel.swift
import SwiftData
import SwiftUI

class FriendViewModel: ObservableObject {
    @Published var friends: [Friend] = []

    var modelContext: ModelContext? = nil
    var modelContainer: ModelContainer? = nil

    @MainActor
    init() {
        do {
            let configuration = ModelConfiguration(isStoredInMemoryOnly: false)
            let container = try ModelContainer(
                for: Friend.self, configurations: configuration)
            modelContainer = container

            modelContext = container.mainContext
            modelContext?.autosaveEnabled = true

            fetchFriends()
        } catch {
            print(
                "DEBUG: FriendViewModel init failed: \(error.localizedDescription)"
            )
        }
    }

    private func fetchFriends() {
        guard let modelContext = modelContext else { return }

        let friendsFetchDescriptor = FetchDescriptor<Friend>(
            predicate: nil,
            sortBy: [
                .init(\.birthday)
            ]
        )

        do {
            friends = try modelContext.fetch(friendsFetchDescriptor)
        } catch {
            print(
                "DEBUG: Failed to fetch friends: \(error.localizedDescription)")
        }
    }

    func addFriend(_ friend: Friend) {
        guard let modelContext = modelContext else { return }

        modelContext.insert(friend)
        save()
        fetchFriends()
    }

    private func save() {
        guard let modelContext = modelContext else { return }

        do {
            try modelContext.save()
        } catch {
            print("DEBUG: Failed to save: \(error.localizedDescription)")
        }
    }
}
```

なんで、`var modelContainer: ModelContainer? = nil` が必要？とか、`modelContext?.autosaveEnabled = true` した上で、`save()` してるのなぜ？とかは、元記事を読んでいただきたいが、簡単に言えばそうしないとうまくいかないからだ。

この ViewModel を使って View を書き換える。

```swift:./Core/Friend/View/FriendView.swift
import SwiftUI
import SwiftData

struct FriendView: View {
//    @Query(sort: \Friend.birthday) private var friends: [Friend]
//    @Environment(\.modelContext) private var context
    @StateObject private var viewModel = FriendViewModel()

    @State private var newName = ""
    @State private var newDate = Date.now

    var body: some View {
        NavigationStack {
//            List(friends) { friend in
//                HStack { ... }
//            }
            List(viewModel.friends) { friend in
                HStack { ... }
            }
            .navigationTitle("Birthdays")
            .safeAreaInset(edge: .bottom) {
                VStack(alignment: .center, spacing: 20) {
                    Text("New Birthday")
                        .font(.headline)
                    DatePicker(...) { ... }
                    Button("Save") {
                        let newFriend = Friend(name: newName, birthday: newDate)
//                        context.insert(newFriend)
                        viewModel.addFriend(newFriend)


                        newName = ""
                        newDate = .now
                    }
                    .bold()
                }
                .padding()
                .background(.bar)
            }

        }
    }
}

#Preview {
    FriendView()
//        .modelContainer(for: Friend.self, inMemory: true)
}
```

最後に `FriendBirthdayApp.swift` からは、`.modelContainer(for: Friend.self)` を削除する。

これで一応動く。ToDo アプリとか、Calendar アプリとか、あれこれ対象データを集計したり、はたまた、とにかくデータを元にあれこれ複雑なことをしたくなったときには、View がスッキリして満足できるかも知れない。

## Service を追加して美しくする

せっかくなので、SwiftData を利用するものと、Mock データを利用するものとを切り替えて使えるようにしてみたい。ViewModel の時点で、うわ〜と思ってるので、これ以上続けるのか？とモチベーションが低めだが、せっかくなので続けてみる。

今回は、手元の Xcode でプレビューして UI を作り込んでいく際に、SwiftData だとイマイチなのを解消するのと、大量にデータを入れてみたくなったりするかも知れないなとか、古い iOS をサポートしなければならず SwiftData 使えなくなっちゃいましたなどの事態に備えて、あっても良いのかな？

```
FriendBirthday/FriendBirthday
├── App
│   └── FriendBirthdayApp <- 変更
└── Core
    ├── Friend
    │   ├── Model
    │   │   └── Friend
    │   ├── Protocol
    │   │   └── FriendServiceProtocol ← 追加
    │   ├── Service
    │   │   ├── FriendService ← 追加
    │   │   └── MockService ← 追加
    │   ├── ViewModel
    │   │   └── FriendViewModel ← 変更
    │   └── View
    │       └── FriendView ← 変更
    └── Root
        └── View
            └── ContentView ← 変更
```

流れとしては、Protocol で Service を定義し、その Protocol に準拠した実際使う用と開発用の 2 種類の Service を用意して、ViewModel に渡す Service を切り替えることで、実現する。まぁ普通の流れだ。

### Protocol

```swift:./Core/Friend/Protocol/FriendServiceProtocol.swift
import Foundation

protocol FriendServiceProtocol {
    func fetchFriends() -> [Friend]
    func addFriend(_ friend: Friend)
}
```

今回使うのは、fetch と add だけなのでこれで。各 Service は、この fetchFriends と addFriend を用意する。

### ViewModel の変更

```swift:./Core/Friend/ViewModel/FriendViewModel.swift
import SwiftData
import SwiftUI

class FriendViewModel: ObservableObject {
    @Published var friends: [Friend] = []
    
    private let service: FriendServiceProtocol
    
    init(service: FriendServiceProtocol) {
        self.service = service
        fetchFriends()
    }
    
    func fetchFriends() {
        self.friends = service.fetchFriends()
    }
    
    func addFriend(_ friend: Friend) {
        service.addFriend(friend)
        self.friends = service.fetchFriends()
    }
}
```

受け取った Service を注入して、Service に配置された各メソッドを実行する。self なのか service なのか混同しないように、`self.` を明示的に付けた。

### Service の追加

SwiftData を使う方の Service は、元々の ViewModel をゴソッと移植し、多少変更を加える。ViewModel 側でやることは削除し、ViewModel で使うように return するようにする。Binding は ViewModel で行う。

```swift:./Core/Friend/Service/FriendService.swift
import Foundation
import SwiftData

class FriendService: FriendServiceProtocol {
//    var friends: [Friend] = [] ← 削除
    
    var modelContext: ModelContext? = nil
    var modelContainer: ModelContainer? = nil
    
    @MainActor
    init(inMemory: Bool) {
        do {
            ...
//            fetchFriends() ← 削除
        } catch {...}
    }
    
    func fetchFriends() -> [Friend] {
        guard let modelContext = modelContext else { return [] } // ← return を return [] に変更

        ...

        do {
            // service 内のプロパティを更新するのではなく、return するように変更
//            friends = try modelContext.fetch(friendsFetchDescriptor)
            return try modelContext.fetch(friendsFetchDescriptor)
        } catch {
            ...
            return [] // ← return を return [] に変更
        }
    }

    func addFriend(_ friend: Friend) {
        ...
//        fetchFriends() ← friends の更新は ViewModel 側でやるので削除
    }

    private func save() {...}
}
```

エラー時 `return []` で良いのか？などは検討すべきだが、今回の内容とはちょっとずれるのでこれで。
次に、MockService を作る。

```swift:./Core/Friend/Service/MockService.swift
import Foundation

class MockService: FriendServiceProtocol {
    @Published var friends: [Friend] = []

    init() {
        self.friends = [
            Friend(name: "Elton Lin", birthday: Date()),
            Friend(name: "Jenny Court", birthday: Date(timeIntervalSince1970: 0))
        ]
    }

    func fetchFriends() -> [Friend] {
        return self.friends
    }

    func addFriend(_ friend: Friend) {
        self.friends.append(friend)
    }
}
```
ただの配列操作。init も引数を揃えた方が美しいような気もするが、使わないのでなしで。

### View の更新

```swift:./Core/Friend/View/FriendView.swift
import SwiftData
import SwiftUI

struct FriendView: View {
//    @StateObject private var viewModel = FriendViewModel()
    @ObservedObject var viewModel: FriendViewModel

    @State private var newName = ""
    @State private var newDate = Date.now

    init(service: FriendServiceProtocol) {
        self.viewModel = FriendViewModel(service: service)
    }

    var body: some View {...}
}

#Preview {
    FriendView(service: MockService())
    //FriendView(service: FriendService(inMemory: true))
}
```

各 View で SwiftData を使った状態で確かめたいケースは、一度動作確認してしまえば、あんまりなさそうな気もするが、使いたい場合は、Preview で `FriendView(service: FriendService(inMemory: true))` とする。inMemory は、実際のリリースバージョンでは、false にして永続化するが、Preview で永続化されると鬱陶しいので、true にする。Build 時には関係ない Preview で切り替えられるのが素敵。

ContentView と App も変更する。

```swift:./Core/Root/View/ContentView.swift
import SwiftUI

struct ContentView: View {
    private let service: FriendServiceProtocol
    
    init (service: FriendServiceProtocol) {
        self.service = service
    }
    
    var body: some View {
        FriendView(service: service)
    }
}


#Preview {
    ContentView(service: MockService())
}
```

```swift:./App/FriendBrithdayApp.swift
import SwiftUI

@main
struct FriendBirthdayApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView(service: FriendService(inMemory: false))
        }
    }
}
```

Build して Simulator で Mock データを使いたいとか、永続化したくないということはないと思うので、inMemory は false で。

事故を減らすためにも、こんな感じにバケツリレーして良いんじゃないかと思う。

## 結論

いらねーだろと思いながら作ってみて、あんなシンプルだったコードがこんなに複雑になってしまうのか？！と絶望したりもしましたが、出来てしまえば、まぁやっぱり便利というか、もしかして有りでは？と思えてきた。

テストデータを入れるのに、Tutorial のような task でぶっこむよりは、こちらの方がスマートである。（よね？）

そのうち今回のような SwiftData の使い方は将来的にはエラーになるかも知れないが、SwiftData 使って iOS17 以前を切り捨てるような男気あふれるアプリは、日本ではまだあまりなさそうだし、いまのところ MVVM で良いんじゃないでしょうか。

もっとスマートな MVVM の実装方法あるよ！という場合は、教えて下さい。