【问题标题】:Structuring a View Model Using RxSwift使用 RxSwift 构建视图模型
【发布时间】:2018-08-24 06:31:36
【问题描述】:

我的视图模型存在根本缺陷,因为使用驱动程序的视图模型将在返回错误时完成,并且无法自动重新订阅。

一个例子是我的PickerViewModel,它的界面是:

//  MARK: Picker View Modelling
/**
Configures a picker view.
 */
public protocol PickerViewModelling {
    /// The titles of the items to be displayed in the picker view.
    var titles: Driver<[String]> { get }
    /// The currently selected item.
    var selectedItem: Driver<String?> { get }
    /**
    Allows for the fetching of the specific item at the given index.
    - Parameter index:  The index at which the desired item can be found.
    - Returns:  The item at the given index. `nil` if the index is invalid.
    */
    func item(atIndex index: Int) -> String?
    /**
    To be called when the user selects an item.
    - Parameter index:  The index of the selected item.
     */
    func selectItem(at index: Int)
}

Driver 问题的示例可以在我的CountryPickerViewModel 中找到:

    init(client: APIClient, location: LocationService) {
        selectedItem = selectedItemVariable.asDriver().map { $0?.name }
        let isLoadingVariable = Variable(false)
        let countryFetch = location.user
            .startWith(nil)
            .do(onNext: { _ in isLoadingVariable.value = true })
            .flatMap { coordinate -> Observable<ItemsResponse<Country>> in
                let url = try client.url(for: RootFetchEndpoint.countries(coordinate))
                return Country.fetch(with: url, apiClient: client)
            }
            .do(onNext: { _ in isLoadingVariable.value = false },
                onError: { _ in isLoadingVariable.value = false })
        isEmpty = countryFetch.catchError { _ in countryFetch }.map { $0.items.count == 0 }.asDriver(onErrorJustReturn: true)
        isLoading = isLoadingVariable.asDriver()
        titles = countryFetch
            .map { [weak self] response -> [String] in
                guard let `self` = self else { return [] }
                self.countries = response.items
                return response.items.map { $0.name }
            }
            .asDriver(onErrorJustReturn: [])

    }
}

titles 驱动UIPickerView,但当countryFetch 因错误而失败时,订阅完成且无法手动重试获取。

如果我尝试catchError,则不清楚我可以返回什么 observable,这些 observable 可以在用户恢复其互联网连接后重试。

任何justReturn 错误处理(asDriver(onErrorJustReturn:)catchError(justReturn:))显然都会在返回值后立即完成,并且对于这个问题毫无用处。

我需要能够尝试获取,失败,然后显示一个 重试 按钮,该按钮将在视图模型上调用 refresh() 并重试。如何保持订阅开放?

如果答案需要重组我的视图模型,因为我正在尝试做的事情是不可能的或干净的,我愿意听到更好的解决方案。

【问题讨论】:

    标签: swift mvvm viewmodel rx-swift


    【解决方案1】:

    关于使用 RxSwift 时的 ViewModel 结构,在一个相当大的项目的密集工作中,我发现了 2 条有助于保持解决方案可扩展和可维护的规则:

    1. 避免在 viewModel 中使用任何与 UI 相关的代码。它包括 RxCocoa 扩展和驱动程序。 ViewModel 应该特别关注业务逻辑。驱动程序是用来驱动 UI 的,所以把它们留给 ViewControllers :)

    2. 尽可能避免使用变量和主题。 AKA 试图让一切“流动”。函数转化为函数,转化为函数等等,最终在 UI 中。当然,有时您需要将非 rx 事件转换为 rx 事件(如用户输入)——在这种情况下,主题是可以的。但是不要过度使用主题 - 否则您的项目将很快变得难以维护和扩展。

    关于您的特定问题。所以当你想要重试功能时总是有点棘手。 Here 与 RxSwift 作者就这个话题进行了很好的讨论。

    第一种方式。在您的示例中,您在 init 上设置了您的 observables,我也喜欢这样做。在这种情况下,您需要接受这样一个事实,即您不希望序列会因错误而失败。您确实期望可以发出带有标题的结果或带有错误的结果的序列。为此,在 RxSwift 中,我们有 .materialize() 组合器。

    在 ViewModel 中:

    // in init
    titles = _reloadTitlesSubject.asObservable() // _reloadTitlesSubject is a BehaviorSubject<Void>
        .flatMap { _ in 
            return countryFetch
                .map { [weak self] response -> [String] in
                    guard let `self` = self else { return [] }
                    self.countries = response.items
                    return response.items.map { $0.name }
                }
                .materialize() // it IS important to be inside flatMap
        }
    
    // outside init
    
    func reloadTitles() {
        _reloadTitlesSubject.onNext(())
    }
    

    在 ViewController 中:

    viewModel.titles
        .asDriver(onErrorDriveWith: .empty())
        .drive(onNext: [weak self] { titlesEvent in
            if let titles = titlesEvent.element {
                // update UI with 
            }
            else if let error = titlesEvent.error {
                // handle error
            }
        })
        .disposed(by: bag)
    
    retryButton.rx.tap.asDriver()
        .drive(onNext: { [weak self] in
            self?.viewModel.reloadTitles()
        })
        .disposed(by: bag)
    

    第二种方式基本上是 CloackedEddy 在他的回答中建议的。但可以进一步简化以避免变量。在这种方法中,您不应该在 viewModel 的init 中设置您的可观察序列,而是每次都重新返回它:

    // in ViewController
    yourButton.rx.tap.asDriver()
        .startWith(())
        .flatMap { [weak self] _ in
            guard let `self` = self else { return .empty() }
            return self.viewModel.fetchRequest()
                .asDriver(onErrorRecover: { error -> Driver<[String]> in
                    // Handle error.
                    return .empty()
                })
        }
        .drive(onNext: { [weak self] in 
            // update UI
        })
        .disposed(by: disposeBag)
    

    【讨论】:

    • 我很欣赏这种不同的思考问题的方式。但是,如果 Driver 不应该在视图模型中使用(目的是驱动视图),那么它的目的是什么?此外,您的第二个示例会中断,因为按钮点击会导致提取,这将完成,然后点击 observable 将不再存在并且按钮将什么也不做。在我使用 RxSwift 的一年半时间里,我从未接触过 materialize,我将不得不对其进行调查。
    • 关于 ViewModel 中的Driver,有两件事值得考虑:1) 通常,如果发生错误,您需要做一些 UI 工作。在视图模型中保留.asDriver 会使其更难,因为您需要其他将错误传播到视图控制器的方法。 2)在某些情况下,您想订阅相同的序列,但对错误的反应不同。关于第二个例子,你是不正确的。 .flatMap 保留外部序列生命周期。试试看,你会看到:)
    • 我已经尝试过了,从经验中我知道我必须在它自己的函数 (configureButton(with:)) 中观察按钮,并在 onError: 回调中递归地重新订阅按钮。
    • Driver 的重点就是针对这种场景。 1. viewModel 应该准备要在视图中显示的数据,这总是需要在主线程上完成 2. Driver 不能有错误,这会强制将任何错误处理到 ViewModel 中,这是应该发生的 3.驱动程序在连接时固有地具有 shareReplay(1),因此相同的值可以绑定到 UI 中的多个点,而无需执行任何特殊操作。您绝对应该为您的 VM 公开的属性使用驱动程序。
    【解决方案2】:

    我会将一些职责转移给视图控制器。

    一种方法是让视图模型生成一个 Observable,作为副作用更新视图模型属性。在下面的代码示例中,视图控制器仍然负责视图绑定,并通过点击按钮触发 viewDidLoad() 中的刷新。

    class ViewModel {
    
        let results: Variable<[String]> = Variable([])
        let lastFetchError: Variable<Error?> = Variable(nil)
    
        func fetchRequest() -> Observable<[String]> {
            return yourNetworkRequest
                .do(onNext: { self.results.value = $0 },
                    onError: { self.lastFetchError.value = $0 })
        }
    }
    
    class ViewController: UIViewController {
    
        let viewModel = ViewModel()
        let disposeBag = DisposeBag()
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            viewModel.results
                .asDriver()
                .drive(onNext: { yourLabel.text = $0 /* .reduce(...) */ })
                .disposed(by: disposeBag) 
    
            viewModel.lastFetchError
                .asDriver()
                .drive(onNext: { yourButton.isHidden = $0 == nil })
                .disposed(by: disposeBag) 
    
            yourButton.rx.tap
                .subscribe(onNext: { [weak self] in 
                   self?.refresh()
                })
                .disposed(by: disposeBag) 
    
            // initial attempt
            refresh()
        }
    
        func refresh() { 
            // trigger the request
            viewModel.fetchRequest()
                .subscribe()
                .disposed(by: disposeBag) 
        }
    }
    

    【讨论】:

      【解决方案3】:

      所有答案都很好,但我想提一下CleanArchitectureRxSwift。这个框架确实帮助我找到了如何将 rx 应用于我的代码的方式。关于“后端”移动编程(请求、解析器等)的部分可以省略,但使用 viewModel/viewController 确实很有趣。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2021-02-17
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2019-12-09
        • 1970-01-01
        • 1970-01-01
        • 2020-01-18
        相关资源
        最近更新 更多