原文地址:
https://blog.uptech.team/taming-great-complexity-mvvm-coordinators-and-rxswift-8daf8a76e7fd
去年我们的团队在正式APP中开始使用Coordinators和MVVM。一开始很可怕,但是到现在为止,我们已经在上面架构的基础上做了4个APP了。在这篇文章中我将会分享我们的经验,引导你到MVVM,Coordinators和响应式编程中。
和在你面前给个定义不同的是,我们将以一个简单的MVC示例应用开始。我们将慢慢的一步一步的重构,从而展示每个组件怎样影响代码,以及他们的输出是什么样。每一步前面将有一个理论概述。
例子
在这篇文章中,我们将举一个根据语言展示一个github上最多star的库列表的例子。它有两页面:一个通过语言过滤的库的列表以及一个用于过滤的语言列表。
用户可以点击navigation Bar上的按钮展示第二个页面。在语言屏,可以选择一个语言或者点击cancel按钮dismiss页面。如果用户选择语言,页面将会dismiss,库列表将会根据选择的语言更新。
源码可以在这里找到。
库中包含4个文件夹,MVC,MVC-Rx,MVVM-Rx,Coordinators-MVVM-Rx,对应每一步的重构。让我们打开工程中的MVC文件夹看看重构之前的代码。
大部分的代码在两个view controller中:RepositoryListViewController,LanguageListViewController。第一个获取一个流行库的列表然后通过一个table来展示,第二个展示语言列表。RepositoryListViewController是LanguageListViewController的代理,遵循以下协议
protocol LanguageListViewControllerDelegate: class {
func languageListViewController(_ viewController: LanguageListViewController,
didSelectLanguage language: String)
func languageListViewControllerDidCancel(_ viewController: LanguageListViewController)
}
RepositoryListViewController 同时也是tableview的代理和数据源。它处理导航,格式化要展示的model数据,执行网络请求。啊!在一个view controller中有好多职责。
同时,你也发现两个全局变量,用于定义RepositoryListViewController的当前语言和库。这种状态变量给类引入了复杂性,这常常是bug之源:当我们的app某种我们没有预料的的状态的时候可能会挂掉。这种代码有这么些问题:
- ViewController有太多的职责
- 我们需要处理状态变化的交互
- 代码完全不可测试
是时候见见我们第一个客人了
RxSwift
这个组件将允许我们响应变化以及写声明式代码。
那么,什么是Rx? 其中一个定义是:
Reactive X是通过使用观察序列的用于异步和基于事件编程的库。
如果不不熟悉函数式编程或者这个定义听起来像是火箭科学(我也一样搞不懂),你也可以暂时极端的认为Rx就是个观察者模式。想要了解跟多的话,可以看这两本书:Getting Started guide和RxSwift Book 。
让我们打开库中MVC-Rx工程来看看Rx是怎样改变代码的。我们将从Rx最明显的事情开始---我们用两个observables:didCancel,didSelectLanguage 代替LanguageListViewControllerDelegate。
/// Shows a list of languages.
class LanguageListViewController: UIViewController {
private let _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)
}
}
/// Shows a list of the most starred repositories filtered by a language.
class RepositoryListViewController: UIViewController {
/// Subscribes on the `LanguageListViewController` observables before navigation.
///
/// - Parameter viewController: `LanguageListViewController` to prepare.
private func prepareLanguageListViewController(_ viewController: LanguageListViewController) {
// We need to dismiss the LanguageListViewController if a language was selected or if a cancel button was tapped.
let dismiss = Observable.merge([
viewController.didCancel,
viewController.didSelectLanguage.map { _ in }
])
dismiss
.subscribe(onNext: { [weak self] in self?.dismiss(animated: true) })
.disposed(by: viewController.disposeBag)
viewController.didSelectLanguage
.subscribe(onNext: { [weak self] in
self?.currentLanguage = $0
self?.reloadData()
})
.disposed(by: viewController.disposeBag)
}
}
}
LanguageListViewControllerDelegate变成了 didSelectLanguage 和 didCancel可观察对象。我们在 prepareLanguageListViewController(_: ) 方法中响应观察 RepositoryListViewController事件。
接下来,我们将重构 GithubService 来返回 observables 而不是使用callback。之后,我们将使用RxCocoa faramework来重写我们的ViewController。当我们声明式的在ViewController中描述逻辑的时候,大部分RepositoryListViewController的代码将移到setupBindings函数中:
private func setupBindings() {
// Refresh control reload events
let reload = refreshControl.rx.controlEvent(.valueChanged)
.asObservable()
// Fires a request to the github service every time reload or currentLanguage emits an item.
// Emits an array of repositories - result of request.
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] _ in self?.refreshControl.endRefreshing() })
// Bind repositories to the table view as a data source.
repositories
.bind(to: tableView.rx.items(cellIdentifier: "RepositoryCell", cellType: RepositoryCell.self)) { [weak self] (_, repo, cell) in
self?.setupRepositoryCell(cell, repository: repo)
}
.disposed(by: disposeBag)
// Bind current language to the navigation bar title.
currentLanguage
.bind(to: navigationItem.rx.title)
.disposed(by: disposeBag)
// Subscribe on cell selection of the table view and call `openRepository` on every item.
tableView.rx.modelSelected(Repository.self)
.subscribe(onNext: { [weak self] in self?.openRepository($0) })
.disposed(by: disposeBag)
// Subscribe on thaps of che `chooseLanguageButton` and call `openLanguageList` on every item.
chooseLanguageButton.rx.tap
.subscribe(onNext: { [weak self] in self?.openLanguageList() })
.disposed(by: disposeBag)
}
现在,我们在ViewController中去掉了tableView的代理和数据源方法,移动我们的状态到一个可变Subject中:
fileprivate let currentLanguage = BehaviorSubject(value: “Swift”)
效果
我们用RxSwift和RxCocoa framework重构了示例代码。所以真正的给我们带来什么呢?
- 所有的逻辑就都声明式的卸载一个地方。
- 我们把状态缩减到一个当前语言的Subject中。我们可以观察和响应这个Subject的变化。
- 我们使用RxCocoa的一些语法糖简洁明了的设置tableView的数据源和代理。
目前我们代码依旧不可测,ViewController依旧承当了太多的职责。让我看看我们架构的下一个组件。
MVVM
MVVM是Model-VIew-X家族的一个UI架构模式。MVVM和标准的MVC很像,只是它定义了一个新的组件--ViewModel。这个组件更好的解耦了UI和Model。本质上,ViewModel是一个展示View且和UIKit无关的对象。
工程中的例子,在MVVM-Rx文件夹中。
首先,让我们造一个代表展示view数据的ViewModel:
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)!
}
}
接着,我们将移动所有RepositoryListViewController中的可变数据和格式代码到RepositoryListViewModel里:
class RepositoryListViewModel {
// MARK: - Inputs
/// Call to update current language. Causes reload of the repositories.
let setCurrentLanguage: AnyObserver<String>
/// Call to show language list screen.
let chooseLanguage: AnyObserver<Void>
/// Call to open repository page.
let selectRepository: AnyObserver<RepositoryViewModel>
/// Call to reload repositories.
let reload: AnyObserver<Void>
// MARK: - Outputs
/// Emits an array of fetched repositories.
let repositories: Observable<[RepositoryViewModel]>
/// Emits a formatted title for a navigation item.
let title: Observable<String>
/// Emits an error messages to be shown.
let alertMessage: Observable<String>
/// Emits an url of repository page to be shown.
let showRepository: Observable<URL>
/// Emits when we should show language list.
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()
}
}
现在,我们的viewController代理所有的UI交互,诸如按钮点击,行的选择等到ViewModel上,然后观察ViewModel的数据和事件(如showLanguageList)的输出。
同样的操作到LanguageListViewController上。看起来我们走对路了。但是我们的测试文件夹依旧是空的!ViewModel的引入让我们能够测试大块大块的代码。因为我们的ViewModel纯粹是使用注入依赖转换输入到输出。单元测试依旧是我们的好朋友。
我们将使用RxSwift自带的RxTest框架测试我们的应用。最重要的部分是TestScheduler类。允许你在将要发送事件的时候根据定义创建一个假的观察者。下面是我们怎样测试ViewModel:
func test_SelectRepository_EmitsShowRepository() {
let repositoryToSelect = RepositoryViewModel(repository: testRepository)
// Create fake observable which fires at 300
let selectRepositoryObservable = testScheduler.createHotObservable([next(300, repositoryToSelect)])
// Bind fake observable to the input
selectRepositoryObservable
.bind(to: viewModel.selectRepository)
.disposed(by: disposeBag)
// Subscribe on the showRepository output and start testScheduler
let result = testScheduler.start { self.viewModel.showRepository.map { $0.absoluteString } }
// Assert that emitted url es equal to the expected one
XCTAssertEqual(result.events, [next(300, "https://www.apple.com")])
}
效果
好了,我们从MVC转到了MVVM。但是,有什么不同呢?
- ViewController变瘦了。
- 数据格式化逻辑从ViewController中解耦了。
- MVVM让我们的代码可测。
虽然这还有一个问题--- RepositoryListViewController知道LanguageListViewController,管理着导航流。让我们用Coordinators来解决它。
Coordinators
如果你还没有听过Coordinators,我强烈推荐你读一下Soroush Khanlou写的这篇文章,能给你做一个很好的介绍。
简单来说,Coordinators是用来控制导应用程序中航流的对象。它帮助我们:
- 隔离和重用ViewController
- 传递依赖到导航继承中
- 定义应用的用户场景
- 实现深度链接
上图中展示了典型应用的场景转换流。APP的Coordinator检查是否存储了access token然后决定接下来将展示哪个coordinator----登录还是Tabbar。Tabbar的coordinator显示三个子coordinator,和他的item一致。
最后我们看看重构过程的最终结果。放在Coordinators-MVVM-Rx 目录下。有什么不一样?
首先,让我们看看BaseCoordinator:
/// Base abstract coordinator generic over the return type of the `start` method.
class BaseCoordinator<ResultType> {
/// Typealias which will allows to access a ResultType of the Coordainator by `CoordinatorName.CoordinationResult`.
typealias CoordinationResult = ResultType
/// Utility `DisposeBag` used by the subclasses.
let disposeBag = DisposeBag()
/// Unique identifier.
private let identifier = UUID()
/// Dictionary of the child coordinators. Every child coordinator should be added
/// to that dictionary in order to keep it in memory.
/// Key is an `identifier` of the child coordinator and value is the coordinator itself.
/// Value type is `Any` because Swift doesn't allow to store generic types in the array.
private var childCoordinators = [UUID: Any]()
/// Stores coordinator to the `childCoordinators` dictionary.
///
/// - Parameter coordinator: Child coordinator to store.
private func store<T>(coordinator: BaseCoordinator<T>) {
childCoordinators[coordinator.identifier] = coordinator
}
/// Release coordinator from the `childCoordinators` dictionary.
///
/// - Parameter coordinator: Coordinator to release.
private func free<T>(coordinator: BaseCoordinator<T>) {
childCoordinators[coordinator.identifier] = nil
}
/// 1. Stores coordinator in a dictionary of child coordinators.
/// 2. Calls method `start()` on that coordinator.
/// 3. On the `onNext:` of returning observable of method `start()` removes coordinator from the dictionary.
///
/// - Parameter coordinator: Coordinator to start.
/// - Returns: Result of `start()` method.
func coordinate<T>(to coordinator: BaseCoordinator<T>) -> Observable<T> {
store(coordinator: coordinator)
return coordinator.start()
.do(onNext: { [weak self] _ in self?.free(coordinator: coordinator) })
}
/// Starts job of the coordinator.
///
/// - Returns: Result of coordinator job.
func start() -> Observable<ResultType> {
fatalError("Start method should be implemented.")
}
}
通用对象为实例coordinators提供三个特征:
- 抽象方法 start(),在这里启动coordinators的工作(诸如展示view controller)
- 通用方法 coordinate(to: ) 用于在传给子coordinator的时候调用 start(),然后保存在内存中。
- disposeBag,给子类用。
为啥start方法返回一个Observable?ResultType又是什么?
ResultType是一个代表coordinator展示结果的类型。通常ResultType是Void类型,但是某些特定场景,可能是个枚举类型。start将在结果完成的时候发出。
在这个应用中,我们有三个Coordinators:
- AppCoordinator 根协调器。
- RepositoryListCoordinator
- LanguageListCoordinator
让我们看看最后一个怎么和ViewController及ViewModel 通讯的以及怎么处理导航流的:
/// Type that defines possible coordination results of the `LanguageListCoordinator`.
///
/// - language: Language was choosen.
/// - cancel: Cancel button was tapped.
enum LanguageListCoordinationResult {
case language(String)
case cancel
}
class LanguageListCoordinator: BaseCoordinator<LanguageListCoordinationResult> {
private let rootViewController: UIViewController
init(rootViewController: UIViewController) {
self.rootViewController = rootViewController
}
override func start() -> Observable<CoordinationResult> {
// Initialize a View Controller from the storyboard and put it into the UINavigationController stack
let viewController = LanguageListViewController.initFromStoryboard(name: "Main")
let navigationController = UINavigationController(rootViewController: viewController)
// Initialize a View Model and inject it into the View Controller
let viewModel = LanguageListViewModel()
viewController.viewModel = viewModel
// Map the outputs of the View Model to the LanguageListCoordinationResult type
let cancel = viewModel.didCancel.map { _ in CoordinationResult.cancel }
let language = viewModel.didSelectLanguage.map { CoordinationResult.language($0) }
// Present View Controller onto the provided rootViewController
rootViewController.present(navigationController, animated: true)
// Merge the mapped outputs of the view model, taking only the first emitted event and dismissing the View Controller on that event
return Observable.merge(cancel, language)
.take(1)
.do(onNext: { [weak self] _ in self?.rootViewController.dismiss(animated: true) })
}
}
LanguageListCoordinator 的工作结果可以是选择某语言或者用户点了取消按钮没选。两种情况都在LanguageListCoordinationResult枚举中定义。
在RepositoryListCoordinator中,我们通过LanguageListCoordinator的展示flatMap了showLanguageList的输出。当LanguageListCoordinator中的start()方法完成之后,我们过滤出结果,如果选择了语言,我们就把结果发送到setCurrentLanguage,输入到ViewModel中。
override func start() -> Observable<Void> {
...
// Observe request to show Language List screen
viewModel.showLanguageList
.flatMap { [weak self] _ -> Observable<String?> in
guard let `self` = self else { return .empty() }
// Start next coordinator and subscribe on it's result
return self.showLanguageList(on: viewController)
}
// Ignore nil results which means that Language List screen was dismissed by cancel button.
.filter { $0 != nil }
.map { $0! }
// Bind selected language to the `setCurrentLanguage` observer of the View Model
.bind(to: viewModel.setCurrentLanguage)
.disposed(by: disposeBag)
...
// We return `Observable.never()` here because RepositoryListViewController is always on screen.
return Observable.never()
}
// Starts the LanguageListCoordinator
// Emits nil if LanguageListCoordinator resulted with `cancel` or selected language
private func showLanguageList(on rootViewController: UIViewController) -> Observable<String?> {
let languageListCoordinator = LanguageListCoordinator(rootViewController: rootViewController)
return coordinate(to: languageListCoordinator)
.map { result in
switch result {
case .language(let language): return language
case .cancel: return nil
}
}
}
注意我们返回Observable.never(),因为库列表页面总是会在view继承树种。
效果
我们完成了最后的重构步骤,这里
- 把导航逻辑移出了ViewController,封装了他们。
- 设置ViewModel的注入到ViewController中
- 简化了storyboard
鸟瞰视角我们的系统像这个样子:
应用程序启动第一个Coordinator,初始化ViewModel。注入到ViewController中然后展示它。ViewController发送用户事件诸如按钮点击或者cell点击等给ViewModel。ViewModel提供格式化好的数据给ViewController,让Coordinator导航到另外一个页面。Coordinator也可以发送事件给ViewModel的输出。
结论
我们搞了很多:我们说了关于UI架构的MVVM,我们用Coordinators解决了导航/路由的问题,用RxSwift让我们的代码清晰。我们一步步的重构了我们的应用程序,展示每一个组件怎样影响最初的代码。
构建iOS应用程序架构的时候也没有银弹。每一个解决方案都有自己的缺点,不一定合适你的项目。选择架构是一个在你实际场景中平衡的事情。
当然,关于Rx,Coordinators和MVVM我这里还有很多没有覆盖的。所以请让我知道是否想要我再来一篇关于极端情况,问题和解决方案的更加深入的文章。
谢谢你的阅读!