64
76

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-04-08

フォームに入力してボタンを押すと成功や失敗が起きるという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)に目覚める
  • 詳しい人がアドバイスをくれる
64
76
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
64
76

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?