0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

というわけで、Develop in Swift Tutorials: 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) を参考に、というかほぼそのまま利用させていただいた。

なお、How to use MVVM to separate SwiftData from your views では、普通の? ViewModel ではなく、View の Extension として ContentViewViewModel な実装をしていて、苦しみが深いので採用しなかった。

./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 を書き換える。

./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

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

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

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

ViewModel の変更

./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 で行う。

./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 を作る。

./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 の更新

./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 も変更する。

./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())
}
./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 の実装方法あるよ!という場合は、教えて下さい。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?