Swift로 좋은 느낌의 ViewModel을 만드는 메모

16235 단어 iOSSwiftRxSwift
폼에 입력해 버튼을 누르면 성공이나 실패가 일어난다고 하는 iOS GUI로부터, RxSwift를 사용해 디자인한 ViewController용의 바인딩 클래스가 어떻게 행동하면 좋을까 생각했다



(이 앱 자체는 아무래도 좋은 것이지만)

동기 부여


  • 지금까지 쓸 수 없었던 부분의 유닛 테스트를 쓰고 싶다
  • 비동기 스트림으로 확인할 수 있습니다
  • 스트림로부터 보내진 데이터에 의해 UI가 항상 같은 결과에 도착하게 한다
  • 그러나 프레젠테이션 절차 부분의 행동은 보장 할 수 없지만


  • 전제가 되는 생각


  • ViewModel은 UI의 세계에서 분리 된 순수한 데이터입니다.
  • UIView, Storyboard, UIViewController, ViewModel의 책임은 모두 View(프레젠테이션)
  • ViewModel은 View이므로 View의 데이터를 정의하기 만하면됩니다. 비즈니스 도메인 및 I/O 세계의 구현과는 국교가 없습니다
  • UIViewController는 Initial 데이터를 Observable로 해 ViewModel에 건네준다
  • ViewModel은 불변의 입력의 값으로부터 자신이 출력하는 Observable를 정의한다. 절차적으로 자신의 속성을 조작하지 않습니다.
  • UIViewController는 ViewModel 값을 구독하고 스트림 당 하나의 프레젠테이션 로직 절차를 수행합니다.
  • 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)에 깨어
  • 자세한 사람이 조언을 준다
  • 좋은 웹페이지 즐겨찾기