前回は、なぜ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はまったく不要だ」という主張も勢いを増しています。この議論は次回、正面から扱います。

![[iOSアーキテクチャ #2] MVVMとバインディングの要点まとめのカバー画像](/assets/images/posts/f493544a-c1fe-4846-88cc-edd9a3879c4c/1.jpg)