- Taming Great Complexity: MVVM, Coordinators and RxSwift
- Originally written by Arthur Myronenko
- The Nuggets translation Project
- Permanent link to this article: github.com/xitu/gold-m…
- Translator: jingzhilehuakai
- Proofread by Cbangchen Swants
MVVM, Coordinators and RxSwift
Last year, our team started using Coordinators and MVVM in production applications. It looked scary at first, but since then, we have completed four applications based on this pattern. In this article, I will share our experience and guide you through exploring MVVM, Coordinators and responsive programming.
Rather than start with a definition, we’ll start with a simple MVC sample application. We’ll step through the refactoring to show how each component affects the code base and what the results are. Each step will be premised on a brief theoretical introduction.
The sample
In this article, we’ll use a simple example program that shows a list of the libraries on GitHub that get the most stars for different development languages and ranks them by number of stars. Contains two pages, one for a list of libraries filtered by development language category and the other for a list of development languages used to categorize.
Users can go to the second page by clicking a button on the navigation bar. From the development language list, you can select a language or exit the page by clicking the Cancel button. If the user selects a development language on the second page, the page will exit and the repository list page will be refreshed based on the selected development language.
You can find the source file at the link below:
The repository contains four folders: MVC, MVC-RX, MVVM-Rx, coordinators-MvVM-Rx. Correspond to each step of the refactoring. Let’s open up the MVC Folder project and take a look at it before refactoring.
Most of the code in two view controller: RepositoryListViewController and LanguageListViewController. The first view controller takes a list of the most popular repositories and presents them to the user in a table, while the second view controller presents a list of development languages. RepositoryListViewController LanguageListViewController is an agent holding objects, follow the following agreement:
protocol LanguageListViewControllerDelegate: class {
func languageListViewController(_ viewController: LanguageListViewController,
didSelectLanguage language: String)
func languageListViewControllerDidCancel(_ viewController: LanguageListViewController)
}Copy the code
RepositoryListViewController also list view agent holding objects and data source object. It handles navigation events, formats displayable Model data, and performs network requests. Wow, that’s a lot of responsibility for one view controller. The RepositoryListViewController is also a delegate and a data source for the table view. It handles the navigation, formats model data to display and performs network requests. Wow, a lot of responsibilities for just one View Controller!
In addition, you can notice the RepositoryListViewController global scope of this file contains two variables: currentLanguage and repositories. Such state variables complicate classes, and can be a common source of BUGS if an application crashes unexpectedly. In summary, there are several problems with the current code:
- The view controller takes too much responsibility;
- We need to deal passively with changes in state;
- Code untestable.
It’s time to meet our new guest.
RxSwift
This component will allow us to passively respond to state changes and write declarative code.
What is Rx? One of the definitions goes like this:
ReactiveX is a library that combines asynchronous event encoding by using observable sequences.
If you’re unfamiliar with functional programming or if this definition sounds like rocket science (and to me, it still does), you can think of Rx as an extreme observer mode. For more information, you can refer to the getting Started guide or RxSwift books.
Let’s open the MVC-Rx project in the repository and see how RX changes the code. We’ll start with the most common Rx application scenario – we replace LanguageListViewControllerDelegate become two observed variables: didCancel and didSelectLanguage.
// display a list of languages. class LanguageListViewController: UIViewController { privatelet _cancel = PublishSubject<Void>()
var didCancel: Observable<Void> { return _cancel.asObservable() }
private let _selectLanguage = PublishSubject<String>()
var didSelectLanguage: Observable<String> { return _selectLanguage.asObservable() }
private func setupBindings() {
cancelButton.rx.tap
.bind(to: _cancel)
.disposed(by: disposeBag)
tableView.rx.itemSelected
.map { [unowned self] in self.languages[$0.row]}.bind(to: _selectLanguage).disposed(by: disposeBag)}} /// Show a list of repositories sorted by development language. Class RepositoryListViewController: UIViewController {/ / / subscription before navigation ` LanguageListViewController ` observation object. private func prepareLanguageListViewController(_ viewController: LanguageListViewController) {let dismiss = Observable.merge([
viewController.didCancel,
viewController.didSelectLanguage.map { _ in }
])
dismiss
.subscribe(onNext: { [weak self] inself? .dismiss(animated:true) })
.disposed(by: viewController.disposeBag)
viewController.didSelectLanguage
.subscribe(onNext: { [weak self] inself? .currentLanguage =$0self? .reloadData() }) .disposed(by: viewController.disposeBag) } } }Copy the code
Proxy mode completed
LanguageListViewControllerDelegate became didSelectLanguage and didCancel two objects. We’re prepareLanguageListViewController (_ 🙂 method is used in the observation of the two objects to passive RepositoryListViewController events.
Next, we’ll refactor GithubService to return an observation object instead of using a callback block. After that, we’ll rewrite our view controller using the RxCocoa framework. RepositoryListViewController most of the code will be moved to setupBindings method, in this way we view controller logic to statement.
private func setupBindings() {// Refresh controlletReload = refreshControl. Rx. ControlEvent (. ValueChanged). AsObservable () / / every time to reload or currentLanguage is modified, All make new requests to the Github server.let repositories = Observable.combineLatest(reload.startWith(), currentLanguage) { _, language in return language }
.flatMap { [unowned self] in
self.githubService.getMostPopularRepositories(byLanguage: $0)
.observeOn(MainScheduler.instance)
.catchError { error in
self.presentAlert(message: error.localizedDescription)
return .empty()
}
}
.do(onNext: { [weak self] _ inself? . RefreshControl. EndRefreshing ()}) / / bind warehouse data as the data source list view. .bind(to: tableView.rx.items(cellIdentifier:"RepositoryCell", cellType: RepositoryCell.self)) { [weak self] (_, repo, cell) inself? SetupRepositoryCell (cell, repository: repo)}. Disposed (by: disposeBag) // Bind the current language to the navigation bar title. currentLanguage .bind(to: navigationItem.rx.title) .disposed(by: DisposeBag) // Subscribe to the table cell selection operation and then call the 'openRepository' operation on each Item. tableView.rx.modelSelected(Repository.self) .subscribe(onNext: { [weak self]inself? .openRepository($0Disposed (by: disposeBag) // Subscribe to the click of the button, and then call the 'openLanguageList' operation on each Item. chooseLanguageButton.rx.tap .subscribe(onNext: { [weak self]inself? .openLanguageList() }) .disposed(by: disposeBag) }Copy the code
A declarative description of view controller logic
Instead of implementing list-view proxy object methods and data source object methods in the view controller, we can now change our state changes to a mutable topic.
fileprivate letCurrentLanguage = BehaviorSubject (value: "Swift")Copy the code
results
We have refactored the sample application using the RxSwift and RxCocoa frameworks. So what does this really do us?
- All logic is written declaratively in the same place.
- We deal with state changes by observing and responding.
- We used the syntax sugar of RxCocoa to set up the data source and proxy for the list view briefly.
Our code is still untestable, and the view controller still has a lot of logic. Let’s look at the next component of our architecture.
MVVM
MVVM is the UI architecture pattern of the Model-View-X family. MVVM is similar to standard MVC except that it defines a new component, the ViewModel, which allows for a better separation of the UI from the model. Essentially, a ViewModel is an object that represents a separate view UIKit.
The sample project is in the MvVM-rx Folder.
First, let’s create a View Model that will prepare the Model data to be displayed in the View:
class RepositoryViewModel {
let name: String
let description: String
let starsCountText: String
let url: URL
init(repository: Repository) {
self.name = repository.fullName
self.description = repository.description
self.starsCountText = "⭐ ️ \ (repository. StarsCount)"self.url = URL(string: repository.url)! }}Copy the code
Next, we will take all the variables and data format code from RepositoryListViewController move to RepositoryListViewModel:
Class RepositoryListViewModel {// MARK: - Input /// set the current language, reload the repository.let setCurrentLanguage: AnyObserver<String> /// Selected language.letChooseLanguage: AnyObserver<Void> // the selected repository.letSelectRepository: AnyObserver<RepositoryViewModel> // reload the repository.letReload: AnyObserver<Void> // MARK: - output /// get the repository array.letRepositoryViewModel: Observable<[RepositoryViewModel]> // Navigation item title.letTitle: Observable<String> // error message displayed.letAlertMessage: Observable<String> /// Displays the home URL of the repository.letShowRepository: Observable<URL> /// displays a list of languages.let showLanguageList: Observable<Void>
init(initialLanguage: String, githubService: GithubService = GithubService()) {
let _reload = PublishSubject<Void>()
self.reload = _reload.asObserver()
let _currentLanguage = BehaviorSubject<String>(value: initialLanguage)
self.setCurrentLanguage = _currentLanguage.asObserver()
self.title = _currentLanguage.asObservable()
.map { "\ [$0)" }
let _alertMessage = PublishSubject<String>()
self.alertMessage = _alertMessage.asObservable()
self.repositories = Observable.combineLatest( _reload, _currentLanguage) { _, language in language }
.flatMapLatest { language in
githubService.getMostPopularRepositories(byLanguage: language)
.catchError { error in
_alertMessage.onNext(error.localizedDescription)
return Observable.empty()
}
}
.map { repositories in repositories.map(RepositoryViewModel.init) }
let _selectRepository = PublishSubject<RepositoryViewModel>()
self.selectRepository = _selectRepository.asObserver()
self.showRepository = _selectRepository.asObservable()
.map { $0.url }
let _chooseLanguage = PublishSubject<Void>()
self.chooseLanguage = _chooseLanguage.asObserver()
self.showLanguageList = _chooseLanguage.asObservable()
}
}Copy the code
Now, our View controller delegates all UI interactions (such as button clicks or row selections) to the View Model and watches the View Model output data or events (like showLanguageList).
We will do the same thing for LanguageListViewController, it seems that everything is going well. But our test folder is still empty! The introduction of View Models allowed us to test a lot of code. Because the ViewModels purely use injected dependencies to transform input into output. ViewModels and unit tests are our best friends in applications.
We will test the application using the RxTest framework that comes with RxSwift. The most important part is the TestScheduler class, which allows you to create fake observable values by defining when values should be emitted. This is how we tested the View Models:
func test_SelectRepository_EmitsShowRepository() {
let repositoryToSelect = RepositoryViewModel(repository: testRepository) // create a dummy observation variable after 300 seconds countdownlet selectRepositoryObservable = testScheduler.createHotObservable([next(300, RepositoryToSelect)] / / bind selectRepositoryObservable input selectRepositoryObservable. Bind (to: The viewModel. SelectRepository). Disposed (by: disposeBag) / / subscribe showRepository output value and starttestScheduler
let result = testScheduler.start { self.viewModel.showRepository.map { $0XCTAssertEqual(result.events, [next(300,"https://www.apple.com")])}Copy the code
results
Well, we’ve moved from MVC to MVVM. But what’s the difference?
- View controller is lighter;
- Separate the logic of data processing from the view controller;
- MVVM makes our code testable;
Our View Controllers and the existence of a problem – RepositoryListViewController know LanguageListViewController and manages the navigation flow. Let’s use Coordinators to solve it.
Coordinators
If you haven’t heard Coordinators yet, I highly recommend you read Soroush Khanlou [this amazing blog] (khanlou.com/2015/10/coo…
In short, Coordinators are objects that control the navigation flow of our application. They help:
- Decouple and reuse ViewControllers;
- Pass dependencies to the navigation hierarchy;
- Define use cases for the application;
- Deep link;
Coordinators process
The figure shows a typical Coordinators process for an application. The App Coordinator checks whether a valid access token exists and decides to display the next coordinator-Login or Tab Bar. TabBar coordinators display three child Coordinators that correspond to TabBar items.
We are finally at the end of our refactoring process. The completed project is located in the Coordinators- MvVM-Rx directory. What has changed?
First, let’s look at what a base ordinator is:
Method based on ` start ` / / / the return type of the class BaseCoordinator < ResultType > {/ / / Typealias allowed by ` CoordinatorName. CoordinationResult ` method Coordainator return type TypeAlias CoordinationResult = ResultType // subclass callable 'DisposeBag' functionletDisposeBag = disposeBag () /// Special identifier privateletIdentifier = UUID() /// The dictionary of the child Coordinators. Each coordinator should be added to the dictionary so that it can be temporarily stored in memory. /// The Key is an identifier for the sub-coordinator, and the corresponding value is the coordinator itself. /// The value type is' Any 'because Swift does not allow generic values to be stored in arrays. private var childCoordinators = [UUID: /// The coordinators can be used to create coordinators. // The coordinators can be used to create coordinators. BaseCoordinator<T>) {childCoordinators[coordinator.identifier] = coordinator} /// Release from the dictionary 'childCoordinators' coordinator private func free<T>(coordinator: BaseCoordinator<T>) { childCoordinators[coordinator.identifier] = nil } /// 1. Coordinators store coordinators in the dictionary /// 2. Call coordinator's 'start()' function /// 3. After returning the 'start()' function of the observation variable, remove the coordinator from the dictionary in the 'onNext:' method. func coordinate<T>(to coordinator: BaseCoordinator<T>) -> Observable<T> { store(coordinator: coordinator)return coordinator.start()
.do(onNext: { [weak self] _ inself? . Free (coordinator: coordinator)})} /// The coordinator starts working. /// /// - Returns: Result of coordinator job. func start() -> Observable<ResultType> { fatalError("Start method should be implemented.")}}Copy the code
Basic Coordinator
This generic object provides three functions for a specific Coordinator:
- Abstract method to start coordinator work (that is, render the view controller)
start()
; - It is called from a passing sub-coordinator
start()
And keep it in memorycoordinate(to: )
; - Used by subclasses
disposeBag
;
Why does *start* return a *Observable*, and what is *ResultType**?
ResultType indicates the type of the coordinator work result. More resultTypes will be Void, but in some cases it will be an enumeration of possible result cases. Start will issue only one result item and complete.
We have three Coordinators in the application:
- Coordinators are the root of the hierarchy
AppCoordinator
; - RepositoryListCoordinator `;
LanguageListCoordinator
.
Let’s look at how the last Coordinator communicates with the ViewController and ViewModel and handles the navigation flow:
/// Used to define the type of possible coordinator results for 'LanguageListCoordinator' /// /// - language: the selected language. /// -cancel: The cancel button is clicked. enum LanguageListCoordinationResult {case language(String)
case cancel
}
class LanguageListCoordinator: BaseCoordinator<LanguageListCoordinationResult> {
private letrootViewController: UIViewController init(rootViewController: UIViewController) { self.rootViewController = rootViewController } override func start() -> Observable<CoordinationResult> {// Initializes an attempt controller from the storyboard and puts it on the UINavigationController stack.let viewController = LanguageListViewController.initFromStoryboard(name: "Main")
letnavigationController = UINavigationController(rootViewController: ViewController) // Initialize the View Model and inject it into the View ControllerletViewModel = LanguageListViewModel() viewController.viewModel = viewModel // Map the output of ViewModel to LanguageListCoordinationResult typelet cancel = viewModel.didCancel.map { _ in CoordinationResult.cancel }
let language = viewModel.didSelectLanguage.map { CoordinationResult.language($0} // Place the current attempted controller on the provided rootViewController. rootViewController.present(navigationController, animated:true) // Merge the mapping output of the View Model, get only the first event sent, and close the attempt controller for that eventreturn Observable.merge(cancel, language)
.take(1)
.do(onNext: { [weak self] _ inself? .rootViewController.dismiss(animated:true)}}}Copy the code
LanguageListCoordinator can work as a result of the selected language, or it can be invalid if the user clicks the cancel button. Both of which are defined in the LanguageListCoordinationResult enumeration.
In RepositoryListCoordinator, drawn to the display of us through LanguageListCoordinator showLanguageList output. After the start() method of LanguageListCoordinator completes, we filter the results and, if a language is selected, we call the View Model’s setCurrentLanguage method as an argument.
override func start() -> Observable<Void> { ... / / test request results to display a list of the viewModel. ShowLanguageList. FlatMap {[weak self] _ - > observables < String? >in
guard let `self` = self else { return .empty() }
// Start next coordinator and subscribe on it'result return self.showLanguageList(on: viewController)} // Ignore nil, which means the language-list page is dismissed. Filter {$0! = nil } .map { $0! } .bind(to: viewModel.setCurrentLanguage) .disposed(by: disposeBag) ... // return 'observable. never()', Because RepositoryListViewController this controller has been showed that the return of observables. Never ()} / / start LanguageListCoordinator / / Returns nil if you cancel or select a selected language private func showLanguageList(on rootViewController: UIViewController) -> Observable
{ let languageListCoordinator = LanguageListCoordinator(rootViewController: rootViewController) return coordinate(to: languageListCoordinator) .map { result in switch result { case .language(let language): return language case .cancel: return nil } } }
?>Copy the code
Note that we return * observable.never ()* because the repository list page is always inside the view stack.
The results of
We completed our final refactoring, we did:
- Decouple navigation logic out of view controller;
- Inject the viewmodel into the view controller;
- Simplified storyboards;
In a bird ‘s-eye view, our system looks like this:
The application’s Coordinator manager starts the first Coordinator to initialize the View Model, which is then injected into the View controller and displayed. The View controller sends user events like button clicks and cell sections to the View Model. The View Model provides the processed data back to the View controller and invokes the Coordinator to go to the next page. Of course, coordinators can also send events to the View Model for processing.
conclusion
We have considered a lot: the MVVM we discussed described the UI structure, used Coordinators to solve the navigation/routing problem, and declaratively modified the code using RxSwift. We refactor the application step by step and show the impact of each step.
There are no shortcuts to building an application. Each solution has its own disadvantages and may not be suitable for your application. The choice of application structure focuses on the trade-offs of a particular situation.
Of course, Rx, Coordinators and MVVM have more use scenarios than they did before, so be sure to let me know if you want me to write another blog that goes deeper into boundary conditions and troubleshooting.
Thanks for reading!
Myronenko, UPTech Group ❤️
If you think this blog post will help you, click 💚 * below to get more people to read it. Follow us to learn more about building quality products.
The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, React, front-end, back-end, product, design and other fields. If you want to see more high-quality translation, please continue to pay attention to the Project, official Weibo, Zhihu column.