3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-11-19

概要

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

参考リンク: Modeling Your View Models as Functions

前提知識

本稿を読む前提として、以下の知識があると理解しやすいです。

  • iOSアプリ開発
  • Swift 5
  • RxSwift

MVVMを使ったアプリの実装例

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

仕様

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

  • 画面の中央にカウンターを表示する
  • +ボタンを押すとカウンターの数値が1増える
  • -ボタンを押すとカウンターの数値が1減る
  • 数値が偶数ならカウンターの文字色が黒に、奇数ならになる。

画面レイアウト

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

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だけで実装してみましょう。コードは以下のとおりです。

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は以下のように実装します。

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は以下のように実装します。

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と宣言するプロトコルがやや多い。
  • InputsPropertyとの対応関係の記述が冗長。
  • 使用されないOutputs変数があってもコンパイラに警告されない。

MVVM(関数型スタイル)による実装例

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

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

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は以下のように実装します。

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に精通していることが必要とされる。
  • 一つの関数内の記述量が多いため、適切に処理を切り出さないと却って見通しが悪くなる。
3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?