モチベーション
rxSwiftのsamplesがすごく勉強になるので、swiftの学習のためにちゃんと理解するために記事にまとめる。
入門書とか見ても書いてないであろうことを中心にまとめられることを目指します。
GitHubSignup
- 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 を用意します。
// 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)
// 入力値により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する引数に渡している
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に書いてありました。
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>
(省略)
// ユーザー名入力チェック(登録可能ユーザー名か、すでに登録済か)
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で違うんだけど?
input: (
username: usernameOutlet.rx.text.orEmpty.asObservable(),
password: passwordOutlet.rx.text.orEmpty.asObservable(),
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.flatMapLatest
とinput.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で合成しているのかな。
let usernameAndPassword = Observable.combineLatest(input.username, input.password) { ($0, $1) }
これを理解するには、playgroundのcombineLatestのソースがすごくわかりやすかったです。
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初学者にはとても読み応えのあるものでした。
他のサンプルや、完全にクリアではない部分もあるので、もう少し理解を深めていきたいと思います。