概要
本稿ではアプリアーキテクチャの1形式であるMVVMの、特にViewController
とViewModel
の関連を記述するスタイルとして関数型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
と宣言するプロトコルがやや多い。 -
Inputs
とProperty
との対応関係の記述が冗長。 - 使用されない
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に精通していることが必要とされる。
- 一つの関数内の記述量が多いため、適切に処理を切り出さないと却って見通しが悪くなる。