フォームに入力してボタンを押すと成功や失敗が起きるというiOS GUIから、RxSwiftを使ってデザインしたViewController用のバインディングクラスがどう振る舞うといいのか考えた
(このapp自体はどうでもいいものだけれど)
モチベーション
- 今まで書けてなかった部分のユニットテストを書きたい
- 非同期ストリーム(Observable)として検査できる
- ストリームから送られてきたデータによってUIが常に同じ結果に行き着くようにする
- とはいえプレゼンテーションの手続き部分のふるまいは保証できないけど
前提となる考え
- ViewModelはUIの世界から切り離された純粋なデータ
- UIView, Storyboard, UIViewController, ViewModel の責務は全部View(プレゼンテーション)
- ViewModelはViewなのでViewのデータを定義するだけ。業務ドメインやI/Oな世界の実装とは国交を持たない
- UIViewControllerはInitialなデータをObservableにしてViewModelへ渡す
- ViewModelは不変な入力の値から自身が出力するObservableを定義する。手続的に自身のプロパティを操作しない。
- UIViewControllerはViewModelの値を購読し、ストリーム毎に1つのプレゼンテーションのロジックの手続きを実行するのみ
- UIViewController内でプレゼンテーションの為の計算は行なわない
ViewController.swift
viewDidLoaded
RxCocoa.Drive
はUIの世界の住人なのでViewControllerで変換する。
viewModel = ViewModel(
emailText: emailField.rx.text.orEmpty.asObservable(),
passwordText: passwordField.rx.text.orEmpty.asObservable(),
submitButtonTap: submitButton.rx.tap.asObservable()
)
viewModel.nextViewPushing.asDriver(onErrorJustReturn: ())
.drive(onNext: pushToHome)
.addDisposableTo(disposeBag)
viewModel.requestError.asObservable()
.filter { $0 != nil }
.observeOn(MainScheduler.instance)
.subscribe(onNext: showError(error:))
.addDisposableTo(disposeBag)
ViewModel
依存する入力値のObservableはイニシャライザの引数やtupleで差し込む。
イニシャライザ経由ではなく PublishSubject<T>
を公開し外から bindTo
するパターンとどちらがいいのか迷った。
let nextViewPushing: Observable<Void>
let requestError = Variable<Swift.Error?>(nil)
init(
emailText: Observable<String> = Observable.empty(),
passwordText: Observable<String> = Observable.empty(),
submitButtonTap: Observable<Void> = Observable.empty(),
signIn: SignIn = SignInImpl(httpClient: URLSession.shared)
) {
let requestError = self.requestError
let fields = Observable.combineLatest(emailText, passwordText)
nextViewPushing = submitButtonTap.withLatestFrom(fields)
.flatMapLatest { statusCode, password -> Observable<String> in
return signIn
.sendRequest(email: statusCode, password: password)
.catchError { e in
requestError.value = e
return Observable.empty()
}
}
.do(onNext: { debugPrint($0) })
.map { _ in () }
}
let requestError = Variable<Swift.Error?>(nil)
はイマイチだとおもう
I/O
HTTPClientはCocoaの世界の住人なのでViewModelに直接登場させたくない。
テストコードでHTTPアクセスの結果をスタブしたいのでプロトコルで取り扱う。
protocol SignIn {
func sendRequest(email: String, password: String) -> Observable<String>
}
struct SignInImpl: SignIn {
let httpClient: URLSession
func sendRequest(email: String, password: String) -> Observable<String> {
let url = URL(string: "https://httpbin.org/status/\(email)")!
let req = URLRequest(url: url)
return httpClient.rx.data(request: req)
.map { String(data: $0, encoding: .utf8) ?? "" }
}
}
SignInTests.swift
イニシャライザで入力が完結しているので RxBlockingでテストしたいViewModelの目的の値を取り出すだけ。
時間軸のある連続的な操作を想定したテストを書くにはRxTestのTestSchedulerを使う必要が出てくるかもしれない
func testSigInSuccess() {
let viewModel = ViewModel(
emailText: Observable.just("200"),
passwordText: Observable.just("pass"),
submitButtonTap: Observable.just(),
signIn: SignInMock()
)
let tapCount = try! viewModel.nextViewPushing.toBlocking(timeout: 1).toArray()
XCTAssertEqual(tapCount.count, 1)
}
func testSigInFailure() {
let viewModel = ViewModel(
emailText: Observable.just("401"),
passwordText: Observable.just("pass"),
submitButtonTap: Observable.just(),
signIn: SignInMock()
)
viewModel.nextViewPushing.subscribe().disposed(by: self.disposeBag)
let error = viewModel.requestError.value as? NSError
XCTAssertEqual(error, SignInMock.error)
}
今後の展開
- SwiftのProtocol ExtensionやGenericを活用して異るViewControllerに設定できるViewModelを作る(Pagination等)
- RxSwift自体の学習をして使いこなす(ストリーム合成、Hot/Cold, scheduler, subject, bind, dispose, operator)
- RxSwift以外のUIデータバインディングについて調べる。Android+RxJava,RAC, XAML, JavaScript, Elm
- FluxアーキテクチャとSwiftでの実装を参考にする
- なぜかHaskell GTK+プログラミング(FRP)に目覚める
- 詳しい人がアドバイスをくれる