談談RxSwift中的錯誤處理

L-Zephyr發表於2019-03-04

RxSwift中提供了多種不同的錯誤處理操作符,它們可以在鏈式操作中相互組合以實現複雜的處理邏輯,下面先簡單介紹一下RxSwift提供的錯誤處理操作,然後通過一些具體的例子來看看如何在實際專案中應用。這裡不會詳細介紹RxSwift,閱讀前需要對Rx的基礎有一定了解。

錯誤處理操作符

throw

Rx中許多操作符中的閉包簽名都是帶有throws修飾符的,比如說這是map方法的簽名:

func map<R>(_ transform: @escaping (E) throws -> R) -> Observable<R>
複製程式碼

我們可以在這樣的操作中丟擲錯誤,丟擲的錯誤會沿著鏈式操作一步步向下傳遞,比如下面的程式碼:

Observable.of(3, 2, 1) // 建立一個包含3個事件的Observable
    .map { (n) -> Int in
        if n < 2 {
            throw CustomError.tooSmall // 1. 丟擲一個自定義的錯誤
        } else {
            return n * 2
        }
    }
	.subscribe { event in
		// 2. 這裡會收到一個 CustomError.tooSmall 型別的error
    }
	...
複製程式碼

當數字小於2時,在mapthrow一個自定義的錯誤型別,這個錯誤會被傳遞到下面的subscribe中。

catchError

RxSwift可以在鏈式操作中捕獲錯誤,不管是Observable自己產生的錯誤還是使用者throw的錯誤,都可以在catchError操作中進行處理,接著上面map的程式碼:

Observable.of(3, 2, 1)
    .map { 
        ...
    }
    .catchError({ (error) -> Observable<Int> in
        if case CustomError.tooSmall = error {
            return .just(2) // 1. 在捕獲到tooSmall錯誤後返回一個2
        }
        return .error(error) // 2. 其他錯誤不處理,繼續沿著操作鏈傳遞
    })
    .subscribe { event in
		// 3. 當發生tooSmall錯誤時這裡會收到2,最終的結果是 3, 2, 2
    }
	...
複製程式碼

這樣的處理方式接近於語言本身的try…catch機制,使用起來十分方便。

retry

retry提供了出錯重試的操作,在一個Observable後面加上retry,當這個Observable出錯的時候訂閱方不會收到.error事件,而是重新訂閱這個Observable,這通常意味著這個Observable建立事件相關的操作都會重新執行一次,比如說這是一個網路請求相關的Observable,“重新訂閱”會重發相關的網路請求:

moyaProvider.rx.request(.customData)
    .retry() // 1. 當發生錯誤時重試請求
    .subscribe { 
    	// 2. 重試的過程對於訂閱者來說是不可見的,請求成功後這裡才會接收到事件
    }
複製程式碼

retry方法還可以帶一個Int型別的引數,表示重試的最大次數。

retryWhen

retryWhen就是帶條件的retry,可以在特定條件下才進行重試。retryWhen的方法簽名比較特別,它的閉包中接受的引數不是一個簡單的Error型別,而是一個Observable<Error>型別,使用方法如下:

Observable.of(3, 2, 1)
    .map { 
        ...
    }
    // 1. retryWhen不關心返回的是什麼型別,只關心事件本身,所以直接用Observable<()>即可
    .retryWhen({ (errorObservable) -> Observable<()> in
       	// 2. 用flatMap將其轉換成其他型別的Observable
        return errorObservable.flatMap({ (error) -> Observable<()> in
            if case CustomError.tooSmall = error {
                return .just(()) // 3. 返回一個next事件表示重試
            }
            return .error(error) // 4. 繼續返回error表示不處理
        })
    })
複製程式碼

閉包返回的Observable可以是任意型別,因為retryWhen只關心Observable中的事件本身,不關心其中承載的資料型別,所以這裡直接用一個空型別即可,如果需要重試的話就將一個帶有.next事件的Observable返回。

retryWhen這樣設計的一個優點是在出錯的時候可以將它重試的邏輯跟另外一個Observable事件流關聯起來(後面我會演示一個例子)。但是在上面這樣一個簡單的場景中,使用起來未免過於麻煩了,這裡可以做一個簡單的封裝,提供一個(Error) -> Bool型別的閉包來處理判斷邏輯:

extension ObservableType {
    public func retryWhen<Error: Swift.Error>(_ shouldRetry: @escaping (Error) -> Bool) -> Observable<E> {
        return self.retryWhen({ (errorObserver: Observable<Error>) -> Observable<()> in
            return errorObserver.flatMap({ (error) -> Observable<()> in
                if shouldRetry(error) {
                    return .just(())
                }
                return .error(error)
            })
        })
    }
    
    public func retryWhen(_ shouldRetry: @escaping (Swift.Error) -> Bool) -> Observable<E> {
        return self.retryWhen({ (errorObserver: Observable<Swift.Error>) -> Observable<()> in
            return errorObserver.flatMap({ (error) -> Observable<()> in
                if shouldRetry(error) {
                    return .just(())
                }
                return .error(error)
            })
        })
    }
}
複製程式碼

將上面這段程式碼複製到你的專案中,之前的重試邏輯就變成了:

...
.retryWhen({ (error) -> Bool in
    if case CustomError.tooSmall = error {
        return true
    }
    return false
})
...
複製程式碼

這樣看起來清楚多了,減輕了思維負擔。

實際應用

Moya是Swift常用的一個網路庫,它提供了Rx的介面,下面的例子以Moya作為網路庫來演示,Moya的一個核心協議是TargetType,不瞭解Moya的朋友可以看看它的文件,基本使用就不再詳細介紹了。下面來看兩個常見的實際應用場景

場景一:帶互動的出錯重試

在很多時候,使用者的操作失敗時不能直接重試,而是要給一個,讓使用者來決定下一步的操作。例如有一個檔案下載的請求,當下載失敗的時候需要彈框來詢問是否重試。也就是說在出錯到重試之間存在一個**“中斷”**,只有當使用者做出選擇之後操作鏈才會繼續向下執行。

解決方法是使用retryWhen,將引數中的的Observable<Error>與我們自己業務邏輯的Observable關聯起來。

首先,我們假定有這樣一個確認框的控制元件,它的簽名如下:

class ConfirmView: UIView {
    /// 在檢視中顯示一個確認框,callback為點選的回撥,點選確認回撥true,點選取消回撥false
    static func show(_ title: String, _ callback: (Bool) -> Void) {
        ...
    }
}
複製程式碼

實際的專案中通常都會有很多封裝好的控制元件型別,藉助於RxSwift中所提供的擴充套件機制,只需要新增一個小小的擴充套件就可以與Rx的世界無縫對接起來:

extension Reactive where Base: ConfirmView {
    // 1. 在擴充套件中定義一個show方法,不同的是沒有callback引數,而是返回一個Observable<Bool>
	static func show(_ title: String) -> Observable<Bool> {
        // 2. 建立一個Observable<Bool>
        return Observable<Bool>.create({ (observer) -> Disposable in
            // 3. 呼叫原始的show方法,並在回撥中通過observer傳送結果
            ConfirmView.show(title, { (confirm) in
                observer.onNext(confirm)
                observer.onCompleted()
            })
            return Disposables.create { 
            	// do some cleanup
            }
        })
    }
}
複製程式碼

之後就可以通過ConfirmView.rx.show(xxx)的方式來呼叫這個方法了,這個方法會彈出一個選擇框等待使用者的選擇,選擇的結果通過Observable的事件來進行通知。之後我們使用flatMap將這個ObservableretryWhen中的Obverable<Error>關聯起來:

...
.retryWhen({ (errorO) -> Observable<()> in
    return errorO.flatMap({ (error) -> Observable<()> in
        if case CustomError.tooSmall = error {
            return ConfirmView.rx
                .show("是否重試?")
                .map {
                    if $0 { // 1. 如果選擇了重試,則返回.next()表示重試
                        return ()
                    } else {
                        throw error // 2. 否則繼續返回error將錯誤繼續向下傳遞
                    }
                }
        }
        return .error(error)
    })
})
.subscribe {
	// 3. 如果上面選擇了重試,這裡不會接收到錯誤事件
}
...
複製程式碼

類似的,將不同的操作封裝成Observable這樣簡單的邏輯流,然後通過RxSwift提供的操作加以組合以實現更加複雜的邏輯,這也是Rx所提倡的函式式思想。

場景二:401認證

401錯誤是一種很常見應用場景,比如說在我們的應用中認證流程是這樣的:當伺服器需要重新認證使用者登入資訊時會返回一個401狀態碼,這時客戶端將認證資訊新增到請求頭中並重發當前的請求,這一過程對上層的業務方應該是無感知的。

這跟之前的例子有一些不同的地方:當出錯時我們不能直接retry整個請求,而是要修改原始請求新增自定義的Header,最簡單粗暴的方法是在檢測到401錯誤時傳送一個通知,外面收到通知之後將Header新增到請求頭裡:

moyaProvider.request(target)
    .map({ (response) -> Response in
        if response.statusCode == 401 { // 將401轉換成自定義的錯誤型別
            // 先傳送通知,之後再retry
            NotificationCenter.default.post(name: .AddAuthHeader, object: nil)
            throw NetworkError.needAuth
        } else {
            return response
        }
    })
    .retry()
複製程式碼

這種做法其實並不好,因為Rx中強調的是事件流,原本應該是一個連貫的邏輯卻被通知給打斷了,當我們閱讀到這裡的時候還得停下來全域性搜尋通知的名字以查詢響應的位置,這樣不利於閱讀,同時也違背了Rx的哲學。

我這裡所採用的做法是捕獲到錯誤時不進行retry,而是返回一個新的網路請求。為了讓這個新的網路請求與之前的邏輯無縫連線起來,首先需要定義一個代理TargetType:

let ProxyProvider = NetworkProvider<ProxyTarget>()

enum ProxyTarget {
    // 新增Header
    case addHeader(target: TargetType, headers: [String: String]) 
    // ...
}

extension ProxyTarget: TargetType {
	var headers: [String: String]? {
        switch self {
        // 1. 將新增的Header新增到被代理的Target上
        case let .addHeader(target: target, headers: headers):
            return headers.merging(target.headers ?? [:], uniquingKeysWith: { (first, second) -> String in
                return first
            })
        }
    }
    
    // 2. 不需要吹的地方直接返回被代理Target的屬性
    var task: Task {
        switch self {
        case let .addHeader(target: target, headers: _):
            return target.task
        }
    }
    
    // ...
}
複製程式碼

ProxyTarget並沒有定義新的網路請求,而是用來代理另外一個TargetType,這裡我們只定義了一個addHeader操作,用來修改請求的Header。

最終的實現如下:

provider.request(target)
    .map({ (response) -> Response in
        if response.statusCode == 401 { // 1. 將401轉換成自定義的錯誤型別
            throw NetworkError.needAuth
        } else {
            return response
        }
    })
    .catchError({ (error) -> Single<Response> in
        if case NetworkError.needAuth(let response) = error{
            // 2. 捕獲未認證的錯誤,新增認證頭後再次重試
            let authHeader = ... // 計算認證頭
            let target = ProxyTarget.addHeader(target: token, headers: authHeader)
            return ProxyProvider.rx.request(target, callbackQueue: callbackQueue)
        }
        return Single.error(error)
    })
    .subscribe {
        // 3. 認證的過程對於上層的業務方是無感知的
    }
	...
複製程式碼

使用map將401轉換成自定義的錯誤型別,之後在catchError中捕獲這個錯誤,使用ProxyTarget加上認證頭之後返回一個新的Observable,這樣一來所有相關的邏輯都被集中在這一系列的鏈式呼叫中了。

當然在實際專案中不僅僅是401這類錯誤,可能還會有許多其他業務相關的錯誤型別,將它們全都放在map中處理顯然不是一個好主意,最好的辦法是將這部分邏輯抽離出來放在Moya的Plugin中,這裡就不再演示了。

最後

Rx中對於事件流的抽象十分強大,可以用來描述各種複雜的場景,這裡僅僅從錯誤處理的方面列舉了一些簡單的例子,可以看到Rx的思想跟我們平常所寫的程式碼有很大不同,思維上的轉變才是理解Rx的關鍵。

相關文章