[Swift][MVVM]関数型ViewModelのススメ

# 概要
本稿ではアプリアーキテクチャの1形式であるMVVMの、特に`ViewController`と`ViewModel`の関連を記述するスタイルとして`関数型ViewModel`を紹介します。

参考リンク: [Modeling Your View Models as Functions](https://medium.com/grailed-engineering/modeling-your-view-models-as-functions-65b58525717f)

## 前提知識
本稿を読む前提として、以下の知識があると理解しやすいです。
- iOSアプリ開発
- Swift 5
- [RxSwift](https://github.com/ReactiveX/RxSwift)

# MVVMを使ったアプリの実装例
いくつかのスタイルで簡単な機能のアプリを実装してみます。

## 仕様
まず、以下のようなアプリを考えてみましょう

- 画面の中央にカウンターを表示する
- +ボタンを押すとカウンターの数値が1増える
- -ボタンを押すとカウンターの数値が1減る
- 数値が偶数ならカウンターの文字色が黒に、奇数なら<font color="Red">赤</font>になる。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/720855/b4445b51-80f7-7dfe-cb52-c2d8861e2d36.png" width=320>

## 画面レイアウト
まずレイアウトを実装します。この時点ではボタンを押しても何も反応しません。

``` swift
import UIKit

final class ViewController: UIViewController {
    private var countLabel: UILabel! // 合計カウント表示ラベル
    private var plusButton: UIButton! // +ボタン
    private var minusButton: UIButton! // -ボタン

    override func loadView() {
        super.loadView()
        view.backgroundColor = .white

        countLabel = UILabel()
        view.addSubview(countLabel)
        countLabel.translatesAutoresizingMaskIntoConstraints = false
        countLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        countLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true


        plusButton = UIButton(type: .system)
        plusButton.setTitle("+", for: .normal)
        view.addSubview(plusButton)
        plusButton.translatesAutoresizingMaskIntoConstraints = false
        plusButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        plusButton.bottomAnchor.constraint(equalTo: countLabel.topAnchor, constant: -10).isActive = true


        minusButton = UIButton(type: .system)
        minusButton.setTitle("-", for: .normal)
        view.addSubview(minusButton)
        minusButton.translatesAutoresizingMaskIntoConstraints = false
        minusButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        minusButton.topAnchor.constraint(equalTo: countLabel.bottomAnchor, constant: 10).isActive = true
    }
```

## ViewControllerによる実装例
このアプリを、愚直に`ViewController`だけで実装してみましょう。コードは以下のとおりです。

``` swift
import UIKit

final class ViewController: UIViewController {
    // レイアウトは省略
    private var count: Int = 0

    override func viewDidLoad() {
        super.viewDidLoad()
        plusButton.addTarget(self, action: #selector(plusButtonDidTap), for: .touchUpInside)
        minusButton.addTarget(self, action: #selector(minusButtonDidTap), for: .touchUpInside)
        updateCountLabel()
    }

    @objc func plusButtonDidTap() {
        count += 1
        updateCountLabel()
    }

    @objc func minusButtonDidTap() {
        count -= 1
        updateCountLabel()
    }

    private func updateCountLabel() {
        countLabel.text = "\(count)"
        countLabel.textColor = count %2 ==0
            ? UIColor.black
            : UIColor.red
    }
}
```

## MVVM(Kickstarterスタイル)による実装例
https://github.com/kickstarter/ios-oss (Kickstarter社のリポジトリ)
https://qiita.com/muukii/items/045b12405f7acff1a9fd (KickstarterスタイルのMVVMについての解説記事)

こちらのリンクを参考に、同じ機能を実装してみましょう。

`ViewModel`は以下のように実装します。

```swift
import RxSwift
import RxCocoa

protocol ViewModelInputs {
    func plusButtonDidTap()
    func minusButtonDidTap()
}

protocol ViewModelOutputs {
    var count: Driver<Int> { get }
    var isEven: Driver<Bool> { get }
}

protocol ViewModelType {
    var inputs: ViewModelInputs { get }
    var outputs: ViewModelOutputs { get }
}

struct ViewModel: ViewModelType, ViewModelInputs, ViewModelOutputs {

    var inputs: ViewModelInputs { return self }
    var outputs: ViewModelOutputs { return self }

    let count: Driver<Int>
    let isEven: Driver<Bool>

    private let plusButtonDidTapProperty = PublishSubject<Void>()
    private let minusButtonDidTapProperty = PublishSubject<Void>()

    init() {
        count = Observable.merge(plusButtonDidTapProperty.map { _ in 1 }, // +ボタンタップは1に変換する
                                 minusButtonDidTapProperty.map { _ in -1 }) // -ボタンタップは-1に変換する
            .scan(0, accumulator: +) // 0から開始して今までの合計値を出力する
            .startWith(0) // 初期値は0
            .asDriver(onErrorDriveWith: .never())

        isEven = count
            .map { $0 % 2 == 0 }
    }

    func plusButtonDidTap() { plusButtonDidTapProperty.onNext(()) }
    func minusButtonDidTap() { minusButtonDidTapProperty.onNext(()) }
}

```

`ViewController`は以下のように実装します。

```swift
import RxSwift
import RxCocoa

final class ViewController: UIViewController {
    // レイアウトは省略
    private let viewModel: ViewModelType
    private let disposeBag = DisposeBag()

    init(viewModel: ViewModelType = ViewModel()) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        plusButton.rx.tap
            .subscribe(onNext: { [viewModel] in viewModel.inputs.plusButtonDidTap() })
            .disposed(by: disposeBag)

        minusButton.rx.tap
            .subscribe(onNext: { [viewModel] in viewModel.inputs.minusButtonDidTap() })
            .disposed(by: disposeBag)

        viewModel.outputs
            .count
            .map { "\($0)" }
            .drive(countLabel.rx.text)
            .disposed(by: disposeBag)

        viewModel.outputs
            .isEven
            .map { $0 ? UIColor.black : UIColor.red }
            .drive(onNext: { [countLabel] in countLabel?.textColor = $0 })
            .disposed(by: disposeBag)
    }
}
```

国内の資料でiOS・Swift環境でMVVMというと、だいたいはこのようなKickstarterスタイルのMVVMが多い印象を受けます。
RxSwiftを使用するテンプレートとしては優れていますが、以下のような問題点があります。

- `Inputs`,`Outputs`と宣言するプロトコルがやや多い。
- `Inputs`と`Property`との対応関係の記述が冗長。
- 使用されない`Outputs`変数があってもコンパイラに警告されない。

## MVVM(関数型スタイル)による実装例
https://medium.com/grailed-engineering/modeling-your-view-models-as-functions-65b58525717f

こちらの記事を参考に、関数型スタイルのMVVMを実装してみましょう。

`ViewModel`は以下のように実装します。リンク先の記事とは違いSwift5.2で追加された`callAsFunction`を使っています。

```swift
import RxSwift
import RxCocoa

protocol ViewModelType {
    func callAsFunction(
        plusButtonDidTap: Observable<Void>,
        minusButtonDidTap: Observable<Void>
    ) -> (
        count: Driver<Int>,
        isEven: Driver<Bool>
    )
}

struct ViewModel: ViewModelType {
    func callAsFunction(
        plusButtonDidTap: Observable<Void>,
        minusButtonDidTap: Observable<Void>
    ) -> (
        count: Driver<Int>,
        isEven: Driver<Bool>
    ) {
        let count = Observable.merge(plusButtonDidTapProperty.map { _ in 1 }, // +ボタンタップは1に変換する
                                 minusButtonDidTapProperty.map { _ in -1 } ) // -ボタンタップは-1に変換する
            .scan(0, accumulator: +) // 0から開始して今までの合計値を出力する
            .startWith(0) // 初期値は0
            .asDriver(onErrorDriveWith: .never())

        let isEven = count
            .map { $0 % 2 == 0 }

        return (
            count,
            isEven
        )
    }
}

```

`ViewController`は以下のように実装します。

```swift
import RxSwift
import RxCocoa

final class ViewController: UIViewController {
    private let viewModel: ViewModelType
    private let disposeBag = DisposeBag()

    init(viewModel: ViewModelType = ViewModel()) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    override func viewDidLoad() {
        super.viewDidLoad()

        let (
            count,
            isEven
        ) = viewModel(
            plusButtonDidTap: plusButton.rx.tap.asObservable(),
            minusButtonDidTap: minusButton.rx.tap.asObservable()
        )

        count
            .map { "\($0)" }
            .drive(countLabel.rx.text)
            .disposed(by: disposeBag)

        isEven
            .map { $0 ? UIColor.black : UIColor.red }
            .drive(onNext: { [countLabel] in countLabel?.textColor = $0 })
            .disposed(by: disposeBag)
    }
}
```

これまでのスタイルと違いViewModelの記述がシンプルになっていますね。

# 関数型MVVMを使うメリット・デメリット
主にKickStarterスタイルと比較した場合のメリット・デメリットが以下になります。

## メリット
- 宣言するプロトコル、メソッドが一つで済む。その他全体的な記述がシンプルになっている。
- 一つの関数内に`Observable`のイベント遷移が集約されるため、実装の検証が容易。
- `ViewModel`の戻り値に使用されていない変数があった場合、コンパイラが警告してくれる。

## デメリット
- KickStarterスタイルと比べてより宣言的な記述を強要するため、開発メンバーがRxSwiftに精通していることが必要とされる。
- 一つの関数内の記述量が多いため、適切に処理を切り出さないと却って見通しが悪くなる。