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.
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:
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 :)
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)
I would shift some responsibilities to the view controller.
One approach would be to have the view model produce an Observable which as a side effect updates the view model properties. In the following code example, the view controller remains in charge of the view bindings, as well as triggering the refresh in viewDidLoad() and via a button tap.
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)
}
}
All answers are good, but i want to mentioned about CleanArchitectureRxSwift. This framework really help me to find the way how rx can be applied to my code. The part about "backend" mobile programming (request, parsers, etc) can be omitted, but work with viewModel/viewController has really interesting things.
Related
I have some odd problem with UITableViewCell when I use MVVM pattern with RxSwift. I can't describe it, but I will try to explain.
Let's say we have simple UITableViewCell
class MyTableViewCell: UITableViewCell {
var disposeBag = DisposeBag()
override func prepareForReuse() {
super.prepareForReuse()
disposeBag = DisposeBag()
}
func bind(to viewModel: MyCellViewModel) {
viewModel.randomValue.asDriver()
.drive(onNext: { [weak self] value in
guard let self = self else { return}
print(value) // WHEN TWO CELLS I GET NEW VALUES JUST IN ONE OF THEM
})
.disposed(by: disposeBag)
}
with ViewModel with simple timer.
class MyCellViewModel: NSObject {
let randomValue = PublishSubject<Int>()
init() {
Observable<Int>.timer(.seconds(1), period: .seconds(1), scheduler: SerialDispatchQueueScheduler(qos: .userInteractive))
.map { _ in Int.random()}
.do(onNext: {
print($0) // WORK PERFECT FOR BOTH
})
.bind(to: randomValue)
.disposed(by: rx.disposeBag)
}
}
I also use RxDataSources to fill my tableView.
private func makeDataSource() -> RxTableViewSectionedAnimatedDataSource<Section> {
RxTableViewSectionedAnimatedDataSource<Section>(configureCell: { _, tableView, indexPath, item in
switch item {
case let .myItem(cellViewModel):
let cell = tableView.dequeueReusableCell(with: MyTableViewCell.self, for: indexPath)
cell.bind(to: cellViewModel)
return cell
}
})
}
The problem is that when I have two cells, I get new values in one of them and not in the other.
Pay attention to the method bind(to viewModel
But everything is ok when I change RxTableViewSectionedAnimatedDataSource to RxTableViewSectionedReloadDataSource.
How to make it work with RxTableViewSectionedAnimatedDataSource? I understand the difference between them, but in this case I don't know how does that affect it?
I also checked memory graph and saw just two instances of cells and two instances of cellViewModels. So I hope there are no leaks.
Thanks for any help!
You implemented your ItemMode.== incorrectly. It doesn't actually check for equality, it only checks the identity. Remove the function and let the compiler generate the correct one for you.
I have a pretty hefty chunk of chained Rx observables that are fired when a tableviews row is selected via table.rx.modelSelected.
I'd like to be able to break this logic up, because I'm currently having to execute business logic in flatMapLatest, because it's "Step 1" to the process (which feels wrong), and I have to execute more business logic in the subsequent subscribe ("Step 2"). Here's the code I'm using:
locationsTable.rx.modelSelected(Location.self)
.flatMapLatest { [weak self] location -> Observable<[JobState]?> in
guard let hubs = self?.viewModel.userInfo.authorizedHubLocations else { return .empty() }
guard let hub = hubs.first(where: { $0.locationId == location.id }) else { return .empty() }
guard let hubToken = hub.hubToken else { return .empty() }
// save data in db
self?.databaseService.persistHub(hubResult: hub, location: location)
// make network call for the 2nd step (the subscribe)
let networkService = NetworkService(plugins: [AuthPlugin(token: hubToken)])
return networkService.jobStates(locationId: location.id)
}
.subscribe(onNext: { [weak self] jobState in
if let jobState = jobState {
self?.databaseService.persistJobStates(jobStates: jobState)
}
NavigationService.renderScreenB()
}, onError: { error in
Banner.showBanner(type: .error, title: "Whoops", message: "Something went wrong.")
}).disposed(by: disposeBag)
This code currently works, but it feels dirty. Any advice on how to clean this up would be greatly appreciated.
You have several separate and distinct bits of logic and side-effects and you are trying to stuff them all into a single flatMap. I suggest breaking them up into their component parts.
Also, your error logic isn't correct. If your network service emits an error your "Whoops" banner will display, but it will also break your chain and the user won't be able to select a different location. My code below fixes this problem.
The functions below are all free functions. Since they are not tied to a specific view controller, they can be used and tested independently. Also notice that these functions encompass all the logic and only the logic of the system. This allows you to test the logic free of side-effects and promotes good architecture. Also notice that they return Drivers. You can be sure that none of these functions will emit an error which would break the chain and the view controller's behavior.
/// Emits hubs that need to be persisted.
func hubPersist(location: ControlEvent<Location>, userInfo: UserInfo) -> Driver<(location: Location, hub: Hub)> {
let hub = getHub(location: location, userInfo: userInfo)
.asDriver(onErrorRecover: { _ in fatalError("no errors are possible") })
return Driver.combineLatest(location.asDriver(), hub) { (location: $0, hub: $1) }
}
/// Values emitted by this function are used to make the network request.
func networkInfo(location: ControlEvent<Location>, userInfo: UserInfo) -> Driver<(NetworkService, Int)> {
let hub = getHub(location: location, userInfo: userInfo)
return Observable.combineLatest(hub, location.asObservable())
.compactMap { (hub, location) -> (NetworkService, Int)? in
guard let hubToken = hub.hubToken else { return nil }
return (NetworkService(plugins: [AuthPlugin(token: hubToken)]), location.id)
}
.asDriver(onErrorRecover: { _ in fatalError("no errors are possible") })
}
/// shared logic used by both of the above. Testing the above will test this by default.
func getHub(location: ControlEvent<Location>, userInfo: UserInfo) -> Observable<Hub> {
return location
.compactMap { location -> Hub? in
let hubs = userInfo.authorizedHubLocations
return hubs.first(where: { $0.locationId == location.id })
}
}
The function below is a wrapper around your network request that makes errors more usable.
extension NetworkService {
func getJobStates(locationId: Int) -> Driver<Result<[JobState], Error>> {
return jobStates(locationId: locationId)
.map { .success($0 ?? []) }
.asDriver(onErrorRecover: { Driver.just(.failure($0)) })
}
}
Here is your view controller code using all of the above. It consists almost exclusively of side effects. The only logic are a couple of guards to check for success/failure of the network request.
func viewDidLoad() {
super.viewDidLoad()
hubPersist(location: locationsTable.rx.modelSelected(Location.self), userInfo: viewModel.userInfo)
.drive(onNext: { [databaseService] location, hub in
databaseService?.persistHub(hubResult: hub, location: location)
})
.disposed(by: disposeBag)
let jobStates = networkInfo(location: locationsTable.rx.modelSelected(Location.self), userInfo: viewModel.userInfo)
.flatMapLatest { networkService, locationId in
return networkService.getJobStates(locationId: locationId)
}
jobStates
.drive(onNext: { [databaseService] jobStates in
guard case .success(let state) = jobStates else { return }
databaseService?.persistJobStates(jobStates: state)
})
.disposed(by: disposeBag)
jobStates
.drive(onNext: { jobStates in
guard case .success = jobStates else { return }
NavigationService.renderScreenB()
})
.disposed(by: disposeBag)
jobStates
.drive(onNext: { jobStates in
guard case .failure = jobStates else { return }
Banner.showBanner(type: .error, title: "Whoops", message: "Something went wrong.")
})
.disposed(by: disposeBag)
}
FYI, the above code uses Swift 5/RxSwift 5.
I have two screens:
NewControlTableViewController: contains a textfield for selecting a client from the other view
ClientsTableViewController: The second view contains a list clients that can be selected
The two screens share a viewmodel.
So here is my code:
import RxSwift
import RxCocoa
struct NewControlViewModel {
var selectedClient = Variable<Client>(Client())
// other stuff
}
// NewControlTableViewController : viewDidLoad
viewModel.selectedClient.asObservable().subscribe { event in
debugPrint(event)
}
// ClientsTableViewController: viewDidLoad
/*tableView.rx.itemSelected.subscribe(onNext: { indexPath in
let client = self.clients[indexPath.row]
debugPrint(client)
self.viewModel.selectedClient.value = client
self.navigationController?.popToRootViewController(animated: true)
}).disposed(by: self.disposeBag)*/
// new code
tableView.rx
.modelSelected(Client.self)
.debug("client selected", trimOutput: true)
.do(onNext: { _ in
self.navigationController?.popViewController(animated: true)
})
.subscribe(onNext: { client in
debugPrint(client)
self.viewModel.selectedClient.value = client
}, onError: { error in
debugPrint(error)
}).disposed(by: disposeBag)
The event is fired(with empty values) whenever I view the first screen, then, after selecting a client from the second screen, for some reason, no event is being fired.
First of all, Variables are deprecated.
You should use a PublishRelay which doesn't need an initial value, that way it won't be fired upon subscription in your first screen.
The advantage of Relays is they don't error out or complete.
struct NewControlViewModel {
let selectedClient = PublishRelay<Client>()
}
// NewControlTableViewController : viewDidLoad
choisirMandat.rx.tap.subscribe(onNext: { [unowned self] in
let viewController = /* instantiate vc */
// Make sure to use the same viewModel
viewController.viewModel = self.viewModel
self.present(viewController, animated: true)
}).disposed(by: disposeBag)
self.viewModel.selectedClient.debug().subscribe().disposed(by: self.disposeBag)
// ClientsTableViewController: viewDidLoad
tableView.rx.itemSelected.map { [unowned self] indexPath in
return self.clients[indexPath.row]
}
.debug("client selected", trimOutput: true)
.do(onNext: { [unowned self] _ in
self.navigationController?.popToRootViewController(animated: true)
})
.bind(to: self.viewModel.selectedClient)
.disposed(by: self.disposeBag)
On a side note, clients should probably also come from the Rx world if you want to be fully functional.
Second side note, you can use the debug() operator to print events.
bind(to:) was added in RxSwift 4.1 so make sure to be up to date
I think I have found your issue here. You are doing is
// NewControlTableViewController : viewDidLoad
viewModel.selectedClient.asObservable().subscribe { event in
debugPrint(event)
}
// ClientsTableViewController: viewDidLoad
tableView.rx.itemSelected.subscribe(onNext: { indexPath in
let client = self.clients[indexPath.row]
debugPrint(client)
self.viewModel.selectedClient.value = client
self.navigationController?.popToRootViewController(animated: true)
}).disposed(by: self.disposeBag)
You need to change this line "self.viewModel.selectedClient.value = client". You need to use previous controller instance or NewControlTableViewController instance like self.newControlTableVC.selectedClient.value = client
I think you are trying to use viewmodel by sending there value from NewControlTableViewController to ClientsTableViewController.
Moreover, I did the same scenario which you did and it worked very well in my case. Just try my scenario if it could not work there must be some minor issue.
I hope it may help
Hi I am trying to make a UIRefreshControl work with RxSwift. Therefore I am using the Activity Indicator that is in the RxSwift Example.
In my viewModel I have the following function and variable to get my data.
// MARK: - Variables
var data = Variable<[Data]>([])
// MARK: - Public Interface
func getData() {
let request = Data.readAll()
_ = request.rxResult().subscribe(onNext: { response in
self.data.value = response.data
}, onError: { (Error) in
}, onCompleted: {
}, onDisposed: {
})
}
Then in my view controller I try to bind it to the UIRefreshcontrol and the collection view I have.
let refresher: UIRefreshControl = UIRefreshControl()
let indicator = ActivityIndicator()
indicator.asObservable()
.bindTo(refresher.rx.isRefreshing)
.addDisposableTo(disposeBag)
let resultObservable = viewModel.data.asObservable()
.trackActivity(indicator)
.bindTo(self.collectionView.rx.items(cellIdentifier: reuseCell, cellType: DataCollectionViewCell.self)) {
row, data, cell in
cell.configureCell(with: data)
}
resultObservable.addDisposableTo(disposeBag)
My question is, what am I missing to make this work? Right now if I start the app nothing happens except a black activity indicator that doesn't stop spinning.
I think you should prefer subscribing in ViewController and add that subscription in that view controller's dispose bag.
The following way I think is the correct way of using ActivityIndicator from RxExamples. following is a pseudo code.
/// ViewController.swift
import RxSwift
import RxCocoa
…
let refreshControl = UIRefreshControl()
refreshControl.rx.controlEvent(.valueChanged)
.bind(to:self.viewModel.inputs.loadPageTrigger)
.disposed(by: disposeBag)
self.viewModel.indicator
.bind(to:refreshControl.rx.isRefreshing)
.dispose(by:disposeBag)
…
/// ViewModel.swift
…
let loadTrigger = PublishSubject<Void>()
let indicator = ActivityIndicator().asDriver()
…
// Assuming rxResult returns Observable<Response>
let req = indicator.asObservable()
.sample(loadTrigger)
.flatMap { isLoading -> Observable<Response> in
if isLoading { return Observable.empty() }
return Data.readAll().rxResult()
}
.trackActivity(indicator)
.map { $0.data }
.do(onNext: { [unowned self] data in
self.data.value = data
})
…
refresher
.rx.controlEvent(UIControlEvents.valueChanged)
.subscribe(onNext: { [weak self] in
//Put your hide activity code here
self?.refresher.endRefreshing()
}, onCompleted: nil, onDisposed: nil)
.disposed(by: disposeBag)
Subscribe an event of refresher(UIRefreshControl)
I have a BehaviorSubject named createObservable in my view model. And my view controller subscribe it.
viewModel!.createObservable.subscribe(onNext: {[unowned self] (obj:PassbookModelType?) -> Void in
if let _ = obj{
self.dismissVC()
}
}, onError: { (error) -> Void in
print(error)
}).addDisposableTo(self.dispose)
I have a function named saveObject() also in the view model. If I click the navigation bar right item it will be emitted. And there is an error will send to createObservable's observer.
func saveObject(){
```````
```````
if condition {
createObservable.on(Event.Next(model))
createObservable.onCompleted()
}else{
createObservable.onError(MyError.someError)
}
}
The problem is that if the error happened the createObservable will be closed, so I won't receive any Next event in the future. I tried to use retry(), but it seems will cause deadlock, view controller can't response any touch event any more. So can some one tell me how to fix this issue? Thanks a lot
viewModel!.createObservable.retry().subscribe(onNext: {[unowned self] (obj:PassbookModelType?) -> Void in
if let _ = obj{
self.dismissVC()
}
}, onError: { (error) -> Void in
print(error)
}).addDisposableTo(self.dispose)
I suggest to make the type of createObservable PublishSubject<Observable<PassbookModelType>>, instead of BehaviorSubject<PassbookModelType?> which, I guess, accidentally flattens two Rx streams conceptually separatable each other: the saveObject process itself (an one-shot process) and starting the saveObject process initiated by user action repeatedly. I've written a short example to demonstrate it.
let createObservable = PublishSubject<Observable<Int>>()
override func viewDidLoad() {
super.viewDidLoad()
createObservable.flatMap {
$0.map { obj in
print("success: \(obj)")
}
.catchError { err in
print("failure: \(err)")
return empty()
}
}.subscribe()
}
// Simulates an asynchronous proccess to succeed.
#IBAction func testSuccess(sender: UIView!) {
let oneShot = PublishSubject<Int>()
createObservable.onNext(oneShot)
callbackAfter3sec { res in
oneShot.onNext(1)
oneShot.onCompleted()
}
}
// Simulates an asynchronous process to fail.
#IBAction func testFailure(sender: UIView!) {
let oneShot = PublishSubject<Int>()
createObservable.onNext(oneShot)
callbackAfter3sec { res in
oneShot.onError(NSError(domain: "Error", code: 1, userInfo: nil))
}
}
func callbackAfter3sec(completion: Int -> ()) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, Int64(NSEC_PER_SEC * 3)), dispatch_get_main_queue()) {
completion(2)
}
}
There is an important merit with that: If the one-shot process would become in the Rx style (for example, like as callbackAfter3sec() -> Observable<Int>) in the future, there were no need to re-write the use-side code like in the viewDidLoad above. There is an only one change to do is to pass an Observable<> object to createObservable.onNext(...).
Sorry for my poor English skill. I hope this makes sense to you.