看看红太阳国家的 RxSwift 示例,经典

1,393 阅读2分钟
原文链接: qiita.com

この投稿がQiitaデビューです*\(^o^)/*

RxSwiftのExamplesを読み解いてみました。

RxSwiftのレポジトリにある Why.mdGettingStarted.md は読んでみた。ストリームというのも読んで恐らく分かった。Qiitaの関係ある記事もちょくちょく読んでみた。次はiOSプロジェクトに活かそうと思っているが取っ掛かりが掴めないという人向け。(自分)

Adding Numbers

add_num.gif

NumbersViewController.swift


Observable.combineLatest(number1.rx_text, number2.rx_text, number3.rx_text) { textValue1, textValue2, textValue3 -> Int in
                return (Int(textValue1) ?? 0) + (Int(textValue2) ?? 0) + (Int(textValue3) ?? 0)
            }
            .map { $0.description }
            .bindTo(result.rx_text)
            .addDisposableTo(disposeBag)

各UITextFieldの値が変更される度に、 combineLatest の中で3つのUITextFieldに入力されているテキストをIntに変換し、足し算して、return。その結果を map で String をとして返して、結果表示用のUILabelにバインドするだけで、UILabelが更新される!

Simple Validation

validation.gif

SimpleValidationViewController.swift


let usernameValid = usernameOutlet.rx_text
    .map { $0.characters.count >= minimalUsernameLength }
    .shareReplay(1) // (1

let passwordValid = passwordOutlet.rx_text
    .map { $0.characters.count >= minimalPasswordLength }
    .shareReplay(1)

let everythingValid = Observable.combineLatest(usernameValid, passwordValid) { $0 && $1 }
    .shareReplay(1)

usernameValid
    // パスワードフィールドの検証が真の時に, 有効になる
    .bindTo(passwordOutlet.rx_enabled)
    .addDisposableTo(disposeBag)

usernameValid
    // ユーザー名フィールドの検証が真の時に, 非表示になる
    .bindTo(usernameValidOutlet.rx_hidden)
    .addDisposableTo(disposeBag)

passwordValid
    // パスワードフィールドの検証が真の時に, 非表示になる
    .bindTo(passwordValidOutlet.rx_hidden)
    .addDisposableTo(disposeBag)

everythingValid
    // usernameValid と passwordValid が共に真の場合に `doSomethingOutlet(UIButton)` が有効になる
    .bindTo(doSomethingOutlet.rx_enabled)
    .addDisposableTo(disposeBag)

doSomethingOutlet.rx_tap
    // タップされた際に、`showAlert()`を呼ぶ
    .subscribeNext { [weak self] in self?.showAlert() }
    .addDisposableTo(disposeBag)

検証ロジックをユーザー名とパスワード用に定義し、それらを各コントロールにbind。検証結果により、UIButtonを有効にしたり、注意書きのUILabelを非表示にしたりしている。

shareReplay(1)というのは、これがないとストリームが複数生成され、mapの中がUITextFieldの値を変更する度に、複数回実行されてしまう。詳しくは参考であげた記事が分かりやすいです。

Geolocation Subscription

GeolocationViewController.swift


let geolocationService = GeolocationService.instance
geolocationService.authorized
    .drive(noGeolocationView.rx_driveAuthorization)
    .addDisposableTo(disposeBag)

geolocationService.location
    .drive(label.rx_driveCoordinates)
    .addDisposableTo(disposeBag)

button.rx_tap
    .bindNext { [weak self] in
        self?.openAppPreferences()
    }
    .addDisposableTo(disposeBag)

button2.rx_tap
    .bindNext { [weak self] in
        self?.openAppPreferences()
    }
    .addDisposableTo(disposeBag)

GeolocationService というのは、位置情報に関する便利クラス。 .authorized では、位置情報の許可を取得しているか判定し、Boolで返してくれる。また、.locationでは、位置情報が変更された時に呼び出されて、CLLocationCoordinate2Dで返してくれる。

bindTo の代わりに drive を使い、メインスレッドで結果を受け取るようにして、エラーも吐き出さないようにしている。Driveに関しては、本家の Unit が分かりやすい。

GeolocationViewController.swift


private extension UILabel {
    var rx_driveCoordinates: AnyObserver {
        return UIBindingObserver(UIElement: self) { label, location in
            label.text = "Lat: \(location.latitude)\nLon: \(location.longitude)"
        }.asObserver()
    }
}

private extension UIView {
    var rx_driveAuthorization: AnyObserver {
        return UIBindingObserver(UIElement: self) { view, authorized in
            if authorized {
                view.hidden = true
                view.superview?.sendSubviewToBack(view)
            }
            else {
                view.hidden = false
                view.superview?.bringSubviewToFront(view)
            }
        }.asObserver()
    }
}

この例では、カスタムObserver( rx_*** )の作成仕方を学べる。 rx_driveAuthorization では、Boolを渡すようにして、その値により、UIViewのhiddenを切り替えたり、z軸を変更している。 rx_driveCoordinates の方は、CLLocationCoordinate2D を結びつけその値により、UILabelのtextを変更している。

GitHub Signup - Vanilla Observables

github.gif

GitHubSignupViewController1.swift


let viewModel = GithubSignupViewModel1(
    input: (
        username: usernameOutlet.rx_text.asObservable(),
        password: passwordOutlet.rx_text.asObservable(),
        repeatedPassword: repeatedPasswordOutlet.rx_text.asObservable(),
        loginTaps: signupOutlet.rx_tap.asObservable()
    ),
    dependency: (
        API: GitHubDefaultAPI.sharedAPI,
        validationService: GitHubDefaultValidationService.sharedValidationService,
        wireframe: DefaultWireframe.sharedInstance
    )
)

// bind results to  {
viewModel.signupEnabled
    .subscribeNext { [weak self] valid  in
        self?.signupOutlet.enabled = valid
        self?.signupOutlet.alpha = valid ? 1.0 : 0.5
    }
    .addDisposableTo(disposeBag)

viewModel.validatedUsername
    .bindTo(usernameValidationOutlet.ex_validationResult)
    .addDisposableTo(disposeBag)

viewModel.validatedPassword
    .bindTo(passwordValidationOutlet.ex_validationResult)
    .addDisposableTo(disposeBag)

viewModel.validatedPasswordRepeated
    .bindTo(repeatedPasswordValidationOutlet.ex_validationResult)
    .addDisposableTo(disposeBag)

viewModel.signingIn
    .bindTo(signingUpOulet.rx_animating)
    .addDisposableTo(disposeBag)

viewModel.signedIn
    .subscribeNext { signedIn in
        print("User signed in \(signedIn)")
    }
    .addDisposableTo(disposeBag)
//}

MVVMパターンの実装例。検証ロジックなどは、GithubSignupViewModel1の中に入っている。GithubSignupViewModel1を初期化する際に、ObserveするUITextFieldや検証するためのサービスを渡している。ViewControllerの中では、ValidationResultによってテキストやテキスト色を変えるとうことをbindしているだけ。

GitHub Signup - Using Driver

GithubSignupViewModel2.swift


class GithubSignupViewModel2 {
    // outputs {

    //
    let validatedUsername: Driver
    let validatedPassword: Driver
    let validatedPasswordRepeated: Driver

    // Is signup button enabled
    let signupEnabled: Driver

    // Has user signed in
    let signedIn: Driver

    // Is signing process in progress
    let signingIn: Driver

    // }

    init(
        input: (
            username: Driver,
            password: Driver,
            repeatedPassword: Driver,
            loginTaps: Driver
        ),
        dependency: (
            API: GitHubAPI,
            validationService: GitHubValidationService,
            wireframe: Wireframe
        )
    ) {
        let API = dependency.API
        let validationService = dependency.validationService
        let wireframe = dependency.wireframe

        validatedUsername = input.username
            .flatMapLatest { username in
                return validationService.validateUsername(username)
                    .asDriver(onErrorJustReturn: .Failed(message: "Error contacting server"))
            }

        validatedPassword = input.password
            .map { password in
                return validationService.validatePassword(password)
            }

        validatedPasswordRepeated = Driver.combineLatest(input.password, input.repeatedPassword, resultSelector: validationService.validateRepeatedPassword)

        let signingIn = ActivityIndicator()
        self.signingIn = signingIn.asDriver()

        let usernameAndPassword = Driver.combineLatest(input.username, input.password) { ($0, $1) }

        signedIn = input.loginTaps.withLatestFrom(usernameAndPassword)
            .flatMapLatest { (username, password) in
                return API.signup(username, password: password)
                    .trackActivity(signingIn)
                    .asDriver(onErrorJustReturn: false)
            }
            .flatMapLatest { loggedIn -> Driver in
                let message = loggedIn ? "Mock: Signed in to GitHub." : "Mock: Sign in to GitHub failed"
                return wireframe.promptFor(message, cancelAction: "OK", actions: [])
                    // propagate original value
                    .map { _ in
                        loggedIn
                    }
                    .asDriver(onErrorJustReturn: false)
            }


        signupEnabled = Driver.combineLatest(
            validatedUsername,
            validatedPassword,
            validatedPasswordRepeated,
            signingIn
        )   { username, password, repeatPassword, signingIn in
                username.isValid &&
                password.isValid &&
                repeatPassword.isValid &&
                !signingIn
            }
            .distinctUntilChanged()
    }
}

GitHub Signup - Vanilla ObservablesのDriverで実装したバージョン。ViewControllerは基本同じなので(driverの箇所以外)、ViewModelの中を見てみる。

GithubSignupViewModel2では、大きく分けてinputとdependencyという引数を渡して初期化する。inputは、ObserveするUITextField等。dependencyでは、APIとの通信・検証機能・ポップアップを表示するサービス群を渡しています。こういったサービス群をキレイに分割して、保守性の高いコードになっています。

API wrappers

APIWrappersViewController.swift


override func viewDidLoad() {
  super.viewDidLoad()

  datePicker.date = NSDate(timeIntervalSince1970: 0)

  // MARK: UIBarButtonItem

  bbitem.rx_tap
      .subscribeNext { [weak self] x in
          self?.debug("UIBarButtonItem Tapped")
      }
      .addDisposableTo(disposeBag)

  // MARK: UISegmentedControl

  // also test two way binding
  let segmentedValue = Variable(0)
  segmentedControl.rx_value <-> segmentedValue

  segmentedValue.asObservable()
      .subscribeNext { [weak self] x in
          self?.debug("UISegmentedControl value \(x)")
      }
      .addDisposableTo(disposeBag)


  // MARK: UISwitch

  // also test two way binding
  let switchValue = Variable(true)
  switcher.rx_value <-> switchValue

  switchValue.asObservable()
      .subscribeNext { [weak self] x in
          self?.debug("UISwitch value \(x)")
      }
      .addDisposableTo(disposeBag)

  // MARK: UIActivityIndicatorView

  switcher.rx_value
      .bindTo(activityIndicator.rx_animating)
      .addDisposableTo(disposeBag)


  // MARK: UIButton

  button.rx_tap
      .subscribeNext { [weak self] x in
          self?.debug("UIButton Tapped")
      }
      .addDisposableTo(disposeBag)


  // MARK: UISlider

  // also test two way binding
  let sliderValue = Variable(1.0)
  slider.rx_value <-> sliderValue

  sliderValue.asObservable()
      .subscribeNext { [weak self] x in
          self?.debug("UISlider value \(x)")
      }
      .addDisposableTo(disposeBag)


  // MARK: UIDatePicker

  // also test two way binding
  let dateValue = Variable(NSDate(timeIntervalSince1970: 0))
  datePicker.rx_date <-> dateValue


  dateValue.asObservable()
      .subscribeNext { [weak self] x in
          self?.debug("UIDatePicker date \(x)")
      }
      .addDisposableTo(disposeBag)


  // MARK: UITextField

  // also test two way binding
  let textValue = Variable("")
  textField <-> textValue

  textValue.asObservable()
      .subscribeNext { [weak self] x in
          self?.debug("UITextField text \(x)")
      }
      .addDisposableTo(disposeBag)


  // MARK: UIGestureRecognizer

  mypan.rx_event
      .subscribeNext { [weak self] x in
          self?.debug("UIGestureRecognizer event \(x.state)")
      }
      .addDisposableTo(disposeBag)


  // MARK: UITextView

  // also test two way binding
  let textViewValue = Variable("")
  textView <-> textViewValue

  textViewValue.asObservable()
      .subscribeNext { [weak self] x in
          self?.debug("UITextView text \(x)")
      }
      .addDisposableTo(disposeBag)

  // MARK: CLLocationManager

  #if !RX_NO_MODULE
  manager.requestWhenInUseAuthorization()
  #endif

  manager.rx_didUpdateLocations
      .subscribeNext { x in
          print("rx_didUpdateLocations \(x)")
      }
      .addDisposableTo(disposeBag)

  _ = manager.rx_didFailWithError
      .subscribeNext { x in
          print("rx_didFailWithError \(x)")
      }

  manager.rx_didChangeAuthorizationStatus
      .subscribeNext { status in
          print("Authorization status \(status)")
      }
      .addDisposableTo(disposeBag)

  manager.startUpdatingLocation()
}

この例では、UIBarButtonItemUISegmentedControlUIDatePickerUISwitchUIActivityIndicatorViewUIButtonUISliderUIDatePickerUITextFieldUIGestureRecognizerUITextViewCLLocationManager といった様々なパーツに対してRxSwiftの使い方が分かります。

このサンプルでのポイントは、RxSwift独自の機能であるVariableだと思います。これは、BehaviorSubjectというものの薄いラッパーのようです。BehaviorSubjectは、subscribeした途端、イベントを送信し初期値を設定することができます。上記の例のVariableでは、各コントロールに対して、それぞれ初期値を与えています。UITextViewには、let textViewValue = Variable("")といった感じです。

また、textView <-> textViewValueとして、双方向バインディング?というものをしているようです。これにより、textViewValue.value = "hogehoge"とすることにより、UITextViewの表示も変更されます。

Calculator

cal.gif

CalculatorViewController.swift


override func viewDidLoad() {
let commands:[Observable] = [
    allClearButton.rx_tap.map { _ in .Clear },

    changeSignButton.rx_tap.map { _ in .ChangeSign },
    percentButton.rx_tap.map { _ in .Percent },

    divideButton.rx_tap.map { _ in .Operation(.Division) },
    multiplyButton.rx_tap.map { _ in .Operation(.Multiplication) },
    minusButton.rx_tap.map { _ in .Operation(.Subtraction) },
    plusButton.rx_tap.map { _ in .Operation(.Addition) },

    equalButton.rx_tap.map { _ in .Equal },

    dotButton.rx_tap.map { _ in .AddDot },

    zeroButton.rx_tap.map { _ in .AddNumber("0") },
    oneButton.rx_tap.map { _ in .AddNumber("1") },
    twoButton.rx_tap.map { _ in .AddNumber("2") },
    threeButton.rx_tap.map { _ in .AddNumber("3") },
    fourButton.rx_tap.map { _ in .AddNumber("4") },
    fiveButton.rx_tap.map { _ in .AddNumber("5") },
    sixButton.rx_tap.map { _ in .AddNumber("6") },
    sevenButton.rx_tap.map { _ in .AddNumber("7") },
    eightButton.rx_tap.map { _ in .AddNumber("8") },
    nineButton.rx_tap.map { _ in .AddNumber("9") }
]

commands
    .toObservable()
    .merge()
    .scan(CalculatorState.CLEAR_STATE) { a, x in
        // ここで、CalculatorStateに変換している
        return a.tranformState(x)
    }
    .debug("debugging")
    .subscribeNext { [weak self] calState in
        self?.resultLabel.text = self?.prettyFormat(calState.inScreen)
        switch calState.action {
        case .Operation(let operation):
            switch operation {
            case .Addition:
                self?.lastSignLabel.text = "+"
            case .Subtraction:
                self?.lastSignLabel.text = "-"
            case .Multiplication:
                self?.lastSignLabel.text = "x"
            case .Division:
                self?.lastSignLabel.text = "/"
            }
        default:
            self?.lastSignLabel.text = ""
        }
    }
    .addDisposableTo(disposeBag)
}

この計算機の例では、Observableの配列をmergeして、1つのストリームとして扱います。そして、scanという前の値をストリームで扱うものを使用します。Accumulatorとして、CalculatorState.CLEAR_STATEから始まり、計算機のボタンを押す度にこれを更新していきます。また、debugをはさみ都度結果をコンソールに出力。最後に、subscribeNextの中で、計算機上のUILabelを更新しています。このような計算機をViewControllerの中では、特に結果等を保持せずともほぼ上記のコードで実現してしまうのはすごいですね。

参考

RxSwift/Units.md at master · ReactiveX/RxSwift
github.com/ReactiveX/R…

RxSwift/Why.md at master · ReactiveX/RxSwift
github.com/ReactiveX/R…

RxSwift/GettingStarted.md at master · ReactiveX/RxSwift
github.com/ReactiveX/R…

[RxSwift] shareReplayをちゃんと書いてお行儀良くストリームを購読しよう
qiita.com/kazu0620/it…

オブザーバーパターンから始めるRxSwift入門
qiita.com/k5n/items/1…