【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をつなぐのにデータバインディングという方法を用います。
データバインディングとは
下の図のように、一方のコンポーネントがもう一つを監視することで、手続的な処理を経なくても、データを自動で更新できる方法です。
双方に監視しあうデータバインディングも存在しますが、今回は View
→ ViewModel
の単方向のバインディングで説明します。
MVVM の特徴
MVVM におけるそれぞれのコンポーネントの役割をまとめると以下のようになります。
- Model: 各種ビジネスロジックのかたまり
- View: 画面の描画、入力の受付
- ViewModel: Model と View の仲介役であり、プレゼンテーションロジックを担う
冒頭で、MVVM は MVP のデータバインディングを用いたバージョンだと書いたようにそれぞれのコンポーネントの役割はほぼ MVP と同じです。違いは View
→ Presenter
/ViewModel
の繋ぎ方でしょう。データバインディングを用いることで、ViewModel
が View
の参照を持つ必要がなくなりより疎結合になったと言えます。また、RxSwift や ReactiveSwift などのデータバインディングに向いているライブラリが存在するのも、MVVM が使われることが多い理由にもなっています。
まとめると
- Model: 各種ビジネスロジックのかたまり
- View: 画面の描画、入力の受付
- ViewModel: Model と View の仲介役であり、プレゼンテーションロジックを担う
データバインディングを用いてView → ViewModel間をつなぐことで、MVP のときよりも疎結合になり、テストや作業分担がしやすくなっています。MVVM の概要は以上です。
サンプルアプリ
以上の MVVM の考え方を用いてよくある登録フォームのような簡単なアプリを作ってみました。空欄があったり、パスワードとパスワード(確認用)が一致していないと警告が出て、登録ボタンが押せないようになっています。今回のサンプルアプリでは『RxSwift』というライブラリを使ってデータバインディングを実装しています。
ソースコード
https://github.com/yukendev/sampleMVVM
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)
}
}
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
}
}
}
}
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 の時と変わらずですが、View
→ ViewModel
の繋ぎ方が特殊で少し戸惑いました。RxSwift に慣れていない方は理解しづらいかもしれませんがデータバインディング、MVVM について理解するには、いいサンプルかなと思います。
最後に
これまで『MVC』『MVP』『MVVM』と勉強して、やっと違いを説明できるようになったかなと思います。納得できると面白いですね。
では、Bye