はじめに
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 な実装をしていて、苦しみが深いので採用しなかった。
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 を書き換える。
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
import Foundation
protocol FriendServiceProtocol {
func fetchFriends() -> [Friend]
func addFriend(_ friend: Friend)
}
今回使うのは、fetch と add だけなのでこれで。各 Service は、この fetchFriends と addFriend を用意する。
ViewModel の変更
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 で行う。
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 を作る。
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 の更新
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 も変更する。
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())
}
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 の実装方法あるよ!という場合は、教えて下さい。