Swiftでいい感じのViewModelを作るためのメモ

  • 43
    Like
  • 1
    Comment

フォームに入力してボタンを押すと成功や失敗が起きるというiOS GUIから、RxSwiftを使ってデザインしたViewController用のバインディングクラスがどう振る舞うといいのか考えた

cap

(この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)に目覚める
  • 詳しい人がアドバイスをくれる