ViewModelは@EnvironmentObjectを使わない方がいい?? in SwiftUI

こんにちは、決済認証システム開発事業部 iOSエンジニアの冨永です!!

昨今、SwiftUIの案件が増えつつあり、開発者としては日々ワクワクしております。 SwiftUIにて当初ViewModelを@EnvironementObjectで宣言し実装していたのですが、 iOS16対応をきっかけに、いくつか問題点が見つかり、@StateObjectで実装するに至りました。 今回は@StateObject,@ObservedObject,@EnvironmentObjectの3パターンの実装方法と特徴を並べつつ、ViewModelの実装について検討していきたいと思います。

目次

【前提】筆者のViewModelの認識

筆者のViewModelの認識ですが、Android公式サイトの推奨アーキテクチャのViewModelの説明が自分の中でのViewModelの認識になります。
ViewModelはViewから分離されたプレゼンテーションロジックが含まれるという認識です。

Android公式サイトのUIレイヤの図

【方法】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のみのシンプルな構成を検討すべきかなと思いました。

  1. 個人開発など、案件規模が小さい場合はViewとModelのみを検討
    →@EnvironmentObjectや@AppStorageなどのプロパティラッパーはVMのみの方が使いやすそうな印象でした
  2. 案件規模が中規模かつ、プレゼンテーションロジックが複雑になりやすい場合はViewModelを検討
    →顧客の要望によって、表示方法が変わるので、、、
  3. 複雑な状態管理が必要かつ、自社サービスなど学習リソースがある場合はTCAなどを検討
    →MVVMでは状態管理が難しい印象でした。ただし、TCAは学習コストが高く受託案件で導入する際は人材獲得の面で注意が必要だなと思いました。(MVVMでの状態管理についてこうしているよ!というのがあれば、この記事を引用し、Twitterにて教えていただきたいです🙇‍♂️)

参考

qiita.com

developer.apple.com

その他参考資料

rizumita.medium.com

zoewave.medium.com

qiita.com

SwiftUIでViewModelは@ObservedObjectではなく@StateObjectを使うべき|TAAT|note