RxSwift で値を非同期で得るときに Loading を簡単に扱えるようにする Extension


概要

例えば以下のようなコードが合ったとします.


// 例えば Model 層にこんなコードがあって

/// fetchContent を呼び出したあとしばらくすると非同期で content が得られる.
var content = Observable<Content>
func fetchContent()

/// ViewModel 側で使う.

content.subscribe(/*なんか表示*/).disposed(by: disposeBag)

ここで ViewModel 側で content の Loading の状態を扱いたくなったとします.

content.subscribe(/*なんか表示*/).disposed(by: disposeBag)
loading.subscribe(/*ローディング表示*/).disposed(by: disposeBag) // こうしたくなった

そんなときは以下のようにする場合があると思います.

// 1. loading を監視する property を増やす. ↑の "こうしたくなった" の方法を実現するやつ.
var loading: Observable<Bool> 

// or

// 2. Loading を識別できる enum みたいなので包む
var content = Observable<Loading<Content>>

こういった方法を使うとそれぞれ以下の様の弊害が考えられます.

  1. の場合は新しい property が増えるのでコードが煩雑になる. API ごとに loadingHoge みたいの増えると地獄になりそう.

  2. の場合は外部との interface の型が変わってしまうので影響範囲が広くて微妙です. Loading に関心がない場所でも Loading のことを知らないといけなくなります. プログラミングするのに必要な知識が増えるのはゴメンです.

なので

のようなコードを書きました. これを使うと先程のコードは以下のように書けます.

let loadingcontent = content.loadingContent()
loadingcontent.content.subscribe(/*なんか表示*/).disposed(by: disposeBag)
loadingcontent.loading.subscribe(/*ローディング表示*/).disposed(by: disposeBag) // こうしたくなった

何が良いのかというと、 extension で表現しているのでもとの content には全く影響を与えずに書けることです.

ただしこれは fetch を何回も呼ぶような場面(引っ張り更新があるなど)では使えません. でも最近のアプリは勝手に更新される UI が多いと思うので初回のローディングだけにしか関心がないことが多いので十分使えると思います.

Gist にあるのと同じコード

import Foundation
import RxSwift

protocol AsyncLoadingContentType {
    associatedtype ElementType
    var loading: Bool { get }
    var content: ElementType? { get }
    var error: Error? { get }
}

enum AsyncLoadingContent<ElementType>: AsyncLoadingContentType {
    case loading
    case content(ElementType)
    case error(Error)

    var loading: Bool {
        if case .loading = self {
            return true
        }
        return false
    }

    var content: ElementType? {
        if case let .content(content) = self { return content }
        return nil
    }

    var error: Error? {
        if case let .error(e) = self { return e }
        return nil
    }
}

extension ObservableType {
    /**
     Add loading status until first next.
         let loadingContnt = someAsyncLoadingObservable().loadingContent()
         loadingContnt.loading.subscribe( /* some thing*/ );
         loadingContnt.content.subscribe( /* some thing*/ );
         loadingContnt.error.subscribe( /* some thing*/ );
     */
    func loadingContent() -> Observable<AsyncLoadingContent<Element>> {
        materialize().compactMap {
            switch $0 {
            case let .error(error):
                return AsyncLoadingContent.error(error)
            case let .next(elem):
                return AsyncLoadingContent.content(elem)
            case .completed:
                return nil
            }
        }.startWith(AsyncLoadingContent.loading)
    }
}

extension Observable where Element: AsyncLoadingContentType {
    var loading: Observable<Bool> {
        map { $0.loading }
    }

    var content: Observable<Element.ElementType> {
        compactMap { $0.content }
    }

    var error: Observable<Error> {
        compactMap { $0.error }
    }
}