この記事は、RxSwift が提供する公式のサンプルである RxExample で行き詰まった方向けに、実践的な対処方法を紹介します。具体的には RxExample にある MVVM (Model-View-ViewModel) を真似たアーキテクチャで陥りがちな Fat ViewModel へ対処する方法の解説になっています。

なお、この記事の想定読者は RxExample の初級者〜上級者ですが、それぞれの方向けにこの記事の読み方を説明します。

初級者の方へ

登場するコードは、コメントだけ拾い読みしても内容がわかるように配慮しています。コードを深く追うと疲れてしまうと思うので、コード内のコメントの内容を追ってください。

上級者の方へ

この記事では、RxExample 上級者が疑問を感じるであろうコードに「上級者向け注釈」をつけています。記事末に注釈の解説がありますので、そちらをご覧ください。

では、RxExample の MVVM について詳しく見てみましょう。

RxExample MVVM とは

RxExample とは、Reactive Extensions の Swift 向け実装である RxSwift が提供するアプリの実装例です。この RxExample では、 RxSwift の利用例だけでなく RxSwift の iOS/macOS 向けバインディングである RxCocoa の利用例も紹介されています。

この中でも、特に有名なのが RxCocoa を使った MVVM アーキテクチャの実装です。以降では、この MVVM を RxExample MVVM と呼びます。

では、これからこの RxExample MVVM の簡単な実装例を紹介するのですが、まず RxExample で頻出する Driver と Signal というクラスについて解説します。

Driver と Signal とは

Driver と Signal はいずれも RxCocoa によって提供されている、UI 操作向けに特化した Observable です。この Driver と Signal は以下の性質を備えています:

  • イベントを受け取ったときの処理はメインスレッド上で実行される
  • Observable とは異なり、エラー状態にならない
  • UI のバインディングに適した Cold-Hot 変換 がなされる

なお、Driver と Signal の使い分けは以下の通りです:

Driver
購読直後にイベントを1つ流してほしいものに使う。
適した例
  • UITextField の入力文字列(初期状態を購読直後に把握したいため)
  • UIButton の有効/無効状態(同じく初期状態を購読直後に把握したいため)
Signal
購読後に実際にイベントが発生するまでイベントを流さないでほしいものに使う。
適した例
  • UIButton のタップイベント
  • アニメーションの完了イベント

次に RxExample MVVM で Driver と Signal をどのように使うのかを見てみましょう。

RxExample MVVM における View-ViewModel 間の接続方法

RxExample では、Driver と Signal を View-ViewModel 間のイベント通知(双方向バインディング)のために使います:

  • View → ViewModel の向きのイベント通知
    • ViewModel の init 関数の引数経由で Driver が Signal の形式で渡される
  • ViewModel → View の向きのイベント通知
    • ViewModel のプロパティ経由で Driver か Signal の形式で渡される

text8845.png

それでは、実際のコードを見てみましょう。

サンプルコード

ここでは、入力された文字列を大文字にして表示するアプリを例として扱います:

path87692.png

このアプリのコードで注目してほしいのは、先ほど紹介した View-ViewModel 間の接続方法です。実際のコードを見てみましょう:

SimpleViewController.swift
class SimpleViewController: UIViewController {
    // IBOutlet などが接続された View。
    //
    // 上級者向け注釈1: View の持ち方について(記事末を参照)。
    // 上級者向け注釈2: weak じゃない理由について(記事末を参照)。
    private let rootView: SimpleRootView
    private var viewModel: SimpleViewModel?

    // 上級者向け注釈3: フレームワーク名をつけることについて(記事末を参照)。
    private let disposeBag = RxSwift.DisposeBag()

    // ...

    override func viewDidLoad() {
        super.viewDidLoad()

        // ViewModel への UI イベントを流す Observable は、ViewModel の初期化時に
        // IBOutlet などから作成して渡す。
        let viewModel = SimpleViewModel(

            // inputOutlet は IBOutlet で接続された UITextField のインスタンス。
            // RxCocoa を使うと、UITextField の rx.text から、入力された文字列を
            // Observable に類似した形式(ControlProperty型)で取得できる。
            // ここから、ViewModel が要求する Driver 型へ変換する(Driver の説明は後述)。
            input: self.rootView.inputOutlet.rx.text.orEmpty.asDriver()
        )

        // ViewModel からの指示は ViewModel のプロパティにある Driver 経由で
        // 購読して反映させる。
        // ここでは ViewModel が指示した文字列を、outputOutlet の指す
        // UILabel の表示文字列へと反映させている。
        viewModel.outputText
            .drive(self.rootView.outputOutlet.rx.text)
            .disposed(by: self.disposeBag)

        // ViewModel が ARC で回収されないように保持しておく。
        //
        // 上級者向け注釈4: ViewModel を保持していることについて(記事末を参照)。
        self.viewModel = viewModel
    }

    // ...
}
SimpleViewModel.swift
class SimpleViewModel {
    // View への指示を ViewModel のプロパティとして公開する。
    // ViewModel のプロパティの型は Driver か Signal のどちらかにする
    // (Driver と Signal についての説明は後述)。
    let outputText: RxCocoa.Driver<String>


    // View からの UI イベントは init 関数の input 引数から受け取る。
    init(input inputText: RxCocoa.Driver<String>) {
        self.outputText = inputText
            .map { text in text.uppercased() }
            .asDriver()
    }
}

このように、とてもイージーなコードで View-ViewModel 間の接続(双方向バインディング)を実現しています。

さて、このような RxExample ViewModel ですが、これを工夫せずに使おうとすると困った点が出てきます。RxExample では簡単な仕様の例しかないため、複雑な仕様を RxExample MVVM で実装するやり方がわからないのです。実際の業務では複雑な仕様を扱うことが多いわけですから、複雑な仕様への対処方法はきちんと考えなければなりません。

では、複雑な仕様に対する RxExample の限界の実際の具体例を見てみましょう。

RxExample の限界

今回複雑な仕様として扱うのは、次のようなバッジの選択画面です:

path8769.png

この画面では、下部のバッジをタップすることでバッジを選択状態にできます。選択状態のバッジは、下部の領域から上部の領域に移動します。バッジの選択を解除するには、上部に移動したバッジを再度タップします。また、UINavigationBar 上の Done ボタンをタップすると、別の画面へ遷移するようになっています。ただし、この Done ボタンは一つでもバッジが選択されていないと無効状態になるようになっています。

これを実現する、ViewModel の入出力は次のようになります:

  • View から ViewModel への入力:

    • Done ボタンのタップイベントの Signal
    • 上部バッジのタップイベントの Signal
    • 下部バッジのタップイベントの Signal
  • ViewModel から View への出力:

    • Done ボタンの有効/無効フラグ の Driver
    • 上部に表示するバッジの一覧の Driver
    • 下部に表示するバッジの一覧の Driver
    • 選択解除されたバッジを通知する Signal
    • 選択されたバッジを通知する Signal

では、この仕様を特に工夫せずに RxExample に倣って実装した例を示します(なお、コードがそこそこ長いので、読み飛ばしても構いません。リファクタリングしたくなるほどには長いということを感じてください):

BadgeSelectorViewModel.swift
class BadgesSelectorViewModel {
    typealias Dependency = (
        // 依存: すべてのバッジの一覧を取得するためのリポジトリ。
        repository: AnyEntityRepository<Void, [Badge], Never>,
        // 依存: 別の画面へ遷移するためのクラス。このクラスのメソッドを
        // 叩くと新しい画面へ遷移できる。この種のクラスは RxExample で
        // Wireframe と命名されている。
        wireframe: BadgeSelectorWireframe
    )

    typealias Input = (
        // View からの入力: 完了ボタンのタップイベント、
        doneTap: RxCocoa.Signal<Void>,

        // View からの入力: 選択されているバッジへのタップイベント。
        selectedTap: RxCocoa.Signal<Badge>,

        // View からの入力: まだ選択されていないバッジへのタップイベント。
        selectableTap: RxCocoa.Signal<Badge>
    )

    // View への出力: Done ボタンの有効/無効フラグ の Driver。
    let canComplete: RxCocoa.Driver<Bool>

    // View への出力: 上部に表示するバッジの一覧の Driver。
    let selectedBadges: RxCocoa.Driver<[Badge]>

    // View への出力: 下部に表示するバッジの一覧の Driver。
    let selectableBadges: RxCocoa.Driver<[Badge]>

    // View への出力: 選択解除されたバッジを通知する Signal。
    let badgeDidDeselect: RxCocoa.Signal<Badge>

    // View への出力: 選択されたバッジを通知する Signal。
    let badgeDidSelect: RxCocoa.Signal<Badge>

    var currentSelectedBadges: [Badge] {
        return self.selectedBadgesRelay.value
    }

    private let badgeDidSelectRelay: RxCocoa.PublishRelay<Badge>
    private let badgeDidDeselectRelay: RxCocoa.PublishRelay<Badge>
    private let selectedBadgesRelay: RxCocoa.BehaviorRelay<[Badge]>
    private let dependency: Dependency
    private let disposeBag = RxSwift.DisposeBag()


    init(input: Input, dependency: Dependency) {
        self.dependency = dependency

        // バッジが選択されたことを UIView へ通知するための PublishRelay。
        // PublishRelay は任意のイベントが流せる Signal のようなもの。
        // 最終的に Signal へ変換して View へ公開する。
        //
        // 上級者向け注釈5: インスタンス変数代入と変数定義を分けていることについて(記事末を参照)。
        let badgeDidSelectRelay = RxCocoa.PublishRelay<Badge>()
        self.badgeDidSelectRelay = badgeDidSelectRelay
        self.badgeDidSelect = badgeDidSelectRelay.asSignal()

        // バッジの選択が解除されたことを UIView へ通知するための PublishRelay。
        let badgeDidDeselectRelay = RxCocoa.PublishRelay<Badge>()
        self.badgeDidDeselectRelay = badgeDidDeselectRelay
        self.badgeDidDeselect = badgeDidDeselectRelay.asSignal()

        // 選択済みのバッジの出力。選択されたバッジは保持しておかないといけないので、
        // BehaviorRelay を使って保持している。BehaviorRelay は任意のイベントが
        // 流せる Driver のようなもの。最終的に Driver へ変換して View へ公開する。
        let selectedBadgesRelay = RxCocoa.BehaviorRelay<[Badge]>(value: [])
        self.selectedBadgesRelay = selectedBadgesRelay
        let selectedBadges = selectedBadgesRelay.asDriver()
        self.selectedBadges = selectedBadges

        // すべてのバッジを保持しておくための BehaviorRelay。
        let allBadgesRelay = RxCocoa.BehaviorRelay<[Badge]>(value: [])
        let allBadges = allBadgesRelay.asDriver()

        // すべてのバッジをサーバーから取得(今回は簡単にするためにダミーのものがくる)。
        dependency.repository
            .get(count: 100)
            .subscribe(
                onSuccess: { result in
                    switch result {
                    case let .success(badges):
                        // 取得が成功したらすべてのバッジを更新する。
                        allBadgesRelay.accept(badges)
                    case .failure:
                        break
                    }
                },
                onError: nil
            )
            .disposed(by: self.disposeBag)

        // 選択可能なバッジの出力。
        self.selectableBadges = RxCocoa.Driver
            .combineLatest(
                allBadges,
                selectedBadges,
                resultSelector: { ($0, $1) }
            )
            .map { tuple -> [Badge] in
                let (allBadges, selectedBadges) = tuple
                // すべてのバッジから、既に選択されたものを除く。
                return BadgesSelectorViewModel.dropSelected(
                    from: allBadges,
                    without: Set(selectedBadges)
                )
            }

        // バッジが一つも選択されていなければ UINavigationBar 上の
        // Done ボタンを無効にする。
        self.canComplete = selectedBadges
            .map { selection in !selection.isEmpty }
            .asDriver()

        // Done ボタンタップ時に画面を遷移させる。
        input.doneTap
            .emit(onNext: { [weak self] _ in
                guard let `self` = self else { return }

                self.dependency.wireframe.goToResultScreen(
                    with: selectedBadgesRelay.value
                )
            })
            .disposed(by: self.disposeBag)

        // 上部バッジタップで選択を解除する。
        input.selectedTap
            .emit(onNext: { [weak self] badge in
                guard let `self` = self else { return }

                self.deselect(badge: badge)
            })
            .disposed(by: self.disposeBag)

        // 下部バッジタップで選択する。
        input.selectableTap
            .emit(onNext: { [weak self] badge in
                guard let `self` = self else { return }

                self.select(badge: badge)
            })
            .disposed(by: self.disposeBag)
    }


    // 与えられたバッジを選択状態にする。
    private func select(badge: Badge) {
        let currentSelection = self.selectedBadgesRelay.value
        guard !currentSelection.contains(badge) else { return }

        var newSelection = currentSelection
        newSelection.append(badge)

        self.selectedBadgesRelay.accept(newSelection)
        self.badgeDidSelectRelay.accept(badge)
    }


    // 与えられたバッジを非選択状態にする。
    private func deselect(badge: Badge) {
        let currentSelection = self.selectedBadgesRelay.value
        var newSelection = currentSelection

        guard let index = newSelection.index(of: badge) else { return }

        newSelection.remove(at: index)

        self.selectedBadgesRelay.accept(newSelection)
        self.badgeDidDeselectRelay.accept(badge)
    }


    private static func dropSelected(from allBadges: [Badge], without selectedBadges: Set<Badge>) -> [Badge] {
        return allBadges
            .filter { !selectedBadges.contains($0) }
    }
}

この例で着目して欲しいのは、ViewModel の抱える責務の数です。この ViewModel は以下のように多数の責務を抱えています:

  1. すべてのバッジの取得
  2. 選択されたバッジの状態の保持
  3. まだ選択されていないバッジの抽出
  4. まだ選択されていないバッジ表示 UI への双方向バインディング
  5. 選択されたバッジ表示 UI への双方向バインディング
  6. Done ボタンの双方向バインディング

このような多数の責務を抱えた ViewModel を「Fat ViewModel」と呼びます。つまり、複雑な仕様に対して RxExample で実装されている知識だけを使うと、Fat ViewModel になってしまうことがわかると思います。

一般的に Fat ViewModel は可読性と保守性の問題を孕んでいます。そのため、この問題に対処するためのベストプラクティスとして「1つのクラスは1つの責務だけを抱えるべき」という単一責務原則が知られています。

では、このような複雑な仕様の下での RxExample MVVM は、どのようにしてこの単一責務原則を満たせるでしょうか。解決方法は2つあります。1つは仕様を削ること、もう1つはクラスを分割することです。仕様を削れないことはよくありますから、実際にはクラスを分解することで単一責務原則を満たすようにします。

では、この Fat ViewModel の分解方法をみていきましょう。

Fat ViewModel の分解

Fat ViewModel を分解して単一責務にさせる方法は、次のように 2 つあります:

  • Fat ViewModel を複数の ViewModel へと分割すること
  • Fat ViewModel の責務を Model 層へと移動すること

まず1つめの方法は ViewModel の分割です。例えば、UI の構成要素が別れていれば ViewModel も分割できます。先ほどの例では、UI は次の 3つの構成要素に分かれています:

path87693.png

したがって、この画面の ViewModel は、それぞれの構成要素と対応する 3 つの ViewModel と、それらの取りまとめ役となる ViewModel の計 4 つの ViewModel へと分割できます。

そして、もう1つの手段は ViewModel の責務を Model 層へと移動することです。RxExample MVVM では Model 層がとても希薄なため、その存在を忘れがちですが、本来の Model 層はとてもパワフルな存在です。経験的には、ViewModel の内部状態の管理の責務はおおよそ Model 層へと移動できます。

では、まず前者の ViewModel の分割の方から取り掛かりましょう。

ViewModel の分割

ViewModel の分割では、UI の構成要素を複数の小さな ViewModel で分担して管理するようにします。先ほどの例では以下の3つの構成要素がありましたから、それぞれ 3 つの ViewModel で分担するようにします:

  • 構成要素A: Done ボタン
  • 構成要素B: 選択されたバッジ表示 UI
  • 構成要素C: まだ選択されていないバッジ表示 UI

まず、一番簡単である Done ボタンに対応する ViewModel の作成から始めます。この Done ボタンに対応する ViewModel の入出力は以下の通りです:

  • View から ViewModel への入力:
    • Done ボタンのタップイベントの Signal
  • ViewModel から View への出力:
    • Done ボタンの有効/無効フラグ の Driver
  • ViewModel の依存:
    • 選択された badge の一覧を保持した BehaviorRelay

この ViewModel は次のようにすっきりとしたコードになります:

BadgeSelectorCompletionViewModel.swift
// Done ボタンに対応する ViewModel。
class BadgeSelectorCompletionViewModel {
    typealias Dependency = (
        // 依存: 選択された badge 数の変化がわかる BehaviorRelay。
        selectedBadgesRelay: RxCocoa.BehaviorRelay<[Badge]>,
        wireframe: BadgeSelectorWireframe
    )

    // View への出力: Done ボタンの有効/無効フラグ の Driver。
    let canComplete: RxCocoa.Driver<Bool>


    private let dependency: Dependency
    private let disposeBag = DisposeBag()


    init(
        // View からの入力: Done ボタンのタップイベントの Signal。
        input doneTap: RxCocoa.Signal<Void>,
        dependency: Dependency
    ) {
        self.dependency = dependency

        // バッジが一つも選択されていなければ UINavigationBar 上の
        // Done ボタンを無効にする。
        self.canComplete = dependency.selectedBadgesRelay
            .map { selection in !selection.isEmpty }
            .asDriver(onErrorDriveWith: .empty())

        // Done ボタンタップ時に画面を遷移させる。
        doneTap
            .emit(onNext: { [weak self] _ in
                guard let `self` = self else { return }

                self.dependency.wireframe.goToResultScreen(
                    with: self.dependency.selectedBadgesRelay.value
                )
            })
            .disposed(by: self.disposeBag)
    }
}

見ての通り、この ViewModel は Done ボタンにおける双方向バインディングの責務だけを持つようになりました。したがって、この ViewModel においては単責務になったといえそうです。

同様に、選択されたバッジの表示 UI に対応する ViewModel を作成します。この ViewModel の入出力は次の通りです:

  • View から ViewModel への入力:
    • 上部バッジのタップイベントの Signal
  • ViewModel から View への出力:
    • 上部に表示するバッジの一覧の Driver
    • 選択解除されたバッジを通知する Signal
  • ViewModel の依存:
    • 選択された badge の一覧を保持した BehaviorRelay

これをコードにしてみましょう:

SelectedBadgesViewModel.swift
// 選択されたバッジの表示 UI に対応する ViewModel。
class SelectedBadgesViewModel {
    // View への出力: 上部に表示するバッジの一覧の Driver。
    let selectedBadges: RxCocoa.Driver<[Badge]>

    // View への出力: 選択解除されたバッジを通知する Signal。
    let badgeDidDeselect: RxCocoa.Signal<Badge>

    private let selectedBadgesRelay: RxCocoa.BehaviorRelay<[Badge]>
    private let badgeDidDeselectRelay: RxCocoa.PublishRelay<Badge>

    private let disposeBag = RxSwift.DisposeBag()


    init(
        // View からの入力: 上部バッジのタップイベントの Signal。
        input selectedTap: RxCocoa.Signal<Badge>,

        // 依存: 選択された badge の一覧を保持した BehaviorRelay。
        dependency selectedBadgesRelay: RxCocoa.BehaviorRelay<[Badge]>
    ) {
        // 選択済みのバッジの出力を。
        self.selectedBadgesRelay = selectedBadgesRelay
        self.selectedBadges = selectedBadgesRelay.asDriver()

        // バッジの選択が解除されたことを UIView へ通知するための PublishRelay。
        let badgeDidDeselectRelay = RxCocoa.PublishRelay<Badge>()
        self.badgeDidDeselectRelay = badgeDidDeselectRelay
        self.badgeDidDeselect = badgeDidDeselectRelay.asSignal()

        // 上部バッジタップで選択を解除する。
        selectedTap
            .emit(onNext: { [weak self] badge in
                guard let `self` = self,
                      let index = self.selectedBadgesRelay.value.index(of: badge) else { return }

                var newSelectedBadges = self.selectedBadgesRelay.value
                newSelectedBadges.remove(at: index)
                self.selectedBadgesRelay.accept(newSelectedBadges)

                self.badgeDidDeselectRelay.accept(badge)
            })
            .disposed(by: self.disposeBag)
    }
}

この ViewModel についても、上部選択済みバッジ表示 UI に対する双方向バインディングのみの責務になったといえそうです。

続けて、まだ選択されていないバッジの表示 UI に対応する ViewModel を作成します。この ViewModel の入出力は次の通りです:

  • View から ViewModel への入力:
    • 下部バッジのタップイベントの Signal
  • ViewModel から View への出力:
    • 下部に表示するバッジの一覧の Driver
    • 選択されたバッジを通知する Signal
  • ViewModel の依存:
    • 選択された badge の一覧を保持した BehaviorRelay
    • 選択されていない badge の一覧を保持した BehaviorRelay

では、これをコードにしてみましょう:

SelectableBadgesViewModel.swift
// まだ選択されていないバッジの表示 UI に対応する ViewModel。
class SelectableBadgesViewModel {
    typealias Dependency = (
        // 依存: 選択された badge の一覧を保持した BehaviorRelay。
        selectedBadgesRelay: RxCocoa.BehaviorRelay<[Badge]>,

        // 依存: 選択されていない badge の一覧を保持した BehaviorRelay。
        selectableBadgesRelay: RxCocoa.BehaviorRelay<[Badge]>
    )

    // View への出力: 下部に表示するバッジの一覧の Driver。
    let selectableBadges: RxCocoa.Driver<[Badge]>

    // View への出力: 選択されたバッジを通知する Signal(上部画面のスクロールに使われる)。
    let badgeDidSelect: RxCocoa.Signal<Badge>

    private let badgeDidSelectRelay: RxCocoa.PublishRelay<Badge>
    private let dependency: Dependency
    private let disposeBag = RxSwift.DisposeBag()


    init(
        // View からの入力: 下部バッジのタップイベントの Signal。
        input selectableTap: RxCocoa.Signal<Badge>,
        dependency: Dependency
    ) {
        self.dependency = dependency

        // バッジが選択されたことを UIView へ通知するための PublishRelay。
        let badgeDidSelectRelay = RxCocoa.PublishRelay<Badge>()
        self.badgeDidSelectRelay = badgeDidSelectRelay
        self.badgeDidSelect = badgeDidSelectRelay.asSignal()

        // 選択可能なバッジの出力。
        self.selectableBadges = dependency.selectableBadgesRelay.asDriver()

        // 下部バッジタップで選択する。
        selectableTap
            .emit(onNext: { [weak self] badge in
                guard let `self` = self else { return }

                var newSelectedBadges = self.dependency.selectedBadgesRelay.value
                newSelectedBadges.append(badge)
                self.dependency.selectedBadgesRelay.accept(newSelectedBadges)

                self.badgeDidSelectRelay.accept(badge)
            })
            .disposed(by: self.disposeBag)
    }
}

この ViewModel も他の ViewModel と同様に、単責務にできたといえそうです。

最後に、Fat ViewModel だった元の ViewModel を修正し、先ほどの 3 つの ViewModel をまとめる ViewModel とします:

BadgeSelectorViewModel.swift
class BadgesSelectorViewModel {
    typealias Dependency = (
        repository: AnyEntityRepository<Void, [Badge], Never>,
        wireframe: BadgeSelectorWireframe
    )
    typealias Input = (
        // View からの入力: Done ボタンのタップイベントの Signal。
        doneTap: RxCocoa.Signal<Void>,

        // View からの入力: 上部バッジのタップイベントの Signal。
        selectedTap: RxCocoa.Signal<Badge>,

        // View からの入力: 下部バッジのタップイベントの Signal。
        selectableTap: RxCocoa.Signal<Badge>
    )

    // View への出力: 子の ViewModel のプロパティを介して各コンポーネントへの出力を公開する。
    let selectedViewModel: SelectedBadgesViewModel
    let selectableViewModel: SelectableBadgesViewModel
    let completionViewModel: BadgeSelectorCompletionViewModel

    var currentSelectedBadges: [Badge] {
        return self.selectedBadgesRelay.value
    }

    private let allBadgesRelay: RxCocoa.BehaviorRelay<[Badge]>
    private let selectedBadgesRelay: RxCocoa.BehaviorRelay<[Badge]>
    private let selectableBadgesRelay: RxCocoa.BehaviorRelay<[Badge]>
    private let disposeBag = RxSwift.DisposeBag()


    init(input: Input, dependency: Dependency) {
        let allBadgesRelay = RxCocoa.BehaviorRelay<[Badge]>(value: [])
        self.allBadgesRelay = allBadgesRelay
        let selectedBadgesRelay = RxCocoa.BehaviorRelay<[Badge]>(value: [])
        self.selectedBadgesRelay = selectedBadgesRelay
        let selectableBadgesRelay = RxCocoa.BehaviorRelay<[Badge]>(value: [])
        self.selectableBadgesRelay = selectableBadgesRelay

        // 子の ViewModel を内部的に作成する。
        self.selectedViewModel = SelectedBadgesViewModel(
            input: input.selectedTap,
            dependency: selectedBadgesRelay
        )
        self.selectableViewModel = SelectableBadgesViewModel(
            input: input.selectableTap,
            dependency: (
                selectedBadgesRelay: selectedBadgesRelay,
                selectableBadgesRelay: selectableBadgesRelay
            )
        )
        self.completionViewModel = BadgeSelectorCompletionViewModel(
            input: input.doneTap,
            dependency: (
                selectedBadgesRelay: selectedBadgesRelay,
                wireframe: dependency.wireframe
            )
        )

        // バッジの取得処理は、まだこのまとめ役 ViewModel が管理する必要がある。
        dependency.repository
            .get(count: 100)
            .subscribe(
                onSuccess: { [weak self] result in
                    guard let `self` = self else { return }

                    switch result {
                    case let .success(badges):
                        self.allBadgesRelay.accept(badges)
                    case .failure:
                        break
                    }
                },
                onError: nil
            )
            .disposed(by: self.disposeBag)

        // 選択可能なバッジの出力。
        RxCocoa.Driver
            .combineLatest(
                allBadgesRelay.asDriver(),
                selectedBadgesRelay.asDriver(),
                resultSelector: { ($0, $1) }
            )
            .map { tuple -> [Badge] in
                let (allBadges, selectedBadges) = tuple
                // すべてのバッジから既に選択されているバッジを除く。
                return BadgesSelectorViewModel.dropSelected(
                    from: allBadges,
                    without: Set(selectedBadges)
                )
            }
            .drive(selectableBadgesRelay)
            .disposed(by: self.disposeBag)
    }


    private static func dropSelected(from allBadges: [Badge], without selectedBadges: Set<Badge>) -> [Badge] {
        return allBadges
            .filter { !selectedBadges.contains($0) }
    }
}

この ViewModel の分割によって、Fat だった ViewModel の責務を以下のように減らせました:

  1. すべてのバッジの取得
  2. 選択されたバッジの状態の保持
  3. まだ選択されていないバッジの抽出
  4. まだ選択されていないバッジ表示 UI への双方向バインディング
  5. 選択されたバッジ表示 UI への双方向バインディング
  6. Done ボタンの双方向バインディング
  7. 子 ViewModel の管理 ←🆕

責務は以前のおよそ半分になり、かなり見通しをよくできました。

さて、責務を減らすことを目的とするだけならば、このリファクタリングで十分です。しかし、実はさらに ViewModel から責務を減らす方法があります。それは前の節で触れた Model 層への責務の移動です。この方法には責務を減らすだけでなく、テスト容易性の向上などの様々なメリットがあります。そのため、ここで手を止めずに続けて Model 層への分離をやってみましょう。

Model 層への分離

Model 層への分離では躓きやすいポイントがあります。そのポイントは、「いったいどの責務を Model 層へ移すべきなのか」です。これにはいくつかの流派があるのですが、この記事では「双方向バインディングと子ViewModelの管理以外のすべては Model 層へと分離できる」という立場をとります。

この立場では、先ほどの例における以下の責務はすべて Model 層へと分離できます:

  1. すべてのバッジの取得
  2. 選択されたバッジの状態の保持
  3. まだ選択されていないバッジの抽出

では、実際にどのような Model になるのか具体例をみてみましょう。まず、最もわかりやすい Model として、選択されたバッジの状態を保持する Model のコードを示します:

SelectedBadgesModel.swift
// Model 層は Protocol にしておくと ViewModel のテストがやりやすくなる。
// ViewModel のテストでは偽物の Model と差し替えると挙動が確認しやすくなるため。
protocol SelectedBadgesModel {
    // 選択されたバッジの一覧に変化があったら通知する Driver。
    var selectionDidChange: RxCocoa.Driver<[Badge]> { get }

    // 現在選択されているバッジ。
    var currentSelection: [Badge] { get }

    // 新たに選択されたバッジを通知する Driver。
    var badgeDidSelect: RxCocoa.Signal<Badge> { get }

    // 新たに選択解除されたバッジを通知する Driver。
    var badgeDidDeselect: RxCocoa.Signal<Badge> { get }

    // 与えられたバッジを選択する。
    func select(badge: Badge)

    // 与えられたバッジの選択を解除する。
    func deselect(badge: Badge)
}



// 上の Protocol を実装した本体実装。
class DefaultSelectedBadgesModel: SelectedBadgesModel {
    private let selectedBadgesRelay: RxCocoa.BehaviorRelay<[Badge]>
    private let badgeDidSelectRelay: RxCocoa.PublishRelay<Badge>
    private let badgeDidDeselectRelay: RxCocoa.PublishRelay<Badge>

    let badgeDidSelect: RxCocoa.Signal<Badge>
    let badgeDidDeselect: RxCocoa.Signal<Badge>
    let selectionDidChange: RxCocoa.Driver<[Badge]>


    var currentSelection: [Badge] {
        get { return self.selectedBadgesRelay.value }
        set { self.selectedBadgesRelay.accept(newValue) }
    }


    init(selected initialSelection: [Badge]) {
        self.stateMachine = StateMachine(
            startingWith: initialSelection
        )

        let badgeDidSelectRelay = RxCocoa.PublishRelay<Badge>()
        self.badgeDidSelectRelay = badgeDidSelectRelay
        self.badgeDidSelect = badgeDidSelectRelay.asSignal()

        let badgeDidDeselectRelay = RxCocoa.PublishRelay<Badge>()
        self.badgeDidDeselectRelay = badgeDidDeselectRelay
        self.badgeDidDeselect = badgeDidDeselectRelay.asSignal()

        self.selectionDidChange = stateMachine.stateDidChange
    }


    func select(badge: Badge) {
        guard !self.currentSelection.contains(badge) else { return }

        var newSelection = self.currentSelection
        newSelection.append(badge)

        self.currentSelection = newSelection

        self.badgeDidSelectRelay.accept(badge)
    }


    func deselect(badge: Badge) {
        var newSelection = self.currentSelection

        guard let index = newSelection.index(of: badge) else { return }

        newSelection.remove(at: index)

        self.currentSelection = newSelection

        self.badgeDidDeselectRelay.accept(badge)
    }
}

この Model には、バッジの選択/非選択に関わる状態管理処理が凝集されていることがわかります。したがって、Model 自体も単一責務原則を満たしています。

また、この Model を使った ViewModel をみてみましょう。次のように、選択解除の処理が Model 層へと分離されたため、ViewModel の責務がさらに減ったことがわかります:

SelectedBadgesViewModel.swift
class SelectedBadgesViewModel {
    let selectedBadges: RxCocoa.Driver<[Badge]>
    let badgeDidSelect: RxCocoa.Signal<Badge>
    let badgeDidDeselect: RxCocoa.Signal<Badge>

    private let selectedModel: SelectedBadgesModel
    private let disposeBag = RxSwift.DisposeBag()


    init(
        input selectedTap: RxCocoa.Signal<Badge>,
        // init 関数の引数から SelectedBadgesModel を注入する。
        dependency selectedModel: SelectedBadgesModel
    ) {
        self.selectedModel = selectedModel
        self.selectedBadges = selectedModel.selectionDidChange
        self.badgeDidSelect = selectedModel.badgeDidSelect
        self.badgeDidDeselect = selectedModel.badgeDidDeselect

        // 上部バッジのタップでバッジの選択を解除する。
        selectedTap
            .emit(onNext: { [weak self] badge in
                guard let `self` = self else { return }

                // 選択解除に関する処理が Model 層へと移動したため、見通しがよくなった。
                self.selectedModel.deselect(badge: badge)
            })
            .disposed(by: self.disposeBag)
    }
}

これで、選択されたバッジの状態の保持の責務を Model 層へと分離できました。

次に、すべてのバッジを取得する Model をみてみましょう:

AllBadgesModel.swift
// ViewModel のテストを容易にするために Protocol にする。
protocol AllBadgesModel: class {
    // バッジの取得状態が変化したら通知する Driver。
    var stateDidChange: RxCocoa.Driver<AllBadgesModelState> { get }

    // 現在のバッジの取得状態。
    var currentState: AllBadgesModelState { get }
}



// バッジの取得状態の型。
enum AllBadgesModelState {
    // まだ取得されていない状態。
    case notFetchedYet

    // 取得中の状態。
    case fetching

    // 取得が完了した状態。
    case fetched(result: Result<[Badge], FailureReason>)


    var badges: [Badge] {
        switch self {
        case let .fetched(result: .success(badges)):
            return badges
        case .notFetchedYet, .fetching, .fetched(result: .failure):
            return []
        }
    }


    var isFetching: Bool {
        switch self {
        case .fetching:
            return true
        case .notFetchedYet, .fetched:
            return false
        }
    }


    enum FailureReason {
        case unspecified(debugInfo: String)
    }
}



class DefaultAllBadgesModel: AllBadgesModel {
    let stateDidChange: RxCocoa.Driver<AllBadgesModelState>


    var currentState: AllBadgesModelState {
        get { return self.stateRelay.value }
        set { self.stateRelay.accept(newValue) }
    }


    private let stateRelay: RxCocoa.BehaviorRelay<AllBadgesModelState>
    private let repository: BadgesRepository
    private let disposeBag = RxSwift.DisposeBag()


    init(gettingBadgesVia repository: BadgesRepository) {
        self.repository = repository
        self.stateRelay = RxCocoa.BehaviorRelay<AllBadgesModelState>(value: .notFetchedYet)
        self.stateDidChange = stateRelay.asDriver()

        self.fetch()
    }


    // バッジの一覧を更新する。もし、バッジの一覧の更新の仕様追加があっても、
    // このメソッドを public にするだけでよい。
    private func fetch() {
        // すでに更新中だったら fetch しない。
        guard !self.currentState.isFetching else { return }
        self.currentState = .fetching

        // リポジトリからすべてのバッジを取得する。
        self.repository
            .get(count: 100)
            .subscribe(
                onSuccess: { [weak self] result in
                    guard let `self` = self else { return }

                    switch result {
                    // もし取得に成功したら、バッジを BehaviorRelay に保持する。
                    case let .success(badges):
                        self.currentState = .fetched(result: .success(badges))

                    // もし取得に失敗したら、失敗理由を保持しておく。
                    case let .failure(reason):
                        self.currentState = .fetched(result: .failure(.unspecified(debugInfo: "\(reason)")))
                    }
                },
                onError: { [weak self] error in
                    guard let `self` = self else { return }

                    // もし取得に失敗したら、失敗理由を保持しておく。
                    self.currentState = .fetched(result: .failure(.unspecified(debugInfo: "\(error)")))
                }
            )
            .disposed(by: self.disposeBag)
    }
}

見ての通り、すべてのバッジの取得の責務をうまくこの Model へと凝集できたことがわかります。

最後に、まだ選択されていないバッジの抽出の責務をもつ Model をみてみましょう:

SelectableBadgesModel.swift
// ViewModel のテストを容易にするために Protocol にする。
protocol SelectableBadgesModel {
    // 選択可能なバッジの一覧が変化したら通知する Driver。
    var selectableBadgesDidChange: RxCocoa.Driver<[Badge]> { get }

    // 現在選択可能なバッジの一覧。
    var currentSelectableBadges: [Badge] { get }
}



class DefaultSelectableBadgesModel: SelectableBadgesModel {
    typealias Dependency = (
        allModel: AllBadgesModel,
        selectedModel: SelectedBadgesModel
    )
    private let disposeBag = RxSwift.DisposeBag()
    private let dependency: Dependency
    private let selectableBadgesRelay: RxCocoa.BehaviorRelay<[Badge]>


    let selectableBadgesDidChange: RxCocoa.Driver<[Badge]>


    var currentSelectableBadges: [Badge] {
        return selectableBadgesRelay.value
    }


    init(dependency: Dependency) {
        self.dependency = dependency

        // NOTE: 選択できるバッジの一覧を同期的に取得できるようにするために
        // BehaviorRelay を使う。
        let selectableBadgesRelay = RxCocoa.BehaviorRelay(
            value: DefaultSelectableBadgesModel.dropSelected(
                from: dependency.allModel.currentState.value ?? [],
                without: Set(dependency.selectedModel.currentSelection)
            )
        )
        self.selectableBadgesRelay = selectableBadgesRelay
        self.selectableBadgesDidChange = selectableBadgesRelay.asDriver()

        RxCocoa.Driver
            .combineLatest(
                dependency.allModel.stateDidChange,
                dependency.selectedModel.selectionDidChange,
                resultSelector: { ($0, $1) }
            )
            .map { tuple -> [Badge] in
                let (allBadgesModelState, selectedBadges) = tuple
                return DefaultSelectableBadgesModel.dropSelected(
                    from: allBadgesModelState.value ?? [],
                    without: Set(selectedBadges)
                )
            }
            .drive(self.selectableBadgesRelay)
            .disposed(by: self.disposeBag)
    }


    private static func dropSelected(from allBadges: [Badge], without selectedBadges: Set<Badge>) -> [Badge] {
        return allBadges
            .filter { !selectedBadges.contains($0) }
    }
}

これで Fat ViewModel だった BadgeSelectorViewModel から子 ViewModel の管理以外の責務を Model 層へと移せました:

  1. すべてのバッジの取得
  2. 選択されたバッジの状態の保持
  3. まだ選択されていないバッジの抽出
  4. まだ選択されていないバッジ表示 UI への双方向バインディング
  5. 選択されたバッジ表示 UI への双方向バインディング
  6. Done ボタンの双方向バインディング
  7. 子 ViewModel の管理

つまり、責務が多すぎた Fat ViewModel を単責務にできたことがわかります。では、単責務になった BadgeSelectorViewModel のコードがどのようになったかみてみましょう:

BadgeSelectorViewModel.swift
class BadgesSelectorViewModel {
    typealias Dependency = (
        selectedModel: SelectedBadgesModel,
        selectableModel: SelectableBadgesModel,
        wireframe: BadgeSelectorWireframe
    )
    typealias Input = (
        // View からの入力: Done ボタンのタップイベントの Signal。
        doneTap: RxCocoa.Signal<Void>,

        // View からの入力: 上部バッジのタップイベントの Signal。
        selectedTap: RxCocoa.Signal<Badge>,

        // View からの入力: 下部バッジのタップイベントの Signal。
        selectableTap: RxCocoa.Signal<Badge>
    )

    // View への出力: 子の ViewModel のプロパティを介して各コンポーネントへの出力を公開する。
    let selectedViewModel: SelectedBadgesViewModel
    let selectableViewModel: SelectableBadgesViewModel
    let completionViewModel: BadgeSelectorCompletionViewModel

    private let disposeBag = RxSwift.DisposeBag()


    init(input: Input, dependency: Dependency) {
        let selectedModel = dependency.selectedModel

        self.selectedViewModel = SelectedBadgesViewModel(
            input: input.selectedTap,
            dependency: selectedModel
        )

        self.selectableViewModel = SelectableBadgesViewModel(
            input: input.selectableTap,
            dependency: (
                selectedModel: selectedModel,
                selectableModel: dependency.selectableModel
            )
        )

        self.completionViewModel = BadgeSelectorCompletionViewModel(
            input: input.doneTap,
            dependency: (
                selectedModel: selectedModel,
                wireframe: dependency.wireframe
            )
        )
    }
}

とてもスッキリしたコードになりました。行数も 135 行 → 37 行ととても短くなりました。

また、責務の一覧をみてみると、責務1つにつき1つのクラスが対応することがわかります:

責務 対応するクラス
すべてのバッジの取得 AllBadgesModel
選択されたバッジの状態の保持 SelectedBadgesModel
まだ選択されていないバッジの抽出 SelectableBadgesModel
まだ選択されていないバッジ表示 UI への双方向バインディング SelectableBadgesViewModel
選択されたバッジ表示 UI への双方向バインディング SelectedBadgesViewModel
Done ボタンの双方向バインディング BadgeSelectorCompletionViewModel
子 ViewModel の管理 BadgeSelectorViewModel

これで、この MVVM に登場したすべてのクラスが単一責務原則を満たすようになったといえそうです。

さて、今回は執拗に単一責務原則を追いましたが、これによって得られたもの/失われたものをまとめてみましょう:

  • メリット:
    • 責務に応じてコードが凝集されたので、処理の意図を把握しやすくなる
    • スコープが小さくなったので、変数の挙動把握が楽になる
    • 分割されたクラスは他の場所でも再利用できる
    • テストが容易になる(この記事では説明を割愛しています)
  • デメリット:
    • クラスの数が多くなるため、システム全体の可読性は落ちる
    • コード量がやや増える

実際に今回のようなリファクタリングへ着手する際には、上記のメリット/デメリットのバランスをよく考える必要があります。なお、著者は単一責務原則はメリットの方が勝ると考えているので、なるべく単一責務にしています。ただし、実装速度を優先する場合には単一責務を諦めるといったバランス調整をしています。

では、最後にこれまでの解説をまとめます。

まとめ

この記事では、RxExample MVVM で陥りがちな Fat ViewModel への対応方法として、ViewModel の分割と Model 層への分離の2つの方法があることを解説しました。また、それぞれの方法を突き進めていくと、MVVM における ViewModel と Model のクラスを単責務にできることを解説しました。

実装例の紹介

この記事で紹介されている実装例は、Kuniwak/RxNextExample で閲覧できます。なお、質問や改善提案は歓迎しております。お気軽に issue や pull request を送ってください。

上級者向け注釈

  1. RxExample の View の持ち方とは少し異なりますが、UIViewController を任意の引数で初期化できるようにするためのアレンジです。本題は View-ViewModel 間の接続方法なので、そちらに注目してください。
  2. View の持ち方を weak にするべきでは、とも思うかもしれませんが、結論としては weak でなくとも循環参照にはなりません。なぜなら、hoge.rx.moge の実体である Binder や ControlProperty は内部的に weak で UI要素を保持するためです。 そのため、VC のプロパティが weak でなくとも、循環参照になることはありません。
  3. この記事では、Driver が RxCocoa 由来のものだということを強調するために常にパッケージ名をつけています。あくまで例の読みやすさのためにつけているので、実際のコードを書く際には省略して構いません。
  4. RxExample では ViewModel を保持していない書き方が見受けられます。しかし、この記事では ViewModel が DisposeBag を持つことがあり、ViewModel が回収されると意図しない挙動をして危険です。そのため、ViewModel の参照を保持する方向へ倒しています。
  5. 変数定義とインスタンス変数定義を分けて書いている理由は2つあります:
    • init 内でまだ未初期化な状態の self へのアクセスが発生しないようにするため。なお、Driver.empty() などで初期化しておいて初期化時に上書きする方法はバッドプラクティスです。let での変数宣言が出来なくなるからです。
    • Optional な変数への参照を避けるため。ViewController 上でのself.viewModel を例とします。最初から self.viewModel = ViewModel(...) とすると、以降の ViewModel の出力の購読が self.viewModel?.hoge となってしまいます。すると、Optional なはずなのに確実に実行されることを期待されるという、暗黙の前提を埋め込むことになります。暗黙の前提は可読性を落とすため、なるべく避けたほうがいいでしょう。