【iOS・設計】MVVMアーキテクチャ【サンプルコード付き】

公開日: 

はじめに

この記事では、前回に引き続き、iOS アプリのアーキテクチャについて紹介します。今回はMVVMです。

前回の記事

https://www.yukendev.com/blogs/swift-mvp

アーキテクチャってなんぞや?

ていうかたはよかったら下の記事を読んでみてください。

https://www.yukendev.com/blogs/book-ios-architecture

MVVM アーキテクチャとは

簡単にいうと MVP のデータバインディングバージョンです。MVP ではPresenter → Viewをつなぐやり方として

self.view.updateTodo()

のように Presenter が View の参照をもち、直接処理を呼び出していました。対して MVVM ではViewModel → Viewをつなぐのにデータバインディングという方法を用います。

データバインディングとは

下の図のように、一方のコンポーネントがもう一つを監視することで、手続的な処理を経なくても、データを自動で更新できる方法です。

双方に監視しあうデータバインディングも存在しますが、今回は ViewViewModel の単方向のバインディングで説明します。

MVVM の特徴

MVVM におけるそれぞれのコンポーネントの役割をまとめると以下のようになります。

  • Model: 各種ビジネスロジックのかたまり
  • View: 画面の描画、入力の受付
  • ViewModel: Model と View の仲介役であり、プレゼンテーションロジックを担う

冒頭で、MVVM は MVP のデータバインディングを用いたバージョンだと書いたようにそれぞれのコンポーネントの役割はほぼ MVP と同じです。違いは ViewPresenter/ViewModel の繋ぎ方でしょう。データバインディングを用いることで、ViewModelView の参照を持つ必要がなくなりより疎結合になったと言えます。また、RxSwift や ReactiveSwift などのデータバインディングに向いているライブラリが存在するのも、MVVM が使われることが多い理由にもなっています。

まとめると

  • Model: 各種ビジネスロジックのかたまり
  • View: 画面の描画、入力の受付
  • ViewModel: Model と View の仲介役であり、プレゼンテーションロジックを担う

データバインディングを用いてView → ViewModel間をつなぐことで、MVP のときよりも疎結合になり、テストや作業分担がしやすくなっています。MVVM の概要は以上です。

サンプルアプリ

以上の MVVM の考え方を用いてよくある登録フォームのような簡単なアプリを作ってみました。空欄があったり、パスワードとパスワード(確認用)が一致していないと警告が出て、登録ボタンが押せないようになっています。今回のサンプルアプリでは『RxSwift』というライブラリを使ってデータバインディングを実装しています。

ソースコード

https://github.com/yukendev/sampleMVVM

View.swift
import UIKit
import RxSwift
import RxCocoa

// MARK: -- View
class ViewController: UIViewController {

    @IBOutlet weak var idTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var passwordConfirmTextField: UITextField!
    @IBOutlet weak var validationLabel: UILabel!
    @IBOutlet weak var registerButton: UIButton!
    private var viewModel: ViewModel!
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        self.viewModel = ViewModel(
            input: (
                idTextField.rx.text.orEmpty.asDriver(),
                passwordTextField.rx.text.orEmpty.asDriver(),
                passwordConfirmTextField.rx.text.orEmpty.asDriver()
            )
        )

        viewModel.validationResult.drive(onNext: { validationresult in
            self.registerButton.isEnabled = validationresult.isValidated
            self.validationLabel.text = validationresult.text
            self.validationLabel.textColor = validationresult.textColor
        }).disposed(by: disposeBag)
    }

    // 登録ボタンがタップされた時の処理
    @IBAction func registerButtonTapped(_ sender: Any) {
        let alert = UIAlertController(title: "登録!", message: "", preferredStyle: .alert)
        let ok = UIAlertAction(title: "ok", style: .cancel, handler: nil)
        alert.addAction(ok)
        self.present(alert, animated: true, completion: nil)
    }
}
ViewModel.swift
import Foundation
import RxSwift
import RxCocoa

// MARK: -- ViewModel
final class ViewModel {

    typealias Input = (
        idDriver: Driver<String>,
        passwordDriver: Driver<String>,
        passwordConfirmDriver: Driver<String>
    )

    // バリデーションの結果
    let validationResult: Driver<ValidationResult>
    // 空欄がないかどうかのバリデーション
    let blankValidation: Driver<Bool>
    // パスワードとパスワード(確認用)が一致しているかどうかのバリデーション
    let passwordConfirmValidation: Driver<Bool>

    private let disposeBag = DisposeBag()

    init(input: Input) {

        let validationModel = ValidationModel()

        blankValidation = Driver.combineLatest(
            input.idDriver,
            input.passwordDriver,
            input.passwordConfirmDriver
        ) { id, password, passwordConfirm in
            return validationModel.blankBalidation(text: [id, password, passwordConfirm])
        }

        passwordConfirmValidation = Driver.combineLatest(
            input.passwordDriver,
            input.passwordConfirmDriver
        ) { password, passwordConfirm in
            return validationModel.passwordConfirmValidation(password: password, passwordConfirm: passwordConfirm)
        }

        validationResult = Driver.combineLatest(
            blankValidation,
            passwordConfirmValidation
        ) { blankValidation, passwordConfirmValidation in
            if !blankValidation {
                // 空白がある場合
                return .blankError
            } else if !passwordConfirmValidation {
                // パスワードが確認用と一致していない場合
                return .passwordConfirmError
            } else {
                // 全てのバリデーションがOKの場合
                return .ok
            }
        }
    }
}
Model.swift
import Foundation
import UIKit

enum ValidationResult {

    case ok
    case blankError
    case passwordConfirmError

    var isValidated: Bool {
        switch self {
        case .ok: return true
        case .blankError, .passwordConfirmError: return false
        }
    }

    var text: String {
        switch self {
        case .ok: return "登録可能です"
        case .blankError: return "空欄があります"
        case .passwordConfirmError: return "パスワードが確認用と一致していません"
        }
    }

    var textColor: UIColor {
        switch self {
        case .ok: return .green
        case .blankError, .passwordConfirmError: return .red
        }
    }
}

// MARK: -- Model
final class ValidationModel {
    // 引数の[String]の中に空文字があったらfalseを返す
    func blankBalidation(text: [String]) -> Bool {
        for text in text {
            if text.isEmpty {
                return false
            }
        }
        return true
    }

    // パスワードとパスワード(確認用)が一致してなかったらfalseを返す
    func passwordConfirmValidation(password: String, passwordConfirm: String) -> Bool {
        return password == passwordConfirm
    }

}

役割分担は MVP の時と変わらずですが、ViewViewModel の繋ぎ方が特殊で少し戸惑いました。RxSwift に慣れていない方は理解しづらいかもしれませんがデータバインディング、MVVM について理解するには、いいサンプルかなと思います。

最後に

これまで『MVC』『MVP』『MVVM』と勉強して、やっと違いを説明できるようになったかなと思います。納得できると面白いですね。

では、Bye