RxSwift 错误处理订阅

Posted

技术标签:

【中文标题】RxSwift 错误处理订阅【英文标题】:RxSwift errors dispose of subscriptions 【发布时间】:2018-09-04 10:55:54 【问题描述】:

我一直在尝试一些新的 swift 架构和模式,并且我注意到 RxSwift 存在一个奇怪的问题,如果我在进行服务调用并发生错误 - 例如用户输入了错误的密码 - 然后它似乎处理了我的订阅,所以我无法再次拨打服务电话

我不确定为什么会这样。我做了一个快速的迷你项目,用一个示例登录应用程序演示了这个问题。

我的 ViewModel 看起来像这样

import RxSwift
import RxCocoa
import RxCoordinator
import RxOptional
extension LoginModel : ViewModelType 
    struct Input 
        let loginTap : Observable<Void>
        let password : Observable<String>
    

    struct Output 
        let validationPassed : Driver<Bool>
        let loginActivity : Driver<Bool>
        let loginServiceError : Driver<Error>
        let loginTransitionState : Observable<TransitionObservables>
    

    func transform(input: LoginModel.Input) -> LoginModel.Output 
        // check if email passes regex
        let isValid = input.password.map(val) -> Bool in
            UtilityMethods.isValidPassword(password: val)
        

        // handle response
        let loginResponse = input.loginTap.withLatestFrom(input.password).flatMapLatest  password in
            return self.service.login(email: self.email, password: password)
        .share()

        // handle loading
        let loginServiceStarted = input.loginTap.maptrue
        let loginServiceStopped = loginResponse.map_ in false
        let resendActivity = Observable.merge(loginServiceStarted, loginServiceStopped).materialize().map$0.element.filterNil()

        // handle any errors from service call
        let serviceError = loginResponse.materialize().map$0.error.asDriver(onErrorJustReturn: RxError.unknown).filterNil()

        let loginState = loginResponse.map  _ in
            return self.coordinator.transition(to: .verifyEmailController(email : self.email))
        

        return Output(validationPassed : isValid.asDriver(onErrorJustReturn: false), loginActivity: resendActivity.asDriver(onErrorJustReturn: false), loginServiceError: serviceError, loginTransitionState : loginState)
    


class LoginModel 
    private let coordinator: AnyCoordinator<WalkthroughRoute>
    let service : LoginService
    let email : String
    init(coordinator : AnyCoordinator<WalkthroughRoute>, service : LoginService, email : String) 
        self.service = service
        self.email = email
        self.coordinator = coordinator
     

我的 ViewController 看起来像这样

import UIKit
import RxSwift
import RxCocoa
class TestController: UIViewController, WalkthroughModuleController, ViewType 

    // password
    @IBOutlet var passwordField : UITextField!

    // login button
    @IBOutlet var loginButton : UIButton!

    // disposes of observables
    let disposeBag = DisposeBag()

    // view model to be injected
    var viewModel : LoginModel!

    // loader shown when request is being made
    var generalLoader : GeneralLoaderView?

    override func viewDidLoad() 
        super.viewDidLoad()

    
    // bindViewModel is called from route class
    func bindViewModel() 
        let input = LoginModel.Input(loginTap: loginButton.rx.tap.asObservable(), password: passwordField.rx.text.orEmpty.asObservable())

        // transforms input into output
        let output = transform(input: input)

        // fetch activity
        let activity = output.loginActivity

        // enable/disable button based on validation
        output.validationPassed.drive(loginButton.rx.isEnabled).disposed(by: disposeBag)

        // on load
        activity.filter$0.drive(onNext:  [weak self] _ in
            guard let strongSelf = self else  return 
            strongSelf.generalLoader = UtilityMethods.showGeneralLoader(container: strongSelf.view, message: .Loading)
        ).disposed(by: disposeBag)

        // on finish loading
        activity.filter!$0.drive(onNext :  [weak self] _ in
            guard let strongSelf = self else  return 
            UtilityMethods.removeGeneralLoader(generalLoader: strongSelf.generalLoader)
        ).disposed(by: disposeBag)

        // if any error occurs
        output.loginServiceError.drive(onNext:  [weak self] errors in
            guard let strongSelf = self else  return 

            UtilityMethods.removeGeneralLoader(generalLoader: strongSelf.generalLoader)

            print(errors)
        ).disposed(by: disposeBag)

        // login successful
        output.loginTransitionState.subscribe().disposed(by: disposeBag)
    

我的服务类

import RxSwift
import RxCocoa

struct LoginResponseData : Decodable 
    let msg : String?
    let code : NSInteger


    class LoginService: NSObject 
        func login(email : String, password : String) -> Observable<LoginResponseData> 
            let url = RequestURLs.loginURL

            let params = ["email" : email,
                          "password": password]

            print(params)

            let request = AFManager.sharedInstance.setupPostDataRequest(url: url, parameters: params)
            return request.map data in
                return try JSONDecoder().decode(LoginResponseData.self, from: data)
            .map$0
        
    

如果我输入有效密码,请求就可以正常工作。如果我出于测试目的删除转换代码,只要密码有效,我就可以一遍又一遍地调用登录服务。但是一旦发生任何错误,与服务调用相关的 observables 就会被处理掉,这样用户就不能再尝试服务调用了

到目前为止,我发现解决此问题的唯一方法是,如果发生任何错误,请再次调用 bindViewModel 以便重新设置订阅。但这似乎是非常糟糕的做法。

任何建议将不胜感激!

【问题讨论】:

如果我理解正确,方法func login(email : String, password : String) -&gt; Observable&lt;LoginResponseData&gt; 在登录尝试失败后立即被处理掉。您是否在此方法的 return 语句末尾使用 .debug("someMessage") 调试了 observable,以便查看确切的输出? Observable&lt;error&gt; 的可观察到的错误是否会导致登录尝试失败? 【参考方案1】:

在您进行登录调用的地方:

let loginResponse = input.loginTap
    .withLatestFrom(input.password)
    .flatMapLatest  [unowned self] password in
        self.service.login(email: self.email, password: password)
    
    .share()

你可以做两件事之一。将登录名映射到 Result&lt;T&gt; 类型。

let loginResponse = input.loginTap
    .withLatestFrom(input.password)
    .flatMapLatest  [unowned self] password in
        self.service.login(email: self.email, password: password)
            .map(Result<LoginResponse>.success)
            .catchError  Observable.just(Result<LoginResponse>.failure($0)) 
    
    .share()

或者你可以使用物化操作符。

let loginResponse = input.loginTap
    .withLatestFrom(input.password)
    .flatMapLatest  [unowned self] password in
        self.service.login(email: self.email, password: password)
            .materialize()
    
    .share()

这两种方法都可以通过将loginResponse 对象包装在一个枚举中(Result&lt;T&gt;Event&lt;T&gt;)来更改它的类型。然后,您可以在不破坏 Observable 链的情况下以不同于处理合法结果的方式处理错误并且不会丢失错误。

正如您所发现的,另一种选择是将loginResponse 的类型更改为可选项,但随后您会丢失错误对象。

【讨论】:

另外,你真的应该传入email,就像你传入password一样。如果你必须在你的闭包中引用self,你想让它变得弱或无主。【参考方案2】:

这种行为并不奇怪,但可以按预期工作:正如官方 RxSwift 文档 documentation 中所述: “当一个序列发送完成或错误事件时,所有计算序列元素的内部资源都将被释放。” 对于您的示例,这意味着登录尝试失败将导致方法 func login(email : String, password : String) -&gt; Observable&lt;LoginResponseData&gt; 返回错误,即返回 Observable&lt;error&gt;,这将:

一方面将此错误快速转发给所有订阅者(这将由您的 VC 完成) 另一方面处理 observable

回答你的问题,除了再次订阅,你可以做些什么来维持订阅:你可以使用.catchError(),所以observable不会终止,你可以自己决定你想要返回什么发生错误后。请注意,您还可以检查特定错误域的错误并仅返回某些域的错误。

我个人认为错误处理的责任在各个订阅者手中,即在你的情况下你的TestController(所以你可以在那里使用.catchError()),但是如果你想确保从来自func login(email : String, password : String) -&gt; Observable&lt;LoginResponseData&gt; 甚至不会快进所有订阅的任何错误,您也可以在此处使用.catchError(),尽管我会看到潜在的不当行为问题。

【讨论】:

捕获错误的问题是它迫使我返回 LoginResponseData 对象,即使服务调用失败。我得到这个工作的唯一方法是让服务返回可选的 Observable,然后在服务类中执行 .catchErrorJustReturn(nil)。但是我从来没有从网络服务中得到错误,每次服务失败时只是一个 nil LoginResponseData 对象。我希望能够在 viewModel 中捕获错误,然后在 Output 结构中返回它。 注意事项。如果你把catchError outside 出了flatMap,那么报错还是会拆流。

以上是关于RxSwift 错误处理订阅的主要内容,如果未能解决你的问题,请参考以下文章

使用 Moya + RxSwift 处理自定义错误响应

处理 UITableView 绑定中的连接错误(Moya、RxSwift、RxCocoa)

RxSwift - PublishSubject - 忽略错误并继续订阅(不要处置)

如何使用 RxSwift 在一个地方捕获来自两个请求的错误

6. RxSwift 订阅操作过滤1 mapbufferfilter

观察者在单元格 RxSwift 中处理后仍然接收事件