こんにちは、決済認証システム開発事業部 iOSエンジニアの冨永です!!
昨今、SwiftUIの案件が増えつつあり、開発者としては日々ワクワクしております。 SwiftUIにて当初ViewModelを@EnvironementObjectで宣言し実装していたのですが、 iOS16対応をきっかけに、いくつか問題点が見つかり、@StateObjectで実装するに至りました。 今回は@StateObject,@ObservedObject,@EnvironmentObjectの3パターンの実装方法と特徴を並べつつ、ViewModelの実装について検討していきたいと思います。
目次
- 【前提】筆者のViewModelの認識
- 【方法】SwiftUIではViewModelをどう実装するか
- 【結論】@StateObjectで定義したViewModelが使いやすいのでは。。?
- 【脱線】そもそもViewModelを採用すべき??
- その他参考資料
【前提】筆者のViewModelの認識
筆者のViewModelの認識ですが、Android公式サイトの推奨アーキテクチャのViewModelの説明が自分の中でのViewModelの認識になります。
ViewModelはViewから分離されたプレゼンテーションロジックが含まれるという認識です。
【方法】SwiftUIではViewModelをどう実装するか
ではまずはViewModelの実装方法の3パターンについて説明、検討します。
1. @EnvironmentObjectでViewModelを定義
■ 実装例
// メインクラスの実装 @main struct MVVMApp: App { let model = Model() var body: some Scene { WindowGroup { SampleView() .environmentObject(SampleViewModel(model: model)) } } }
// Viewの実装 struct SampleView: View { @EnvironmentObject var viewModel: SampleViewModel var body: some View { VStack(spacing: 8) { Text("\(viewModel.count)") Button("Count Up") { viewModel.countUp() } } } }
// ViewModelの実装 class SampleViewModel: ObservableObject { @Published var model: Model var count: Int { model.count } init(model: Model) { self.model = model } func countUp() { model.count += 1 } func changeStarsLength(_ len: Int) { model.changeStarsLength(len) } }
// Modelの実装 struct Model { var count: Int = 0 var stars: String = "★" mutating func changeStarsLength(_ len: Int) { self.stars = [String](repeating: "★", count: len).joined() } }
■ 特徴
😊 ViewModel間でModelの共有がしやすい
😊 DIを一箇所に集約でき、依存関係がわかりやすい ※1
😊 Viewを呼び出す際のコード量が減る ※2
😓 previewでEnvironment Object定義を忘れがち。
😓 画面表示とViewModel生成のタイミングが異なるため、画面表示時にViewModel内で初期化処理が必要
😱 ダミーオブジェクトが作成できないため、Previewの起動に時間がかかることも. ※3
※1 以下のように@main部分にEnvrionmentObjectをまとめて宣言出来るので、依存関係がわかりやすい。
@main struct MVVMApp: App { let model = Model() var body: some Scene { WindowGroup { ContentView() .environmentObject(SampleViewModel(model: model)) .environmentObject(SampleViewModel2(model: model)) .environmentObject(SampleViewModel3(model: model)) .environmentObject(SampleViewModel4(model: model)) } } }
※2 NavigationLinkなどでViewを呼び出す際の記述量が減る
NavigationLink(isActive: ...) { SampleView() // SampleView(viewModel:SampleViewModel)と書かなくてよい } label: { // 省略 }
※3 型が一致しないと@Environment Objectを呼び出せないため、ダミーオブジェクトを作成できない。 そのため、ViewのonAppear()でViewModelの重い処理がある場合は、Previewの表示に時間がかかることもしばしば、、、、
2. @StateObjectでViewModelを定義
■ 実装例
// メインクラスの実装 import SwiftUI @main struct MVVMApp: App { var body: some Scene { WindowGroup { SampleView(viewModel: .init(model: model)) } } }
// Viewの実装 struct SampleView: View { @StateObject var viewModel: SampleViewModel var body: some View { VStack(spacing: 8) { Text("\(viewModel.count)") Button("Count Up") { viewModel.countUp() } } } }
// ViewModel・Modelの実装はEnvironmentObjectの例と同じ
■ 特徴
😊 画面表示タイミングとViewModel生成のタイミングが一致しているため、ライフサイクルが管理しやすい
😊 親Viewの更新タイミングと独立してViewが更新される (ObservedObjcetとの違い)
😊 ダミーオブジェクトが作成できるため、迅速にPreviewを確認できる
😓 各Viewで依存関係が定義されるため、依存関係がわかりづらい
😓 Viewを呼び出す際に呼び出し元で呼び出し先のViewModelも定義する必要がある ※3
※3 NavigationLinkなどでViewを呼び出す際の記述量が増える
NavigationLink(isActive: ...) { SampleView(viewModel:SampleViewModel(model:Model)) } label: { // }
解決策として、init(wrapper)を使う方法があります。 ただしダミーオブジェクトが作りづらく、またメモリーリークの可能性があるなど議論されています。 公式サイト上では非推奨と書かれていますが、Apple的には許容される初期化方法だという情報も。。。
3. @ObservedObjectでViewModelを定義
■ 実装例
// メインクラスの実装 import SwiftUI @main struct MVVMApp: App { @StateObject var viewModel:SampleViewModel = .init(model:Model()) var body: some Scene { WindowGroup { SampleView(viewModel: viewModel) } } }
// Viewの実装 struct SampleView: View { @ObservedObject var viewModel: SampleViewModel var body: some View { NavigationStack { VStack(spacing: 8) { Text("\(viewModel.count)") Button("Count Up") { viewModel.countUp() } } } } }
// ViewModel・Modelの実装はEnvironmentObjectの例と同じ
■ 特徴 StateObjectと特徴は類似。違う点としては
- 🤔 親Viewの更新タイミングと同期してViewが更新される
→1つのViewModelに対して、複数のViewで表示・更新する際に有効
【結論】@StateObjectで定義したViewModelが使いやすいのでは。。?
ViewとViewModelは1:1で実装する場合が多いため、@ObservedObjectを使った実装パターンは用途が限られるかと思います。 @EnvrionmentObjectの問題点としてはダミーオブジェクトを作成できない点、 ViewModelとViewのライフサイクルが一致しない点が挙げられます。 上記理由により、まずは@StateObjectで実装するのが、 迅速に開発を進める上では良いのかと思いました。 自分自身開発当初、@EnvironmentObjectでViewModelを宣言し、ViewのonAppear時にViewModel内を初期化するように実装していたのですが、 iOS16ではonAppearより先にNavigationLink内の処理が評価されるという仕様になり、ViewModelの初期化タイミングで詰まってしまったので、 @StateObjectを採用するに至りました。 開発の参考になれば幸いです。
【脱線】そもそもViewModelを採用すべき??
そもそもViewModel採用の前に
案件の規模、状態管理の有無、学習コストを考慮し、
まずはViewとModelのみのシンプルな構成を検討すべきかなと思いました。
- 個人開発など、案件規模が小さい場合はViewとModelのみを検討
→@EnvironmentObjectや@AppStorageなどのプロパティラッパーはVMのみの方が使いやすそうな印象でした - 案件規模が中規模かつ、プレゼンテーションロジックが複雑になりやすい場合はViewModelを検討
→顧客の要望によって、表示方法が変わるので、、、 - 複雑な状態管理が必要かつ、自社サービスなど学習リソースがある場合はTCAなどを検討
→MVVMでは状態管理が難しい印象でした。ただし、TCAは学習コストが高く受託案件で導入する際は人材獲得の面で注意が必要だなと思いました。(MVVMでの状態管理についてこうしているよ!というのがあれば、この記事を引用し、Twitterにて教えていただきたいです🙇♂️)
参考
その他参考資料
2つほど案を考えてみました💭
— 小清水 健人 (@_take_hito_) 2022年9月28日
いい方法かどうかは好みによるかも・・😅
Sample1はプロトコル適合
Sample2はクラス継承
個人的にはSample1が好み。 https://t.co/wXWD6wLGvQ pic.twitter.com/d39dsECHkX
SwiftUIでViewModelは@ObservedObjectではなく@StateObjectを使うべき|TAAT|note