Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Structuring a View Model Using RxSwift

My view models are fundamentally flawed because those that use a driver will complete when an error is returned and resubscribing cannot be automated.

An example is my PickerViewModel, the interface of which is:

//  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)
}

An example of the Driver issue can be found within my 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: [])

    }
}

The titles drive the UIPickerView, but when the countryFetch fails with an error, the subscription completes and the fetch cannot be retried manually.

If I attempt to catchError, it is unclear what observable I could return which could be retried later when the user has restored their internet connection.

Any justReturn error handling (asDriver(onErrorJustReturn:), catchError(justReturn:)) will obviously complete as soon as they return a value, and are useless for this issue.

I need to be able to attempt the fetch, fail, and then display a Retry button which will call refresh() on the view model and try again. How do I keep the subscription open?

If the answer requires a restructure of my view model because what I am trying to do is not possible or clean, I would be willing to hear the better solution.

like image 994
Infinity James Avatar asked Apr 18 '26 06:04

Infinity James


1 Answers

Regarding ViewModel structuring when using RxSwift, during intensive work on a quite big project I've figured out 2 rules that help keeping solution scalable and maintainable:

  1. Avoid any UI-related code in your viewModel. It includes RxCocoa extensions and drivers. ViewModel should focus specifically on business logic. Drivers are meant to be used to drive UI, so leave them for ViewControllers :)

  2. Try to avoid Variables and Subjects if possible. AKA try to make everything "flowing". Function into function, into function and so on and, eventually, in UI. Of course, sometimes you need to convert non-rx events into rx ones (like user input) - for such situations subjects are OK. But be afraid of subjects overuse - otherwise your project will become hard to maintain and scale in no time.

Regarding your particular problem. So it is always a bit tricky when you want retry functionality. Here is a good discussion with RxSwift author on this topic.

First way. In your example, you setup your observables on init, I also like to do so. In this case, you need to accept the fact that you DO NOT expect a sequence that can fail because of error. You DO expect sequence that can emit either result-with-titles or result-with-error. For this, in RxSwift we have .materialize() combinator.

In 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(())
}

In 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)

Second way is basically what CloackedEddy suggests in his answer. But can be simplified even more to avoid Variables. In this approach you should NOT setup your observable sequence in viewModel's init, but rather return it anew each time:

// 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)
like image 77
Nevs12 Avatar answered Apr 20 '26 05:04

Nevs12



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!