#背景
最近はエラーハンドリングしなきゃしなきゃ病からenum
でError
をやたら分けたようと多用しています。その過程でつまづいたところがあったので記します。
知識レベルのメンテナンスというか、しばらく触れずに忘れてしまうとまた取り返しがつかなくなってしまいますのでアプリを丸っと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
をまず流さないようにする = Error
はfalse
に変えてあげることで対応します。
対策
どこで変えればいいか。この場合だと以下の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
になっているので、このまま後は流れないと言うことになります。
よって上二つが希望の動作をしてくれます。
どちらでも問題は解消されますが、後者の方が汎用性はありそうです。
なぜvalidate
でError
を返していたのかというと、Observable<Error>
で返ったものを.materialise
して〜などといったものを今後行おうと思っていたためです。
ちなみに.catchErrorJustReturn(false)
は、Error
をcatch
したらJust Bool(false) returnするぜ
というとってもpureなenglishで分かやすいものです。
まとめ
ただBool
を返すだけだったら、変にハンドリングするコードを作成せずシンプルにした方がいいかもしれません。
enum
でError
を作成してcase
を分けて〜とするとコードの可読性も上がっていいですが、特に必要なさそうだったらコード量の増加を招くので適材適所ということで。
Error
はそのままUIに渡してはいけません。ちゃんと最後まで面倒を見ましょう。
補足
上記コードに更に指摘していただたい部分として、
最新値だけ取得したいなら → .flatMapLatest
これらのような問題を回避するため → Driver
Relay
を活用すればという意見もいただきました。