iOS
mvc
MVVM
Swift
MVP

ライブラリを使わずにMV*の話(iOS)〜MVC, MVP, MVVM〜

話すこと

  • アプリの責務の分け方
    • Model
      • アプリ内で扱う状態・値を持つ
      • Modelの外から指示を受け処理を行う
      • 状態・値の変化をModelの外へ間接的に知らせる
    • View/Whatever
      • 画面の構築/表示
      • ユーザー操作の受付
      • アクションを定義する
      • アクションの結果/途中経過を受け取る
      • 内部表現を視覚表現へ変換する
    • MV* の種類
      • View/Whatever間での仕事の分け方は複数ある
      • (Model-View-Whatever 間の接続の仕方も複数ある)
  • 分けた後のクラス連結の仕方
    • Commandパターン(直接指示する)
    • Observerパターン(間接的に通知する)

対象読者

  • Fatなクラスを作りがちで困っている人
  • 責務の分け方にはどういうものがあるのか知りたい人
  • 分け方と分けた後のクラス間の接続がわからず困っている人

話さないこと

■ MV*の歴史的経緯

  • 理由:
    • どれが正史か判断する手段 ・モチベーションを自分が持たないため
    • 決して「歴史的経緯はどうでもいい」というわけではないです

■ 疎結合、テスタビリティ

  • 理由:
    • 変更容易性やテスタビリティを高めるということはMV*のどれでも可能と思うため
    • できないとしたらクラス間の接続の仕方が悪いか1クラスの責務が大きいのが原因ではないかと思うため

確認事項

説明に入る前に下記の確認を行います。

  1. 解決したい問題
  2. 問題の解決策
  3. 解決策の説明(= この記事)の流れ

1. 解決したい問題

画面描画、ユーザー入力の受付、サーバーとの通信などなど、アプリがやることは多いです。
かといって全ての仕事を1つのクラスに任せてしまうと、

  • 動作確認がつらい
  • 修正がつらい
  • テストを書くのがつらい(書けない)

などの問題があります。
これらが解決したい問題です。

2. 問題の解決策

問題への解決策として、仕事を分割するという方法があります。

分け方で有名どころとして MVC, MVP, MVVM などありますが、
この言葉は人によって認識が異なる部分があります。

■ 認識が異なることが多い部分

  • Modelの役割
  • UI層の仕事の分け方
  • UI層同士、またUI層とUI以外の層での値の受け渡し方

3. 解決策の説明(= この記事)の流れ

言葉の認識が異なったままでは説明に困るので、この記事では下記の流れで話を進めます。

  • Modelの役割を明記
  • UI層の仕事を細分化
  • 細分化した仕事の分け方パターンを考える
  • その上でふさわしそうな(世間一般で呼ばれている)名前を当てる
  • 実コードを載せることで各クラス間での値の受け渡し方を想像しやすくする

また、MVC(MVP, MVVM)の類をまとめて MV-Whatever と呼ぶことにします。

MV-Whatever

Modelの役割

まずは Model の役割の明記です。
Model は、UI層にまったく影響されない仕事をこなすクラスとします。
具体的には下記の役割があります。

  • アプリ内で扱う状態・値を持つ
    • 値: サーバから取得した値、ユーザーが入力した値など
    • 状態: 待機中, 通信中, 通信終了など
  • Modelの外から指示を受け処理を行う
    • 現在の状態に適した処理の振り分けを行う
    • etc.) 指示をうけたらサーバーと通信を開始する。ただし、既に通信中である場合は何も行わない
  • 状態・値の変化をModelの外へ間接的に知らせる
    • 当記事では Observer パターンを利用している

-> 具体的な実装も後ほど載せますが、より詳しい説明は別記事になります。

UI層の仕事を細分化

UI層の仕事は下記のように分類しました。

■ UI層に必要な仕事

  • 画面の構築/表示
    • 画面表示までのプロセス
    • おおよそ UIKit, xib, storyboad が行なってくれていること
  • ユーザー操作の受付
    • UI要素とアクションの接続
    • etc.) @IBAction, UIControl#addTarget(_:action:for:)
  • アクションを定義する
    • 現在の状態に適した画面の振り分け
    • etc.) ボタンを押されたら画面遷移する。ただし、要件を満たしていない場合はアラートを表示する
  • アクションの結果/途中経過を受け取る
    • Modelの状態・値の変化を受け取る
  • 内部表現を視覚表現へ変換する
    • Modelから受け取った値に合った画面表示を行う
    • etc.) Modelの状態が通信中(内部表現)の時、UILabel(視覚表現)に "通信中です" と設定する

細分化した仕事の分け方パターンを考える

View と Whatever は、それぞれ協力してUI層の仕事をこなすクラスとします。
UI層の仕事の内、"画面の構築/表示"を行うクラスを View とします。

パターン1: Whateverがユーザー入力を受け取り、ViewがModelからの出力を受け取る

UI層に必要な仕事 View Whatever
画面の構築/表示
ユーザー操作の受付
アクションを定義する
アクションの結果/途中経過を受け取る
内部表現を視覚表現へ変換する

MV-Whatever.002.jpg

Whatever はユーザー操作を受付け、 Model へ指示を出します。
指示を受けた Model は処理結果によって内部状態を変更し、変更内容を View へ通知します。
また、ユーザー操作を受付た直後の画面処理が Model の状態によって変わる場合、 Whatever がふさわしい処理を View へ指示します。
このパターンは 原初MVC と呼ばれることが多いです。

パターン2: Whateverがユーザー入力を受け取り、WhateverがModelからの出力を受け取る

UI層に必要な仕事 View Whatever
画面の構築/表示
ユーザー操作の受付
アクションを定義する
アクションの結果/途中経過を受け取る
内部表現を視覚表現へ変換する

MV-Whatever.003.jpg

Whatever はユーザー操作を受付け、 Model へ指示を出します。
指示を受けた Model は処理結果によって内部状態を変更し、変更内容を Whatever へ通知します。
通知を受け取った Whatever は、必要になる画面処理を View へ指示します。
このパターンは MVC2 と呼ばれることが多いです。

パターン3: Viewがユーザー入力を受け取り、ViewがModelからの出力を受け取る

UI層に必要な仕事 View Whatever
画面の構築/表示
ユーザー操作の受付  
アクションを定義する
アクションの結果/途中経過を受け取る
内部表現を視覚表現へ変換する

MV-Whatever.004.jpg

ユーザー操作は View から Whatever へ流れ、 Whatever が Model へ指示を出します。
指示を受けた Model は処理結果によって内部状態を変更し、変更内容を View へ通知します。
通知を受け取った View が画面処理を行います。
このパターンは 監視MVP(Supervising Controller) と呼ばれることが多いです。

パターン4: Viewがユーザー入力を受け取り、WhateverがModelからの出力を受け取る

UI層に必要な仕事 View Whatever
画面の構築/表示
ユーザー操作の受付
アクションを定義する
アクションの結果/途中経過を受け取る
内部表現を視覚表現へ変換する

MV-Whatever.005.jpg

ユーザー操作は View から Whatever へ流れ、 Whatever が Model へ指示を出します。
指示を受けた Model は処理結果によって内部状態を変更し、変更内容を Whatever へ通知します。
通知を受け取った Whatever は、必要になる画面処理を View へ指示もしくは通知します。
このパターンは MVP または MVVM と呼ばれることが多いです。

具体例

以降は、ここまでで説明したModel, View, Whatever をどのように記述するかという具体的な実装例になります。
全てのパターンの実装を載せると記事が長くなってしまうので、
パターン1を MVC として1つ、
パターン4を MVP版/MVVM版 の2つ
計3つの実装例を載せます。

また、値の受け渡しを行なってる部分を重点的に載せるとします。

全体像(パターン1~4すべて)は Github に置いています。

作るもの

mvw_sample.gif

こういうものを作ります

■ 要点

  • 星ボタンをタップすると「★/☆」が切り替わる
  • 完全に切り替わるまでタイムラグがある(灰色: 切り替え中 -> 赤: 切り替え完了)
  • 画面遷移前後で「★/☆」の状態は同じ
  • 「★」じゃないと遷移できない

■ ユーザー入力

  • 星ボタンタップ
  • 遷移ボタンタップ
  • アラート時の「"★"にして遷移する」をタップ

MVC

MV-Whatever.006.jpg

  • Controller はユーザー操作を受付け、 Model へ指示を出します。
    • Controller は Model へ依存します。
  • 指示を受けた Model は処理結果によって内部状態を変更し、変更内容を View へ通知します。
    • View は Model へ依存します。
  • ユーザー操作を受付た直後の画面処理が Model の状態によって変わる場合、 Controller がふさわしい処理を View へ指示します。
    • この記事では、Controller が View へ依存する形式をとります。

Model, View, Controller の順で定義していきます。

Model

「★/☆」の状態を持つ Model

「★/☆」の状態を Model に持たせるため、下記のように定義します。

DelayStarModelState
// 「★/☆」の状態
enum DelayStarModelState {
    // 切り替え完了
    case sleeping(current: StarMode)

    // 切り替え中
    case processing(next: StarMode)

    enum StarMode {
        // ★
        case star
        // ☆
        case unstar
    }
}

Modelが実装すべき機能は下記になります。

DelayStarModelProtocol
/// 「★/☆」の状態を持つModelのプロトコル:
protocol DelayStarModelProtocol: class {
    // - 状態・値を持つ
    var state: DelayStarModelState { get }

    // - Modelの外から指示を受け処理を行う
    func toggleStar()
    func star()

    // - 状態・値の変化をModelの外へ **間接的に** 知らせる機能持つ
    func append(receiver: DelayStarModelReceiver)
}

/// Model の変更を受け取るためのプロトコル
protocol DelayStarModelReceiver: class {
    func receive(starState: DelayStarModelState)
}

上記プロトコルの実装の仕方は本題ではないので飛ばします。
気になる場合はGithubをご覧ください。

遷移状態を持つ Model

次に、「遷移できるどうか」の状態を持つ Model を定義します。
この Model はアラートタップ時の遷移に利用します。

NavigationRequestModelState
// 遷移状態
enum NavigationRequestModelState {
    // 遷移しない
    // etc.) 遷移ボタンがタップされていない
    case haveNeverRequest

    // 遷移したいが、準備ができていない
    // etc.) 遷移ボタンがタップされてたが、「☆」である
    case notReady

    // 遷移できる
    // etc.) 遷移ボタンがタップされ、「☆」から「★」に変わった
    case ready
}

Modelが実装すべき機能は下記になります。

NavigationRequestModelProtocol
// 遷移状態を持つ Model のプロトコル
protocol NavigationRequestModelProtocol: class {
    // - 状態・値を持つ
    var state: NavigationRequestModelState { get }

    // - Modelの外から指示を受け処理を行う
    func requestToNavigate()

    // - 状態・値の変化をModelの外へ **間接的に** 知らせる機能持つ
    func append(receiver: NavigationRequestModelReceiver)
}

/// Model の変更を受け取るためのプロトコル
protocol NavigationRequestModelReceiver: class {
    func receive(requestState: NavigationRequestModelState)
}

上記プロトコルの実装の仕方は本題ではないので飛ばします。
気になる場合はGithubをご覧ください。

View

Viewの役割は下記3つです。

  • 画面の構築/表示
  • アクションの結果/途中経過を受け取る
  • 内部表現を視覚表現へ変換する

全てを1つのクラスに書かず、2つに分けることとします。

UIViewのサブクラス

「画面の構築/表示」はUIViewのサブクラス(およびxib)で行います

MVCSampleRootView
/// Viewの役割:
///  - 画面の構築/表示
class MVCSampleRootView: UIView {

    @IBOutlet var starButton: UIButton!
    @IBOutlet var navigationButton: UIButton!

    // initなど省略

}

PassiveView

「アクションの結果/途中経過を受け取る」「内部表現を視覚表現へ変換する」の2つを担う View を定義します。
UIViewのサブクラスとの命名が被らないように PassiveView と名付ける事とします。

こちらの View が実装すべき機能は下記になります。

MVCSample1PassiveViewProtocol
/// Viewの役割:
///  - アクションの結果/途中経過を受け取る
///  - 内部表現を視覚表現へ変換する
protocol MVCSample1PassiveViewProtocol: DelayStarModelReceiver, NavigationRequestModelReceiver {
    // 画面遷移を行う
    func navigate()

    // アラートを表示する
    func present(alert: UIAlertController)
}

アクションの結果/途中経過(= Modelの状態)を受け取るため、
Model の変更を受け取るためのプロトコル DelayStarModelReceiver
NavigationRequestModelReceiver を実装する必要があります。
また、ユーザー操作を受付た Controller から画面処理の指示が来るため、必要なメソッドを外部に公開します。

実際の View と Model の接続の方法は下記のようになります。

MVCSample1PassiveView
class MVCSample1PassiveView: MVCSample1PassiveViewProtocol {

    // 視覚表現への変更に必要な要素をプロパティで持つ
    private let starButton: UIButton
    private let navigationButton: UIButton

    init(
        starButton: UIButton,
        navigationButton: UIButton,
        observe models: (
            starModel: DelayStarModelProtocol,
            navigationModel: NavigationRequestModelProtocol
        )
    ) {
        self.starButton = starButton

        // Model を監視する
        models.starModel.append(receiver: self.starModelReceiver)
        models.navigationModel.append(receiver: self)
    }

    func navigate() {
        // ...doSomething
    }

    func present(alert: UIAlertController) {
        // ...doSomething
    }

}

extension MVCSample1PassiveView: DelayStarModelReceiver {
    func receive(starState: DelayStarModelState) {
        // 受け取った状態によって表示を変える
        switch starState {
            // ...doSomething
        }
    }
}

extension MVCSample1PassiveView: NavigationRequestModelReceiver {
    func receive(requestState: NavigationRequestModelState) {
        // 受け取った状態によって表示を変える
        switch requestState {
            // ...doSomething
        }
    }

}

初期化時に Model を受け取り、監視を始めます。
Model の状態を通知で受け取り、「★/☆」の表示を変更したり、画面遷移を行なったりします。

全体はGithubに載せています。

Controller

Controller が実装すべき機能は下記になります。

MVCSample1ControllerProtocol
/// Controller の役割:
///  - ユーザー操作の受付
///  - アクションを定義する
protocol MVCSample1ControllerProtocol {}

クラス外から直接指示されることは無いため、公開するものはありません。
Controllerはユーザー入力以外のタイミングでは動かないということです。

実際の Controller と View/Model の接続の方法は下記のようになります。

MVCSample1Controller
class MVCSample1Controller: MVCSample1ControllerProtocol {

    // 指示を出すため Model, View をプロパティで持つ
    private let starModel: DelayStarModelProtocol
    private let navigationModel: NavigationRequestModelProtocol
    private let view: MVCSample1PassiveViewProtocol

    init(
        reactTo handle: (
            starButton: UIButton,
            navigationButton: UIButton
        ),
        command models: (
            starModel: DelayStarModelProtocol,
            navigationModel: NavigationRequestModelProtocol
        ),
        update view: MVCSample1PassiveViewProtocol
    ) {
        self.starModel = models.starModel
        self.navigationModel = models.navigationModel
        self.view = view

        // ユーザー操作の受付
        handle.navigationButton.addTarget(
            self,
            action: #selector(MVCSample1Controller.didTapNavigationButton),
            for: .touchUpInside
        )
        handle.starButton.addTarget(
            self,
            action: #selector(MVCSample1Controller.didTapStarButton),
            for: .touchUpInside
        )
    }

    @objc private func didTapNavigationButton() {
        // 現在の Model の状態による分岐処理
        // View へ指示を出す
        switch self.starModel.state {
        case .sleeping(current: .star):
            self.view.navigate()
        case .sleeping(current: .unstar), .processing:
            self.view.present(
                alert: self.createNavigateAlert()
            )
        }
    }

    @objc private func didTapStarButton() {
        // Modelへ指示をだす
        self.starModel.toggleStar()
    }

    private func createNavigateAlert() -> UIAlertController {
        // ...doSomething
    }

}

初期化時にUI要素を受け取り、必要なアクションを付与します。
遷移ボタンタップ時は「★/☆」の状態によって画面処理が変わるため、 Model の状態を見て View へ指示を出します。
UIAlert はタップ時の挙動を決める必要があるため、Controller で作りました。

全体はGithubに載せています。

全体の繋げ方

最後に、UIViewControllerのサブクラスで、MVCの関係を作ります。

MVCSample1ViewController
class MVCSample1ViewController: UIViewController {

    private let starModel: DelayStarModelProtocol
    private var controller: MVCSample1ControllerProtocol?

    // 画面遷移前後で同インスタンスの Model を使いので、
    // 初期化時に Model を受け取り、プロパティで持つ
    init(
        starModel: DelayStarModelProtocol
    ) {
        self.starModel = starModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        return nil
    }


    override func loadView() {
        // Viewの仕事: 画面の構築/表示
        let rootView = MVCSampleRootView()
        self.view = rootView

        let navigationModel = NavigationRequestModel(observe: self.starModel)

        // Viewの仕事: アクションの結果/途中経過を受け取る
        //            内部表現を視覚表現へ変換する
        let passiveView = MVCSample1PassiveView(
            starButton: rootView.starButton,
            navigationButton: rootView.navigationButton,
            observe: (
                starModel: self.starModel,
                navigationModel: navigationModel
            )
        )

        // Controllerの仕事: ユーザー操作の受付
        //                  アクションを定義する
        let controller = MVCSample1Controller(
            reactTo:(
                starButton: rootView.starButton,
                navigationButton: rootView.navigationButton
            ),
            command: (
                starModel: self.starModel,
                navigationModel: navigationModel
            ),
            update: passiveView
        )

        // 参照の保持が必要な場合はVCで持つ
        self.controller = controller
    }

}

以上で、MVCの実装例を終わります。

MVP

MV-Whatever.007.jpg

  • View はユーザー操作を受付け、どんな操作を受付たかを Presenter へ通知します。
    • この記事では、Presenter は View へ依存する形式をとります。
  • Presenter は Model へ指示を出し、Model は状態の変更を Presenter へ通知します。
    • Presenter は Model へ依存します。
  • 画面処理が必要であれば、Presenter が View へ指示を出します。

Modelの定義は MVCの時と同じ です。

View, Presenter の順で定義していきます。

View

Viewの役割は下記3つです。

  • 画面の構築/表示
  • ユーザー操作の受付
  • 内部表現を視覚表現へ変換する

全てを1つのクラスに書かず、2つに分けることとします。

UIViewのサブクラス

「画面の構築/表示」と「ユーザー操作の受付」はUIViewのサブクラス(およびxib)で行います

MVPSampleRootView
/// Viewの役割:
///  - 画面の構築/表示
///  - ユーザー操作の受付
class MVPSampleRootView: UIView {

    @IBOutlet var starButton: UIButton!
    @IBOutlet var navigationButton: UIButton!
    weak var delegate: MVPSampleRootViewDelegate?

    @IBAction func didTapStarButton(_ sender: UIButton) {
        self.delegate?.didTapStarButton()
    }

    @IBAction func didTapNavigationButton(_ sender: UIButton) {
        self.delegate?.didTapnavigationButton()
    }

    // initなど省略

}

@objc
protocol MVPSampleRootViewDelegate: class {
    func didTapStarButton()
    func didTapnavigationButton()
}

InteractiveView

「UIView以外で行うユーザー操作の受付」と「内部表現を視覚表現へ変換する」を担う View を定義します。
UIViewのサブクラスとの命名が被らないように InteractiveView と名付ける事とします。

こちらの View が実装すべき機能は下記になります。

MVPSample2InteractiveViewProtocol
/// Viewの役割:
///  - ユーザー操作の受付
///  - 内部表現を視覚表現へ変換する
protocol MVPSample2InteractiveViewProtocol {
    // ユーザー操作を通知する
    var delegate: MVPSample2InteractiveViewDelegate? { get set }

    // 画面内容を更新する
    func update(star: String, starColor: UIColor, isStarButtonEnable: Bool, isNavigationButtonEnable: Bool)

    // 画面遷移を行う
    func navigate(with: DelayStarModelProtocol)

    // アラートを表示する
    func alertForNavigation()
}

protocol MVPSample2InteractiveViewDelegate: class {
    // アラートをタップされた場合の動作
    func didRequestForceNavigate()
}

ユーザー操作を Presenter へ通知するため MVPSample2InteractiveViewDelegate を持ちます。
また、 Presenter から画面処理の指示が来るため、必要なメソッドを外部に公開します。

上記プロトコルの実装の仕方は本題ではないので飛ばします。
気になる場合はGithubをご覧ください。

Presenter

Presenter が実装すべき機能は下記になります。

MVPSample2PresenterProtocol
/// Presenterの役割:
///  - アクションを定義する
///  - アクションの結果/途中経過を受け取る
protocol MVPSample2PresenterProtocol
    : MVPSampleRootViewDelegate, MVPSample2InteractiveViewDelegate,
      DelayStarModelReceiver, NavigationRequestModelReceiver {}

View からユーザー操作の通知を受け取るためのプロトコル MVPSampleRootViewDelegateMVPSample2InteractiveViewDelegate
Model から状態変更の通知を受け取るためのプロトコル DelayStarModelReceiver
NavigationRequestModelReceiver
をそれぞれ実装する必要があります。

実際の Presenter と View/Model の接続の方法は下記のようになります。

MVPSample2Presenter
class MVPSample2Presenter: MVPSample2PresenterProtocol {

    // 指示を出すため Model, View をプロパティで持つ
    private let starModel: DelayStarModelProtocol
    private let navigationModel: NavigationRequestModelProtocol
    private let view: MVPSample2InteractiveViewProtocol

    init(
        interchange models: (
            starModel: DelayStarModelProtocol,
            navigationModel: NavigationRequestModelProtocol
        ),
        willUpdate view: MVPSample2InteractiveView
    ) {
        self.starModel = models.starModel
        self.navigationModel = models.navigationModel
        self.view = view

        // Model を監視する
        self.starModel.append(receiver: self.starModelReceiver)
        self.navigationModel.append(receiver: self)
    }

}

extension MVPSample2Presenter: MVPSampleRootViewDelegate, MVPSample2InteractiveViewDelegate {

    @objc func didTapnavigationButton() {
        // 現在の Model の状態による分岐処理
        // View へ指示を出す
        switch self.starModel.state {
        case .sleeping(current: .star):
            self.view.navigate(with: self.starModel)
        case .sleeping(current: .unstar), .processing:
            self.view.alertForNavigation()
        }
    }

    @objc func didTapStarButton() {
        // Modelへ指示をだす
        self.starModel.toggleStar()
    }

    func didRequestForceNavigate() {
        self.navigationModel.requestToNavigate()
        self.starModel.star()
    }

}

extension MVPSample2Presenter: DelayStarModelReceiver {
    func receive(starState: DelayStarModelState) {
        // 受け取った状態によって表示を変える
        switch starState {
            // View へ指示を出す
        }
    }
}

extension MVPSample2Presenter: NavigationRequestModelReceiver {
    func receive(requestState: NavigationRequestModelState) {
        // 受け取った状態によって表示を変える
        switch requestState {
            // View へ指示を出す
        }
    }
}

初期化時に Model を受け取り、監視を始めます。
Model の状態を通知で受け取り、View へ画面更新の指示を出します。
遷移ボタンタップ時は「★/☆」の状態によって画面処理が変わるため、 Model の状態を見て View へ指示を出します。

全体はGithubに載せています。

全体の繋げ方

最後に、UIViewControllerのサブクラスで、MVPの関係を作ります。

MVPSample2ViewController
class MVPSample2ViewController: UIViewController {

    private let starModel: DelayStarModelProtocol
    private var presenter: MVPSample2PresenterProtocol?

    // 画面遷移前後で同インスタンスの Model を使いので、
    // 初期化時に Model を受け取り、プロパティで持つ
    init(
        starModel: DelayStarModelProtocol,
    ) {
        self.starModel = starModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        return nil
    }

    override func loadView() {
        // Viewの仕事: 画面の構築/表示
        //            ユーザー操作の受付
        let rootView = MVPSampleRootView()
        self.view = rootView

        // Viewの仕事: UIView以外で行うユーザー操作の受付
        //            内部表現を視覚表現へ変換する
        let interactiveView = MVPSample2InteractiveView(
            handle:(
                starButton: rootView.starButton,
                navigationButton: rootView.navigationButton
            )
        )
        let navigationModel = NavigationRequestModel(
            observe: self.starModel
        )

        // Presenterの仕事: アクションを定義する
        //                 アクションの結果/途中経過を受け取る
        let presenter = MVPSample2Presenter(
            interchange: (
                starModel: self.starModel,
                navigationModel: navigationModel
            ),
            willUpdate: interactiveView
        )

        // View と Presenter を繋げる
        rootView.delegate = presenter
        interactiveView.delegate = presenter

        // 参照の保持が必要な場合はVCで持つ
        self.presenter = presenter
    }

}

以上で、MVPの実装例を終わります。

MVVM

MV-Whatever.008.jpg

  • View はユーザー操作を受付け、行うべき処理を ViewModel へ指示します。
    • この記事では、View は ViewModel へ依存する形式をとります。
  • ViewModel は Model へ指示を出し、Model は状態の変更を ViewModel へ通知します。
    • ViewModel は Model へ依存します。
  • 画面処理が必要であれば、ViewModel が View へ通知します。

MVPとの相違として、ViewModel は View の状態を知る機能を持ちます。
View の状態というのは、UIButton().titleLabel?.textColor が今何色か、といったものです。
(ですので、xib と UIView のサブクラスもまた、 View と ViewModel の関係と見る事ができます。)

Modelの定義は MVCの時と同じ です。

ViewModel, View の順で定義していきます。

ViewModel

ViewModelの役割は下記の2つです。

  • アクションを定義する
  • アクションの結果/途中経過を受け取る

アクションを定義する必要があるのは、星ボタンと遷移ボタンの2つです。
別々に ViewModel を分けて定義する方法をとります。

星ボタンのViewModel

MVVMSampleStarViewModelInput
/// ViewModelの役割:
///  - アクションを定義する
///  - アクションの結果/途中経過を受け取る
protocol MVVMSampleStarViewModelInput: DelayStarModelReceiver {
    // 画面更新に必要な情報を通知する
    var output: MVVMSampleStarViewModelOutput? { get set }

    // 星ボタンがタップされた場合
    func didTapStarButton()
}

// ViewModel から通知を受け取るためのプロトコル
protocol MVVMSampleStarViewModelOutput: class {
    var title: String? { get set }
    var color: UIColor? { get set }
    var isEnable: Bool { get set }
}

アクションの結果/途中経過(= Modelの状態)を受け取るため、
Model の変更を受け取るためのプロトコル DelayStarModelReceiver を実装する必要があります。
また、ユーザー操作を受付た View から指示が来るため、必要なメソッドを外部に公開します。
画面更新に必要な情報は MVVMSampleStarViewModelOutput を通して View へ通知します。

上記プロトコルの実装の仕方は本題ではないので飛ばします。
気になる場合はGithubをご覧ください。

遷移ボタンのViewModel

MVVMSampleNavigationViewModelInput
// ViewModelの役割:
//  - アクションを定義する
//  - アクションの結果/途中経過を受け取る
protocol MVVMSampleNavigationViewModelInput: NavigationRequestModelReceiver {
    // ナビゲーションボタンをタップされた場合
    func didTapnavigationButton()
}

アクションの結果/途中経過(= Modelの状態)を受け取るため、
Model の変更を受け取るためのプロトコル NavigationRequestModelReceiver を実装する必要があります。
また、ユーザー操作を受付た View から指示が来るため、必要なメソッドを外部に公開します。

今回は画面遷移・アラート表示機能を ViewModel に持たせたので、View へ通知することは無くなりました。
なので、この ViewModel は通知機能を持ちません。
画面遷移・アラート表示機能を View に持たせる場合は定義を追加して View へ通知を送る必要があります。

上記プロトコルの実装の仕方は本題ではないので飛ばします。
気になる場合はGithubをご覧ください。

View

View が実装すべき機能は下記になります。

MVVMSampleRootViewInput
// Viewの役割:
//  - 画面の構築/表示
//  - ユーザー操作の受付
//  - 内部表現を視覚表現へ変換する
protocol MVVMSampleRootViewInput: MVVMSampleStarViewModelOutput {}

ViewModel から変更を受け取るプロトコル MVVMSampleStarViewModelOutput を実装する必要があります。

実際の View と ViewModel の接続の方法は下記のようになります。

MVVMSampleRootView
class MVVMSampleRootView: UIView, MVVMSampleRootViewInput {

    @IBOutlet var starButton: UIButton!
    @IBOutlet var navigationButton: UIButton!

    // ViewModel へ指示を出すためプロパティで持つ
    private var starViewModel: MVVMSampleStarViewModelInput!
    private var navigationViewModel: MVVMSampleNavigationViewModelInput!

    convenience init(
        observe viewModels: (
            starViewModel: MVVMSampleStarViewModelInput,
            navigationViewModel: MVVMSampleNavigationViewModelInput
        )
    ) {
        self.init()

        self.starViewModel = viewModels.starViewModel
        self.navigationViewModel = viewModels.navigationViewModel

        // ViewModel の通知が View へ来るようにする
        self.starViewModel.output = self
    }

    @IBAction func didTapStarButton(_ sender: UIButton) {
        self.starViewModel.didTapStarButton()
    }

    @IBAction func didTapNavigationButton(_ sender: UIButton) {
        self.navigationViewModel.didTapnavigationButton()
    }

}

// ViewModel の変更を受け取り、画面を更新する
extension MVVMSampleRootView: MVVMSampleStarViewModelOutput {

    var title: String? {
        get {
            return self.starButton.titleLabel?.text
        }
        set {
            self.starButton.setTitle(newValue, for: .normal)
        }
    }

    var color: UIColor? {
        get {
            return self.starButton.titleLabel?.textColor
        }
        set {
            self.starButton.setTitleColor(newValue, for: .normal)
        }
    }

    var isEnable: Bool {
        get {
            return self.starButton.isEnabled
        }
        set {
            self.starButton.isEnabled = newValue
        }
    }

}

初期化時に ViewModel を受け取り、通知を受け取れるようにします。
ユーザー操作を受け取った場合は ViewModel へ処理を指示します。
指示の結果は通知で受け取り、任意のUI要素を更新します。
MVVMSampleStarViewModelOutput によって、ViewModel が View の状態を見ることも可能です。

全体はGithubにあります。

全体の繋げ方

最後に、UIViewControllerのサブクラスで、MVVMの関係を作ります。

MVVMSampleViewController
class MVVMSampleViewController: UIViewController {

    private let starModel: DelayStarModelProtocol

    // 画面遷移前後で同インスタンスの Model を使いので、
    // 初期化時に Model を受け取り、プロパティで持つ
    init(
        starModel: DelayStarModelProtocol
    ) {
        self.starModel = starModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        return nil
    }

    override func loadView() {
        // ViewModelの役割: アクションを定義する
        //                 アクションの結果/途中経過を受け取る
        let starViewModel = MVVMSampleStarViewModel(
            observe: self.starModel
        )

        let navigationModel = NavigationRequestModel(observe: starModel)

        // ViewModelの役割: アクションを定義する
        //                 アクションの結果/途中経過を受け取る
        let navigationViewModel = MVVMSampleNavigationViewModel(
            dependency: (
                starModel: self.starModel,
                navigator: self.navigator,
                modalPresenter: ModalPresenter(using: self)
            ),
            observe: navigationModel
        )

        // Viewの役割: 画面の構築/表示
        //            ユーザー操作の受付
        //            内部表現を視覚表現へ変換する
        let rootView = MVVMSampleRootView(
            observe: (
                starViewModel: starViewModel,
                navigationViewModel: navigationViewModel
            )
        )
        self.view = rootView

        // ViewModel と View を繋げる
        starViewModel.output = rootView
    }
}

以上で、MVVMの実装例を終わります。

まとめ

  • アプリの責務の分け方
    • Model
      • アプリ内で扱う状態・値を持つ
      • Modelの外から指示を受け処理を行う
      • 状態・値の変化をModelの外へ間接的に知らせる
    • View/Whatever
      • 画面の構築/表示
      • ユーザー操作の受付
      • アクションを定義する
      • アクションの結果/途中経過を受け取る
      • 内部表現を視覚表現へ変換する
    • MV* の種類
      • View/Whatever間での仕事の分け方は複数ある
      • (Model-View-Whatever 間の接続の仕方も複数ある)
  • 分けた後のクラス連結の仕方
    • Commandパターン(直接指示する)
    • Observerパターン(間接的に通知する)

今回はUI層のクラスに重点を置いて説明しました。
次回はModelの話をもう少し詳しくします。
次 -> ライブラリを使わずにMV*の話(iOS)~Modelに状態を持たせて状態遷移を行う〜

参考

インタラクティブソフトウェアの共通アーキテクチャの提案
スマートデバイス向けアプリケーションのための共通アーキテクチャの提案