iOS
Swift
RxSwift

Rx エラーしうるオブザーバブルをflatMapする話

More than 1 year has passed since last update.

今日の課題

  • urlStream: PublishSubject<NSURL>に画像URLが流れてくる。
  • downloadImage: (NSURL) -> Observable<UIImage?>メソッドでダウンロードしてimageViewに表示させる。Optionalなのはrx_imageの型に合わせているため。
  • downloadImageは成功すると、Next(image)Completed、失敗するとErrorが流れる。
  • エラー時はimageView.rx_imagenilを突っ込む。

Rxっぽくない実装

ViewController.swift#viewDidLoad
var imageDisposeBag: DisposeBag!
urlStream
    .subscribeNext { [weak self] url in
        imageDisposeBag = DisposeBag()
        self!.downloadImage(url)
            .catchErrorJustReturn(nil)
            .bindTo(self!.imageView)
            .addDisposableTo(imageDisposeBag)
    }.addDisposableTo(disposeBag)

ついやってしまうsubscribe in subscribe。
URLが更新されるたびに前回の購読を破棄して新しい購読を行っている。
二つのライフサイクルのDisposeBagが必要なことや、ネストが深くなることが問題。

flatMapLatestを使う

とりあえず簡単のためエラー処理を外して、flatMapLatestでネストもflatにした実装。

ViewController.swift#viewDidLoad
urlStream
    .flatMapLatest(downloadImage)
    .bindTo(imageView.rx_image)
    .addDisposableTo(disposeBag)

flatMapLatestのアンサブスクライブのタイミングを誤解していたのだが、switchの項にある通り次のオブザーバブルが開始された時点で以前の購読は解除される。(flatMapLatest = map + switchLatest
takeUntilのように次のオブザーバブルの最初のNextが来た時に解除されるわけではない。

Errorしたらどうなるか

そして除去しておいたエラー処理を直感に従って付け加えたのがこちら。

ViewController.swift#viewDidLoad
urlStream
    .flatMapLatest(downloadImage)
    .catchErrorJustReturn(nil) // エラーをnilに変換
    .bindTo(imageView.rx_image)
    .addDisposableTo(disposeBag)

ためしてみると404のURLで画像が消えるところまでは正常だが、それ以降に流れてきた正常なURLを表示してくれないという状態だった。

flatMapflatMapLatestはクロージャ内で生成されたオブザーバブルにCompletedが流れても、それをまとめた出力にはCompletedが流れない。downloadImageはダウンロード終了時にNext(image)Completedを流すがCompletedは握りつぶされ、次のURLが来たらflatMapLatestswitchLatest部分の効果でまたダウンロードが始まる。

ではErrorが来たらどうなるか。今回の例はErrorが来ても画像が消えるだけで次のURLが来たらまたダウンロードするぜーという勢いだがRxではストリーム中にErrorは一つだけでそれが終了イベントでもある
Completedを握りつぶしてくれるflatMapといえどErrorが来たら出力せざるを得ず、次のオブザーバブルが生成されてもすでにErrorを出力済みなのでNextできない。
かくしてcatchErrorOnReturnnilを出力したあとは何も流れてこなくなり、現在の状況に至る。
普通のflatMapならErrorしたらNext来ないだろうと分かっただろうがflatMapLatestは次のに切り替わるというのに引っ張られて混乱していた。

正しい書き方

まずはflatMapLatestErrorを渡さないようにする書き方。

ViewController.swift#viewDidLoad
urlStream
    .flatMapLatest { [weak self] url in
        self!.downloadImage(url).catchErrorJustReturn(nil)
    }
    .bindTo(imageView.rx_image)
    .addDisposableTo(disposeBag)

catchErrorJustReturnがクロージャの内側に移動している。
Errorを先行して変換することによってflatMapLatestからはErrorが流れなくなる。

もう一つはretryを使う方法。

ViewController.swift#viewDidLoad
urlStream
    .flatMapLatest(downloadImage)
    .doOnError { [weak self] error in
        self!.imageView.rx_Image.onNext(nil)
    }
    .retry()
    .bindTo(imageView.rx_image)
    .addDisposableTo(disposeBag)

Errorは流れてくるがdoOnErrornilを反映させ、さらにretryで再度購読を行う。

幸い今回はurlStreamPublishSubjectなのでretryで再購読しても同じ値が流れないが、BehaviorSubjectだったりするとErrorになるURLを延々とretryするはめになるので、根本に影響されないという意味では前者の方が良く思える。
rx_imageへの入力が二箇所に分かれているのが気になる後者だが、Error時は画像が変更されないような仕様だとdoOnErrorが消えるのでこちらのほうが綺麗になりそう。

おまけ

catchErrorにエラーの型を指定できるようにしたExtension。

Extension.swift
extension ObservableType {
    func catchSpecifiedError<T: ErrorType>(handler: (T) throws ->Observable<E>) -> Observable<E>{
        return self.catchError { e in
            if let e = e as? T {
                return try handler(e)
            } else {
                return Observable.error(e)
            }
        }
    }
}

handlerの引数の型Tを指定してやって使う。
catchErrorのクロージャ内でエラーの型によって分岐させることは割とありそうですが、メソッドチェーンで別々に書けるほうが綺麗になるんじゃないでしょうか。


subscribeもイベントが二種類以上ならそれぞれdoOn~でメソッドチェーンにしたうえで引数なしsubscribeを呼び出すように書くのが綺麗な気がする。ただネットでこの書き方を見ないので引数ありsubscribeに何かメリットがあるのかもしれないが。