iOSエンジニアリング

[iOSアーキテクチャ #2] MVVMとバインディングの要点まとめ

前回は、なぜMassive View Controllerが生まれるのかを扱いました。ViewとControllerを兼ねるため、行き場のないコードがすべてビューコントローラに集まってしまう、という話です。

読了 5 分
[iOSアーキテクチャ #2] MVVMとバインディングの要点まとめのカバー画像

前回は、なぜMassive View Controllerが生まれるのかを扱いました。ViewとControllerを兼ねるため、行き場のないコードがすべてビューコントローラに集まってしまう、という話です。

その解決策として最も広く使われているのが**MVVM(Model-View-ViewModel)**です。しかしMVVMを導入したコードを開くと、意外に多いのがこのパターンです。ViewModelという名前だけのクラスがあり、ビューコントローラが今でも値を一つずつ取り出して画面に反映しています。

今回は、ViewModelの本当の役割と、なぜバインディングのないMVVMが不完全なのかを整理します。


ViewModelは画面の状態を作る工場

MVVMの3つの役割は次のように分かれます。

  • Model:データとビジネスロジック(MVCと同じ)
  • View:画面表示。iOSではUIViewとUIViewControllerの両方がここに属します
  • ViewModel:Modelのデータを画面に表示する形へ加工し、画面の状態を保持するオブジェクト

ポイントは2つあります。

1つ目は、MVVMではUIViewControllerがView側であることです。前回問題にしたビューコントローラの曖昧な位置付けを、Viewとして明確に定義します。ビューコントローラは画面を描画するだけで、判断はすべてViewModelに委ねます。

2つ目は、ViewModelがUIKitを知っていてはいけないことです。つまりimport UIKitUIKitへの依存があってはいけないDateという意味です。Dateを受け取り「3分前」のような文字列に変換し、読み込み中かどうかをBoolで保持するところまでがViewModelの仕事です。その文字列をどのUILabelに入れるかはViewの仕事です。

この分離により、画面なしでViewModelをテストできます。「投稿が0件なら案内文が表示される」をUIViewControllerなしの純粋なロジックとして検証できるのです。


バインディングがないと、なぜ不完全なのでしょうか?

ここで止めると、コードは次のようになります。

// バインディングなしの「名ばかりMVVM」 MVVM"
final class ProfileViewController: UIViewController {
    let viewModel = ProfileViewModel()

    func refresh() {
        viewModel.load()
        nameLabel.text = viewModel.displayName   // 値を直接取り出す
        statusLabel.text = viewModel.statusText  // 1つずつ反映する
        emptyView.isHidden = !viewModel.isEmpty
    }
}

ViewModelの状態が変わるたびに、ビューコントローラはrefresh()を忘れず呼び出さなければなりません。呼び出すタイミングを1つでも逃すと、画面と状態がずれます。状態を移しただけで、「状態と画面を同期する責任」は依然としてビューコントローラに残っています。

MicrosoftのWPFでMVVMが生まれた当初から、データバインディングを前提に設計されていた理由はここにあります。「ViewModelが変わるとViewも追従する」ことが自動的に保証されて、初めて完成なのです。

一度購読を設定すれば、画面が状態に自動で追従します
一度購読を設定すれば、画面が状態に自動で追従します

UIKitではCombineでバインディングする

UIKitには組み込みのバインディングがないため、Combineを組み合わせるのが標準に近い方法です。

final class ProfileViewModel {
    @Published private(set) var displayName = ""
    @Published private(set) var isLoading = false

    func load() { ... }  // 完了時に @Published 値を更新
}

final class ProfileViewController: UIViewController {
    private var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        viewModel.$displayName
            .assign(to: \.text!, on: nameLabel)
            .store(in: &cancellables)
        viewModel.$isLoading
            .sink { [weak self] in self?.spinner.isAnimating = $0 }
            .store(in: &cancellables)
    }
}

一度購読を設定すれば、その後ViewModelがいつ、どのように変わっても画面が追従します。「refreshはいつ呼べばいい?」という悩み自体がなくなるのです。

SwiftUIでは、このバインディングが言語レベルに組み込まれています。@Observableを付けたオブジェクトのプロパティをViewが読むだけで、値が変わると該当するViewが自動的に再描画されます。購読コードすら必要ありません。


Massive ViewModelという落とし穴

MVVMを導入して数か月たつと、新たな問題に直面します。今度はViewModelが肥大化するのです。ネットワーク要求、キャッシュ、ビジネスルールまでViewModelに入れると、怪物の居場所を移しただけになります。

ViewModelはあくまでプレゼンテーションロジック(画面状態の加工)を担う場所です。データ取得はRepositoryやServiceへ、ビジネスルールはModel層へ移すべきです。MVVMはViewとそれ以外を分けるパターンであり、それ以外をすべてViewModelに詰め込むパターンではありません。

すべて詰め込むと、怪物の居場所を移しただけです
すべて詰め込むと、怪物の居場所を移しただけです

まとめ

  • ViewModelはModelのデータを画面用の状態に加工するオブジェクトで、UIKitを知っていてはいけません。だからこそ画面なしでテストできます。
  • 状態だけを移してバインディングがなければ、同期の責任はビューコントローラに残ります。MVVMはバインディングがあって初めて完成します。
  • UIKitではCombine、SwiftUIでは@Observableがバインディングを担います。
  • すべてのロジックをViewModelに入れると、Massive ViewModelになります。担当させるのはプレゼンテーションロジックまでにしましょう。

ただしSwiftUIでは、View自体が状態を宣言的に反映する構造なので、「ViewModelはまったく不要だ」という主張も勢いを増しています。この議論は次回、正面から扱います。

あわせて読みたい