RxSwift学びの過程〜エラーハンドリング編〜


背景

最近はエラーハンドリングしなきゃしなきゃ病からenumErrorをやたら分けたようと多用しています。その過程でつまづいたところがあったので記します。
知識レベルのメンテナンスというか、しばらく触れずに忘れてしまうとまた取り返しがつかなくなってしまいますのでアプリを丸っとRxSwiftで実装して、たまに覗いてメンテしてます。

サンプルコード

内容は、テキストフィールドの入力が確認できればUIButtonが有効化するというもの。
また、入力がない場合は無効化するというRxの THE 使い所 な部分

view

    private lazy var viewMdoel = ViewModel(
        itemLabelObservable: itemTextField.rx.text.asObservable(),
        detailLabelObservable: detailTextField.rx.text.asObservable(),
        tagLabelObservable: tagTextField.rx.text.asObservable(),
        memoLabelObservable: memoTextView.rx.text.asObservable(),
        addButtonTapped: addButton.rx.tap.asObservable(),
        validation: Validation()
    )

    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        bindingViewModel()
    }

    func bindingViewModel() {
        itemTextField.rx.text
            .bind(to: viewMdoel.itemLabel)
            .disposed(by: disposeBag)

        detailTextField.rx.text
            .bind(to: viewMdoel.detailLabel)
            .disposed(by: disposeBag)

        tagTextField.rx.text
            .bind(to: viewMdoel.tagLabel)
            .disposed(by: disposeBag)

        viewMdoel.isEnableButton
            .bind(to: addButton.rx.isEnabled)
            .disposed(by: disposeBag)

        addButton.rx.tap
            .subscribe(onNext: { _ in
                self.viewMdoel.addItem()
            })
            .disposed(by: disposeBag)   
    }
}

viewModel

final class AddTabViewModel {

    let validatedItem: Observable<Bool>
    let validatedDetail: Observable<Bool>
    let validatedTag: Observable<Bool>
    let isEnableButton: Observable<Bool>
    let itemLabel = BehaviorRelay<String?>(value: "")
    let detailLabel = BehaviorRelay<String?>(value: "")
    let tagLabel = BehaviorRelay<String?>(value: "")
    let memoLabel = BehaviorRelay<String?>(value: "")

    init(
        itemLabelObservable: Observable<String?>,
        detailLabelObservable: Observable<String?>,
        tagLabelObservable: Observable<String?>,
        memoLabelObservable: Observable<String?>,
        addButtonTapped: Observable<Void>,
        validation: TextValidate
        ) {

        validatedItem = itemLabelObservable
            .flatMap { (input) -> Observable<Bool> in
                return validation
                    .validate(text: input)
            }
            .share()

        validatedDetail = detailLabelObservable
            .flatMap { (input) -> Observable<Bool> in
                return validation
                    .validate(text: input)
            }
            .share()

        validatedTag = tagLabelObservable
            .flatMap { (input) -> Observable<Bool> in
                return validation
                    .validate(text: input)
            }
            .share()

        isEnableButton = Observable.combineLatest(
            validatedItem,
            validatedDetail,
            validatedTag

        ) {
            $0 && $1 && $2
        }
        .share()
    }

    func addItem() {
        let item = Item(
            name: itemLabel.value!,
            detail: detailLabel.value!,
            tag: tagLabel.value!,
            memo: memoLabel.value!,
            fav: false,
            celllNo: items.count
        )
        items.append(item)
    }
}

validation

enum TextError: Error {
    case blank
    case length
}

protocol TextValidate {
    func validate(text: String?) -> Observable<Bool>
}

final class Validation: TextValidate {

    func validate(text: String?) -> Observable<Bool> {
        switch text {
        case .none:
            return Observable.error(TextError.blank)
        case let text?:
            switch text.isEmpty {
            case true:
                return Observable.error(TextError.blank)
            case false:
                return Observable.just(true)
            }
        }
    }
}

extension TextError {
    var button: Bool {
        switch self {
        case .blank, .length:
            return false
        }
    }
}

問題

上記コードでは、入力されたテキストの状態をvalidateし、結果をBoolで返す流れ。その値によって有効化を切り替えますが、ここでのError Enumによって後々フローを分けようかなと考えていました。

binding自体はされていると思ったのですが、このままビルドすると落ちてしまいます。
RxSwift内に以下のメッセージが出ます。
Thread 1: Fatal error: Binding error: blank

原因

bindingはできているっぽくて、フローも問題なさそうなのにどこでエラーが起こっているのか。
原因はenum TextError: Errorにてエラーをストリームに流しており、UIがこのエラーを受け取ってしまっているから。UIはErrorを受け取れません。

現状の対策として、Errorをまず流さないようにする = Errorfalseに変えてあげることで対応します。

対策

どこで変えればいいか。この場合だと以下の3つほど使えそうです。

// `func validate()`の中

func validate(text: String?) -> Observable<Bool> {
        switch text {
        case .none:
            //return Observable.error(TextError.blank)
          return Observable.just(false) //とか
        case let text?:
            switch text.isEmpty {
            case true:
                //return Observable.error(TextError.blank)
              return Observable.just(false) //とか
            case false:
                return Observable.just(true)
            }
        }
    }

//--------------------------------------------------------

// ストリームを作っているフローの`func validate()`の直後

validatedItem = itemLabelObservable
    .flatMap { (input) -> Observable<Bool> in
        return validation
            .validate(text: input)
            .catchErrorJustReturn(false) //とか
    }
    .share()

//--------------------------------------------------------

// ストリームを`combineLatest`で繋げ、全ての処理が終了した直後

isEnableButton = Observable.combineLatest(
    validatedItem,
    validatedDetail,
    validatedTag

) {
    $0 && $1 && $2
}
.catchErrorJustReturn(false) //とか
.share()

結論から言うと、最後の3つめは実装すると落ちなくはなりますがbuttonが無効化のままです。

なぜか。それはerrorおよびcompleted流れた後一切の後続のものが流れないと言う前提があります。
つまり、実装のデフォルト状態でまずfalseになっているので、このまま後は流れないと言うことになります。

よって上二つが希望の動作をしてくれます。
どちらでも問題は解消されますが、後者の方が汎用性はありそうです。

なぜvalidateErrorを返していたのかというと、Observable<Error>で返ったものを.materialiseして〜などといったものを今後行おうと思っていたためです。

ちなみに.catchErrorJustReturn(false)は、ErrorcatchしたらJust Bool(false) returnするぜというとってもpureなenglishで分かやすいものです。

まとめ

ただBoolを返すだけだったら、変にハンドリングするコードを作成せずシンプルにした方がいいかもしれません。

enumErrorを作成してcaseを分けて〜とするとコードの可読性も上がっていいですが、特に必要なさそうだったらコード量の増加を招くので適材適所ということで。

ErrorはそのままUIに渡してはいけません。ちゃんと最後まで面倒を見ましょう。

補足

上記コードに更に指摘していただたい部分として、
最新値だけ取得したいなら → .flatMapLatest
これらのような問題を回避するため → Driver Relay
を活用すればという意見もいただきました。