LoginSignup
66
43

More than 5 years have passed since last update.

RxSwiftのSamplesを読み解く <GitHubSignup編>

Posted at

モチベーション

rxSwiftのsamplesがすごく勉強になるので、swiftの学習のためにちゃんと理解するために記事にまとめる。
入門書とか見ても書いてないであろうことを中心にまとめられることを目指します。

GitHubSignup

Screen Shot 2017-03-14 at 19.39.27.png

  • GitHubにすでに存在するユーザーの場合はNameのValidateが機能する
  • パスワードの入力文字数のValidate機能する
  • GitHubのログイン(Mock)する

Sampleを通じて学んだこと

Enum Extensionのfunctionの活用方法(Swift)

enumにextensionを用意することで、enumの値を元に、新たなプロパティ(ここではisValid)を表現をしている。

今までだったら、enumの処理を利用する側のControllerにfunctionを用意してやっていたが
Enumの近くにプロパティを用意するのはソースの見通しを良くすると感じました。

enum ValidationResult {
    case ok(message: String)
    case empty
    case validating
    case failed(message: String)
}

extension ValidationResult {
    // getを省略したコンピューティドプロパティ
    var isValid: Bool {
        switch self {
        case .ok:
            return true
        default:
            return false
        }
    }
}

// CustomStringConvertibleプロトコルはstructとかenum、classの文字列の出力の形をカスタマイズしたいときに指定します。
// CustomStringConvertibleに準拠せずにdescriptionを書くことも別にできるだろうし、
// プロパティ名も別にdescriptionにする必要もマストではないと思いますが、お作法的にこのようにしたほうが良い気がします。
extension ValidationResult: CustomStringConvertible {
    var description: String {
        switch self {
        case let .ok(message):
            return message
        case .empty:
            return ""
        case .validating:
            return "validating ..."
        case let .failed(message):
            return message
        }
    }
}

extension ValidationResult {
    var textColor: UIColor {
        switch self {
        case .ok:
            return ValidationColors.okColor
        case .empty:
            return UIColor.black
        case .validating:
            return UIColor.black
        case .failed:
            return ValidationColors.errorColor
        }
    }
}

initializeの引数をグルーピングする(Swift)

初期化処理も変数が多くなると収集がつかなくなる傾向にあるけど、グループを作ることで明確化する。
わかりやすい

呼び出される側
class GithubSignupViewModel1 {
        // inputはグループ名
   init(input: (
            username: Observable<String>,
            password: Observable<String>,
            repeatedPassword: Observable<String>,
            loginTaps: Observable<Void>
        ),
        dependency: (
            API: GitHubAPI,
            validationService: GitHubValidationService,
            wireframe: Wireframe
        )
    ) {
        // グループ名.プロパティ名でアクセス
        let API = dependency.API
呼び出す側
    override func viewDidLoad() {
        super.viewDidLoad()

        let viewModel = GithubSignupViewModel1(
            // グループ名を指定することでinputとdependencyのプロパティの役割を明確にする
            input: (
                username: usernameOutlet.rx.text.orEmpty.asObservable(),
                password: passwordOutlet.rx.text.orEmpty.asObservable(),
                repeatedPassword: repeatedPasswordOutlet.rx.text.orEmpty.asObservable(),
                loginTaps: signupOutlet.rx.tap.asObservable()
            ),
            dependency: (
                API: GitHubDefaultAPI.sharedAPI,
                validationService: GitHubDefaultValidationService.sharedValidationService,
                wireframe: DefaultWireframe.sharedInstance
            )
        )

bindToを積極的に活用しよう!(RxSwift)

今までは、正直デフォルトで用意されていた場合に限りbindToを使ってましたが、
なんと拡張できるようです。

        viewModel.signupEnabled
            // ここで流れてくるvalidはBool型
            .subscribe(onNext: { [weak self] valid  in
                self?.signupOutlet.isEnabled = valid
                self?.signupOutlet.alpha = valid ? 1.0 : 0.5
            })
            .disposed(by: disposeBag)

これを書き換えてみます。

まずは、extension Reactive を用意します。

UIBindingObserver
// bindする対象がUIButtonのため、where条件で
// where Base: UIButtonとします。
// ちなみに何故Baseなのかというと
// Reactive構造体の中で、"Base object to extend."となっていたのでそうしております。
extension Reactive where Base: UIButton {
    // <Base, steamに流れてくる型ここではBool>
    var buttonIsEnable: UIBindingObserver<Base, Bool> {
        // UIBindingObserverのInitializesで、ちなみにtrail closureの形をしております。
        // Genericsで<Base:UIButton,Bool>を指定したので
        // buttonはUIButton、resultはBoolになります
        return UIBindingObserver(UIElement: base) { button, result in
            button.isEnabled = result
            button.alpha = result ? 1.0 : 0.5
        }
    }
}

UIBindingObserverが用意できたので、SteamをbindTo(subscribe)する側はすごくシンプルにかけます。
ちなみに、元のソースが、
subscribe(onNext: { [weak self] valid inとしており、selfをweakしていたのが
気になってたのですが、
そもそも、UIBindingObserverのソースの中に、
UIBindingObserver doesn't retain target interface and in case owned interface element is released, element isn't bound.
と書かれていたのでそもそもbindされるときはstrong参照していないんだなと理解しました。

viewModel.signupEnabled
            .bindTo(signupOutlet.rx.buttonIsEnable)
            .disposed(by: disposeBag)

ただし、今回みたいに、使う箇所が1箇所の場合はそもそも素直にsubscribeしたほうがSteamの流れの中でまとまるので可読性が良いなという感想をいだきました。

元のソースを見ると

        viewModel.signupEnabled
            .subscribe(onNext: { [weak self] valid  in
                self?.signupOutlet.isEnabled = valid
                self?.signupOutlet.alpha = valid ? 1.0 : 0.5
            })
            .disposed(by: disposeBag)

        // 何度もbin
        viewModel.validatedPassword
            .bindTo(passwordValidationOutlet.rx.validationResult)
            .disposed(by: disposeBag)

        viewModel.validatedPasswordRepeated
            .bindTo(repeatedPasswordValidationOutlet.rx.validationResult)
            .disposed(by: disposeBag)

        viewModel.signingIn
            .bindTo(signingUpOulet.rx.isAnimating)
            .disposed(by: disposeBag)

        viewModel.signedIn
            .subscribe(onNext: { signedIn in
                print("User signed in \(signedIn)")
            })
            .disposed(by: disposeBag)
UILabelに対してBindToするためのUIBindingObserver
// 入力値によりValidateメッセージを表示している部分
extension Reactive where Base: UILabel {
    // コンピューテッドプロパティ(セッタの省略)
    var validationResult: UIBindingObserver<Base, ValidationResult> {
        // baseはReactiveのBase object
        // labelはBaseのGenericsがUILabelを継承しているのでUILabelです。
        // resultはValidationResult型の値です。
        // ここで上でまとめたenumのextensionで宣言したコンピューテッドプロパティの
        // textColor、descriptionを利用しています。
        return UIBindingObserver(UIElement: base) { label, result in
            label.textColor = result.textColor
            label.text = result.description
        }
    }
}

のように、入力値により同じようなエラーメッセージを出している箇所が複数箇所ある場合は、ソースの可読性を上げるために活用できそうだなと思いました。

MVVMでコードの書き方(RxSwift)

http://qiita.com/shinkuFencer/items/f2651073fb71416b6cd7
にわかりやすくまとまってましたが、

ViewとViewModelを双方向にバインドして、Modelから値を取得したら自動的にBindしたViewに反映されるし、Viewの操作(テキスト入力だったり、ボタンクリックだったり)もViewModelに伝えられるようにする設計のことですね。Bindするのは、RxだとsubscribeとかbindToとかですね。

理論もそうですが、ソースをどのように書くのか学んでいきたいと思います。

ソースをざっくり見てみると、

  • Outletの要素をすべてViewModelにInitializeする引数に渡している
Controller部分
class GitHubSignupViewController1 : ViewController {
    @IBOutlet weak var usernameOutlet: UITextField!
    @IBOutlet weak var usernameValidationOutlet: UILabel!

    @IBOutlet weak var passwordOutlet: UITextField!
    @IBOutlet weak var passwordValidationOutlet: UILabel!

    @IBOutlet weak var repeatedPasswordOutlet: UITextField!
    @IBOutlet weak var repeatedPasswordValidationOutlet: UILabel!

    @IBOutlet weak var signupOutlet: UIButton!
    @IBOutlet weak var signingUpOulet: UIActivityIndicatorView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // Outletの要素をすべてViewModelにInitializeする引数に渡している
        let viewModel = GithubSignupViewModel1(
            input: (
                username: usernameOutlet.rx.text.orEmpty.asObservable(),
                password: passwordOutlet.rx.text.orEmpty.asObservable(),
                repeatedPassword: repeatedPasswordOutlet.rx.text.orEmpty.asObservable(),
                loginTaps: signupOutlet.rx.tap.asObservable()
            ),
            dependency: (
                API: GitHubDefaultAPI.sharedAPI,
                validationService: GitHubDefaultValidationService.sharedValidationService,
                wireframe: DefaultWireframe.sharedInstance
            )
        )
(省略

ViewModelのObservableをSubscribeしてどうするかは、Controller(View)で記述するが、
Steamの起点となるものは、すべてViewModelから渡されます。

テキスト入力やボタン操作などの外部入力もViewModelのinitializeにOutlet郡を渡しているので、ViewModelから伝達されます。

他のサンプルを見ても、SubjectはもちろんViewModelに書いてありました。

ViewModel
    let validatedUsername: Observable<ValidationResult>
    let validatedPassword: Observable<ValidationResult>
    let validatedPasswordRepeated: Observable<ValidationResult>

    // Is signup button enabled
    let signupEnabled: Observable<Bool>

    // Has user signed in
    let signedIn: Observable<Bool>

    // Is signing process in progress
    let signingIn: Observable<Bool>
Controller(つまりMVVMのView)
(省略
        // ユーザー名入力チェック(登録可能ユーザー名か、すでに登録済か)
        viewModel.validatedUsername
            .bindTo(usernameValidationOutlet.rx.validationResult)
            .disposed(by: disposeBag)

        // パスワード入力チェック(パスワードの長さが一定以上か)
        viewModel.validatedPassword
            .bindTo(passwordValidationOutlet.rx.validationResult)
            .disposed(by: disposeBag)

        // 再入力パスワードが一致しているかどうか
        viewModel.validatedPasswordRepeated
            .bindTo(repeatedPasswordValidationOutlet.rx.validationResult)
            .disposed(by: disposeBag)

        // ログインアクション実行
        viewModel.signingIn
            .bindTo(signingUpOulet.rx.isAnimating)
            .disposed(by: disposeBag)

        // ログイン成功時
        viewModel.signedIn
            .subscribe(onNext: { signedIn in
                print("User signed in \(signedIn)")
            })
            .disposed(by: disposeBag)
(省略

なるほど、Rxぽくプログラムで書くなら、Controllerはあくまでsubscribeするときにどうするかだけを書くと良さそうです。subscribeもbindToを活用してみるとスッキリするな。

ViewModelの中身

名前入力とパスワード入力で、opertorがmapとflatMapLatestで違うんだけど?

Controller(View)
input: (
                username: usernameOutlet.rx.text.orEmpty.asObservable(),
                password: passwordOutlet.rx.text.orEmpty.asObservable(),
ViewModel
  init(input: (
         username: Observable<String>,
         password: Observable<String>,
    (省略)

  validatedUsername = input.username
     .flatMapLatest { username in
       return validationService.validateUsername(username)
          .observeOn(MainScheduler.instance)
          .catchErrorJustReturn(.failed(message: "Error contacting server"))
     }
     .shareReplay(1)

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

なるほど、Controller側から、Obserbableが渡されて、Streamが流れて来たときの処理を記述しているようです。shareReplay(1)はvalidatedUsernameがsubscribeされるたびに何度も実行されるのを防ぐためです。どこかの記事でこれについては詳しく書いておりました。

さて、最初に見たときにあれ!?と思ったのは、
input.username.flatMapLatestinput.password.map
名前、パスワード入力で同じようなものなのに、それに続くなのに、operatorが違う!!

ソースを見ていたら、原因は、GitHubValidationServiceにありました。

protocol GitHubValidationService {
    func validateUsername(_ username: String) -> Observable<ValidationResult>
    func validatePassword(_ password: String) -> ValidationResult
    func validateRepeatedPassword(_ password: String, repeatedPassword: String) -> ValidationResult
}

validateUsernameとvalidatePasswordで戻り値が違いますね。validateUsernameはObservableの形で返却されるので、flatMapをしないと、Observableの中にObservableという入れ子になりますね。(コンパイルエラーになります)

パスワードの方は、

validatedPassword = input.password
    // mapにより、Observable<String>→Observable<ValidationResult>に変換しています。
    .map { password in
        return validationService.validatePassword(password)
    }
    .shareReplay(1)

一方、名前の方は、

  validatedUsername = input.username
     // 1. Observable<String>からflatMapLatestで、Stringを抜き出してuserNameに渡します。
     .flatMapLatest { username in
       // 2. validateUsername(username)の戻りの方が、Observable<ValidationResult>なので、結果、Observable<String>→Observable<ValidationResult>に変換されます。
       return validationService.validateUsername(username)
          .observeOn(MainScheduler.instance)
          .catchErrorJustReturn(.failed(message: "Error contacting server"))
     }
     .shareReplay(1)

flatMapLatestて何?

ちなみに、flatMapLatestのソースに、"It is a combination of map + switchLatest operator"と書かれていて、mapはObservableの型を変換するもので良いとして、switchLatestはなんだろう?

switchLatestは、オフィシャルの図がわかりやすく、
http://reactivex.io/documentation/operators/switch.html
つまりは、Observableが複数流れて来る場合に、その新しいObservableのものだけを採用するよて理解をしました。(上記のサンプルFlatMapと結果変わらなそうだけど)

switchLatestは、説明に、
Transforms the elements emitted by an Observable sequence into Observable sequences, and emits elements from the most recent inner Observable sequence
と書いてあり、inner Observableという記述があり、つまり入れ子になったObservableの中身のものを取り出してemitするということ、

それで、flatMapLatest = map(Observableの形を変換)+switchLatest(最新のinnerObservable)で求めたFlatなObservableが作り出せるという理解をしました。

combineLatest

名前からして、combineで合成しているのかな。

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

これを理解するには、playgroundのcombineLatestのソースがすごくわかりやすかったです。

playground
let disposeBag = DisposeBag()

    let stringSubject = PublishSubject<String>()
    let intSubject = PublishSubject<Int>()

    Observable.combineLatest(stringSubject, intSubject) { stringElement, intElement in
            "\(stringElement) \(intElement)"
        }
        .subscribe(onNext: { print($0) })
        .disposed(by: disposeBag)

    stringSubject.onNext("🅰️")

    stringSubject.onNext("🅱️")
    intSubject.onNext(1)

    intSubject.onNext(2)

    stringSubject.onNext("🆎")

// output
// 🅱️ 1
// 🅱️ 2
// 🆎 2

なるほど2つのObservable(ここではSubject)を合成するものだな。zipと合成は似てる気がする。
ABの結果みるとわかるように、合成するものは常に最新の対となるものを合成材料にしている。

ソースの説明にも、
Merges the specified observable sequences into one observable sequence by using the selector function whenever any of the observable sequences produces an element.
複数の指定したobservableを一つのobservableに合成していると書いてある。
挙動と説明理解しました。selector functionは合成する形を指定するものですね。

説明を見ると、今までの解説についてObservable Sequenceと書いたほうが正しいけど、長いのでObservableと書いてる。

さて、

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

はObservableを一つに合成するものだと理解できました。

signedIn = input.loginTaps.withLatestFrom(usernameAndPassword)
            .flatMapLatest { (username, password) in
                return API.signup(username, password: password)
                    .observeOn(MainScheduler.instance)
                    .catchErrorJustReturn(false)
                    .trackActivity(signingIn)
            }
            .flatMapLatest { loggedIn -> Observable<Bool> 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
                    }
            }
            .shareReplay(1)

色々読み解く要素がつまっている。
まずは、
swift
signedIn = input.loginTaps.withLatestFrom(usernameAndPassword)
.flatMapLatest { (username, password) in
return API.signup(username, password: password)
.observeOn(MainScheduler.instance)
.catchErrorJustReturn(false)
.trackActivity(signingIn)

withLatestFromとは?

Merges two observable sequences into one observable sequence by using latest element from the second sequence every time when self emitts an element.
と書いてありました。2つのObservableを合成して一つにするためのものですね。

うーーむ。combineLatestで合成していたはずだが、ひととまず先に行きます。

flatMapLatestがまた出てきましたね。

protocol GitHubAPI {
    func usernameAvailable(_ username: String) -> Observable<Bool>
    func signup(_ username: String, password: String) -> Observable<Bool>
}

API.signupがObservableを返却するので、ObservableにObservableの入れ子状態にならないようにFlatにしてます。

trackActivity(signingIn)については気になるところなのですが、解析できていないのでひとまず飛ばします。どうもActivityIndicatorを制御しているところのようです。


signedIn = input.loginTaps.withLatestFrom(usernameAndPassword)
            .flatMapLatest { (username, password) in
                return API.signup(username, password: password)
                    .observeOn(MainScheduler.instance)
                    .catchErrorJustReturn(false)
                    .trackActivity(signingIn)
            }
            .flatMapLatest { loggedIn -> Observable<Bool> in
                let message = loggedIn ? "Mock: Signed in to GitHub." : "Mock: Sign in to GitHub failed"
                // alertViewのframeを作成する。
                return wireframe.promptFor(message, cancelAction: "OK", actions: [])
                    // propagate original value
                    .map { _ in
                        // mapで最終的にはlogin成功か失敗のObservable<Bool>の形で返却
                        loggedIn
                    }
            }
            .shareReplay(1)

すごいですね。ActivityIndicatorの表示とAlertViewの表示も一つのStreamで操作することができて。
signedInには、Observableの形でStreamが流れて、Controller側<View>は、以下のように
ログイン成功したら、ログだけを出力しております。何もやることないんですねw

viewModel.signedIn
     .subscribe(onNext: { signedIn in
         print("User signed in \(signedIn)")
     })
     .disposed(by: disposeBag)

もう少しpromotForを掘り下げてソースを見ると、

最終的にはObservable<Action>を返却されていることがわかります

func promptFor<Action : CustomStringConvertible>(_ message: String, cancelAction: Action, actions: [Action]) -> Observable<Action> {
        #if os(iOS)
        return Observable.create { observer in
            let alertView = UIAlertController(title: "RxExample", message: message, preferredStyle: .alert)
            alertView.addAction(UIAlertAction(title: cancelAction.description, style: .cancel) { _ in
                observer.on(.next(cancelAction))
            })

            for action in actions {
                alertView.addAction(UIAlertAction(title: action.description, style: .default) { _ in
                    observer.on(.next(action))
                })
            }

            DefaultWireframe.rootViewController().present(alertView, animated: true, completion: nil)

            return Disposables.create {
                alertView.dismiss(animated:false, completion: nil)
            }
        }
        #elseif os(macOS)
            return Observable.error(NSError(domain: "Unimplemented", code: -1, userInfo: nil))
        #endif
    }

ここでは、Observable.createでObservableを新しく作成していて、

for action in actions {
                alertView.addAction(UIAlertAction(title: action.description, style: .default) { _ in
                    observer.on(.next(action))
                })
            }

でAlertViewの何らかのボタンが押された場合に、Observerにnextでイベントが渡って、Streamが流れて行ってます。なので、AlertViewのボタンが押されるまでStreamはこの場所で待機しています。
ボタンが押されれると晴れて、Controller側のSubscribeが実行されます。

ログインボタンの活性・非活性の制御処理を見てみます。

   validatedPasswordRepeated = Observable.combineLatest(input.password, input.repeatedPassword, resultSelector: validationService.validateRepeatedPassword)
            .shareReplay(1)

またcombineLatestが出てきていますね。Observableの合成をしていますね。
ここでは、input.password、input.repeatedPasswordは普通のStringですがそれからObservableを合成しているは上記の例とは違うかなと思いました。
selectorがprotocolで宣言したfuncですね。funcの実体は記述されていませんね

protocol GitHubValidationService {
    func validateRepeatedPassword(_ password: String, repeatedPassword: String) -> ValidationResult
}

もう少し深く見てみると、こちらのProtocolの実体は、実は、Controller側で指定しておりました。

dependency: (
                API: GitHubDefaultAPI.sharedAPI,
                validationService: GitHubDefaultValidationService.sharedValidationService,  // これですね。
                wireframe: DefaultWireframe.sharedInstance
            )

なるほどそうなると、以下のようになり、combineでObservableの形に変換して合成していますね。

func validateRepeatedPassword(_ password: String, repeatedPassword: String) -> ValidationResult {
        if repeatedPassword.characters.count == 0 {
            return .empty
        }

        if repeatedPassword == password {
            return .ok(message: "Password repeated")
        }
        else {
            return .failed(message: "Password different")
        }
    }

さて、上のvalidatedPasswordRepeatedに加えて、4つのObservableをcombineLatestで再び合成していますね。

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

またちょっと形が変わってますが、これはTrail Closureの形をしていますね。Slectorの部分が、

{ username, password, repeatPassword, signingIn in
                username.isValid &&
                password.isValid &&
                repeatPassword.isValid &&
                !signingIn
            }

となって一つのObservableにしていますね。

さらにController側ではこれを単純に購読してボタンの透明度と活性・非活性を制御していました。

        viewModel.signupEnabled
            .subscribe(onNext: { [weak self] valid  in
                self?.signupOutlet.isEnabled = valid
                self?.signupOutlet.alpha = valid ? 1.0 : 0.5
            })
            .disposed(by: disposeBag)

感想

サンプルソース自体は非常に短かったのですが、
Rx初学者にはとても読み応えのあるものでした。
他のサンプルや、完全にクリアではない部分もあるので、もう少し理解を深めていきたいと思います。

66
43
0

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
66
43