- 原文地址:Taming Great Complexity: MVVM, Coordinators and RxSwift
- 原文做者:Arthur Myronenko
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:jingzhilehuakai
- 校对者:cbangchen swants
去年,咱们的团队开始在生产应用中使用 Coordinators 和 MVVM。 起初看起来很可怕,可是从那时起到如今,咱们已经完成了 4 个基于这种模式开发的应用程序。在本文中,我将分享咱们的经验,并将指导你探索 MVVM, Coordinators 和响应式编程。前端
咱们将从一个简单的 MVC 示例应用程序开始,而不是一开始就给出一个定义。咱们将逐步进行重构,以显示每一个组件如何影响代码库以及结果如何。每一步都将以简短的理论介绍做为前提。react
在这篇文章中,咱们将使用一个简单的示例程序,这个程序展现了 GitHub 上不一样开发语言得到星数最多的库列表,并把这些库以星数多少进行排序。包含两个页面,一个是经过开发语言种类进行筛选的库列表,另外一个则是用来分类的开发语言列表。android
用户能够经过点击导航栏上的按钮来进入第二个页面。在这个开发语言列表里,能够选择一个语言或者经过点击取消按钮来退出页面。若是用户在第二个页面选择了一个开发语言,页面将会执行退出操做,而仓库列表页面也会根据已选的开发语言来进行内容刷新。ios
你能够在下面的连接里找到源代码文件:git
这个仓库包含四个文件夹:MVC,MVC-Rx,MVVM-Rx,Coordinators-MVVM-Rx。分别对应重构的每个步骤。让咱们打开 MVC folder 这个项目,而后在进行重构以前先看一下。github
大部分的代码都在两个视图控制器中:RepositoryListViewController
和 LanguageListViewController
。第一个视图控制器获取了一个最受欢迎仓库的列表,而后经过表格展现给了用户,第二个视图控制器则是展现了一个开发语言的列表。RepositoryListViewController
是 LanguageListViewController
的一个代理持有对象,遵循下面的协议:编程
protocol LanguageListViewControllerDelegate: class {
func languageListViewController(_ viewController: LanguageListViewController,
didSelectLanguage language: String)
func languageListViewControllerDidCancel(_ viewController: LanguageListViewController)
}复制代码
RepositoryListViewController
也是列表视图的代理持有对象和数据源持有对象。它处理导航事件,格式化可展现的 Model 数据以及执行网络请求。哇哦,一个视图控制器包揽了这么多的责任。
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!redux
另外,你能够注意到 RepositoryListViewController
这个文件的全局范围内有两个变量:currentLanguage
和 repositories
。这种状态变量使得类变得复杂了起来,而若是应用出现了意料以外的崩溃,这也会是一种常见的 BUGS 来源。总而言之,当前的代码中存在着好几个问题:swift
是时候去见一下咱们新的客人了。后端
这个组件将容许咱们被动的响应状态变化和写出声明式代码。
Rx 是什么?其中有一个定义是这样的:
ReactiveX 是一个经过使用可观察的序列来组合异步事件编码的类库。
若是你对函数编程不熟悉或者这个定义听起来像是火箭科学(对我来讲,仍是这样的),你能够把 Rx 想象成一种极端的观察者模式。关于更多的信息,你能够参考 开始指导 或者 RxSwift 书籍。
让咱们打开 仓库中的 MVC-RX 项目,而后看一下 Rx 是怎么改变代码的。咱们将从最广泛的 Rx 应用场景开始 - 咱们替换 LanguageListViewControllerDelegate
成为两个观测变量:didCancel
和 didSelectLanguage
。
/// 展现一个语言的列表。
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)
}
}
/// 展现一个经过开发语言来分类的仓库列表。
class RepositoryListViewController: UIViewController {
/// 在进行导航以前订阅 `LanguageListViewController` 观察对象。
private func prepareLanguageListViewController(_ viewController: LanguageListViewController) {
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
来返回观察对象以取代回调 block 的使用。在那以后,咱们将使用 RxCocoa 框架来重写咱们的视图控制器。RepositoryListViewController
的大部分代码将会被移动到 setupBindings
方法,在这个方法里面咱们来声明视图控制器的逻辑。
private func setupBindings() {
// 刷新控制
let reload = refreshControl.rx.controlEvent(.valueChanged)
.asObservable()
// 每次从新加载或 currentLanguage 被修改时,都会向 github 服务器发出新的请求。
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(to: tableView.rx.items(cellIdentifier: "RepositoryCell", cellType: RepositoryCell.self)) { [weak self] (_, repo, cell) in
self?.setupRepositoryCell(cell, repository: repo)
}
.disposed(by: disposeBag)
// 绑定当前语言为导航栏的标题。
currentLanguage
.bind(to: navigationItem.rx.title)
.disposed(by: disposeBag)
// 订阅表格的单元格选择操做而后在每个 Item 调用 `openRepository` 操做。
tableView.rx.modelSelected(Repository.self)
.subscribe(onNext: { [weak self] in self?.openRepository($0) })
.disposed(by: disposeBag)
// 订阅按钮的点击,而后在每个 Item 调用 `openLanguageList` 操做。
chooseLanguageButton.rx.tap
.subscribe(onNext: { [weak self] in self?.openLanguageList() })
.disposed(by: disposeBag)
}复制代码
视图控制器逻辑的声明性描述
如今咱们能够不用在视图控制器里面实现列表视图的代理对象方法和数据源对象方法了,也将咱们的状态变化更改为一种可变的主题。
fileprivate let currentLanguage = BehaviorSubject(value: “Swift”)复制代码
咱们已经使用 RxSwift 和 RxCocoa 框架来重构了示例应用。因此这种写法到底给咱们带来了什么好处呢?
咱们的代码仍然不可测试,而视图控制器也仍是有着不少的逻辑处理。让咱们来看看咱们的架构的下一个组成部分。
MVVM 是 Model-View-X 系列的 UI 架构模式。MVVM 与标准 MVC 相似,除了它定义了一个新的组件 - ViewModel,它容许更好地将 UI 与模型分离。本质上,ViewModel 是独立表现视图 UIKit 的对象。
示例项目在 MVVM-Rx folder.
首先,让咱们建立一个 View Model,它将准备在 View 中显示的 Model 数据:
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: - 输入
/// 设置当前语言, 从新加载仓库。
let setCurrentLanguage: AnyObserver<String>
/// 被选中的语言。
let chooseLanguage: AnyObserver<Void>
/// 被选中的仓库。
let selectRepository: AnyObserver<RepositoryViewModel>
/// 从新加载仓库。
let reload: AnyObserver<Void>
// MARK: - 输出
/// 获取的仓库数组。
let repositories: Observable<[RepositoryViewModel]>
/// navigation item 标题。
let title: Observable<String>
/// 显示的错误信息。
let alertMessage: Observable<String>
/// 显示的仓库的首页 URL。
let showRepository: Observable<URL>
/// 显示的语言列表。
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()
}
}复制代码
如今,咱们的视图控制器将全部 UI 交互(如按钮点击或行选择)委托给 View Model,并观察 View Model 输出数据或事件(像 showLanguageList
这样)。
咱们将为 LanguageListViewController
作一样的事情,看起来一切进展顺利。可是咱们的测试文件夹仍然是空的!View Models 的引入使咱们可以测试一大堆代码。由于 ViewModels 纯粹地使用注入的依赖关系将输入转换为输出。ViewModels 和单元测试是咱们应用程序中最好的朋友。
咱们将使用 RxSwift 附带的 RxTest 框架测试应用程序。最重要的部分是 TestScheduler
类,它容许你经过定义在什么时候应该发出值来建立假的可观察值。这就是咱们测试 View Models 的方式:
func test_SelectRepository_EmitsShowRepository() {
let repositoryToSelect = RepositoryViewModel(repository: testRepository)
// 倒计时 300 秒后建立一个假的观测变量
let selectRepositoryObservable = testScheduler.createHotObservable([next(300, repositoryToSelect)])
// 绑定 selectRepositoryObservable 的输入
selectRepositoryObservable
.bind(to: viewModel.selectRepository)
.disposed(by: disposeBag)
// 订阅 showRepository 的输出值并启动 testScheduler
let result = testScheduler.start { self.viewModel.showRepository.map { $0.absoluteString } }
// 断言判断结果的 url 是否等于预期的 url
XCTAssertEqual(result.events, [next(300, "https://www.apple.com")])
}复制代码
好啦,咱们已经从 MVC 转到了 MVVM。 可是二者有什么区别呢?
咱们的 View Controllers 还有一个问题 - RepositoryListViewController
知道 LanguageListViewController
的存在而且管理着导航流。让咱们用 Coordinators 来解决它。
若是你尚未听到过 Coordinators 的话,我强烈建议你阅读 Soroush Khanlou [这篇超赞的博客] (khanlou.com/2015/10/coo…
简而言之,Coordinators 是控制咱们应用程序的导航流的对象。 他们帮助的有:
Coordinators 流程
该图显示了应用程序中典型的 coordinators 流程。App Coordinator 检查是否存在有效的访问令牌,并决定显示下一个 coordinator - 登陆或 Tab Bar。TabBar Coordinator 显示三个子 coordinators,它们分别对应于 Tab Bar items。
咱们终于来到咱们的重构过程的最后。完成的项目位于 Coordinators-MVVM-Rx 目录下。有什么变化呢?
首先,咱们来看看 BaseCoordinator
是什么:
/// 基于 `start` 方法的返回类型
class BaseCoordinator<ResultType> {
/// Typealias 容许经过 `CoordinatorName.CoordinationResult` 方法获取 Coordainator 的返回类型
typealias CoordinationResult = ResultType
/// 子类可调用的 `DisposeBag` 函数
let disposeBag = DisposeBag()
/// 特殊标识符
private let identifier = UUID()
/// 子 coordinators 的字典。每个 coordinator 都应该被添加到字典中,以便暂存在内存里面
/// Key 是子 coordinator 的一个 `identifier` 标志,而对应的 value 则是 coordinator 自己。
/// 值类型是 `Any`,由于 Swift 不容许在数组中存储泛型的值。
private var childCoordinators = [UUID: Any]()
/// 在 `childCoordinators` 这个字典中存储 coordinator
private func store<T>(coordinator: BaseCoordinator<T>) {
childCoordinators[coordinator.identifier] = coordinator
}
/// 从 `childCoordinators` 这个字典中释放 coordinator
private func free<T>(coordinator: BaseCoordinator<T>) {
childCoordinators[coordinator.identifier] = nil
}
/// 1. 在存储子 coordinators 的字典中存储 coordinator
/// 2. 调用 coordinator 的 `start()` 函数
/// 3. 返回观测变量的 `start()` 函数后,在 `onNext:` 方法中执行从字典中移除掉 coordinator 的操做。
func coordinate<T>(to coordinator: BaseCoordinator<T>) -> Observable<T> {
store(coordinator: coordinator)
return coordinator.start()
.do(onNext: { [weak self] _ in self?.free(coordinator: coordinator) })
}
/// coordinator 的开始工做。
///
/// - Returns: Result of coordinator job.
func start() -> Observable<ResultType> {
fatalError("Start method should be implemented.")
}
}复制代码
基本 Coordinator
该通用对象为具体 coordinators 提供了三个功能:
start()
;start()
并将其保存在内存中的通用方法 coordinate(to: )
;disposeBag
;为何 *start*
方法返回一个 *Observable*
,什么又是 *ResultType*
* 呢?
ResultType
是表示 coordinator 工做结果的类型。更多的 ResultType
将是 Void
,但在某些状况下,它将会是可能的结果状况的枚举。start
将只发出一个结果项并完成。
咱们在应用程序中有三个 Coordinators:
AppCoordinator
;LanguageListCoordinator
。让咱们看看最后一个 Coordinator 如何与 ViewController 和 ViewModel 进行通讯,并处理导航流程:
/// 用于定义 `LanguageListCoordinator` 可能的 coordinator 结果的类型.
///
/// - language: 被选择的语言。
/// - cancel: 取消按钮被点击。
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> {
// 从 storyboard 初始化一个试图控制器,并将其放入到 UINavigationController 堆栈中。
let viewController = LanguageListViewController.initFromStoryboard(name: "Main")
let navigationController = UINavigationController(rootViewController: viewController)
// 初始化 View Model 并将其注入 View Controller
let viewModel = LanguageListViewModel()
viewController.viewModel = viewModel
// 将 View Model 的输出映射到 LanguageListCoordinationResult 类型
let cancel = viewModel.didCancel.map { _ in CoordinationResult.cancel }
let language = viewModel.didSelectLanguage.map { CoordinationResult.language($0) }
// 将当前的 试图控制器放到提供的 rootViewController 上。
rootViewController.present(navigationController, animated: true)
// 合并 View Model 的映射输出,仅获取第一个发送的事件,并关闭该事件的试图控制器
return Observable.merge(cancel, language)
.take(1)
.do(onNext: { [weak self] _ in self?.rootViewController.dismiss(animated: true) })
}
}复制代码
LanguageListCoordinator 工做的结果能够是选定的语言,若是用户点击了“取消”按钮,也能够是无效的。这两种状况都在 LanguageListCoordinationResult
枚举中被定义。
在 RepositoryListCoordinator
中,咱们经过 LanguageListCoordinator
的显示来绘制 showLanguageList
的输出。在 LanguageListCoordinator
的 start()
方法完成后,咱们会过滤结果,若是有一门语言被选中了,咱们就将其做为参数来调用 View Model 的 setCurrentLanguage
方法。
override func start() -> Observable<Void> {
...
// 检测请求结果来展现列表
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) } // 忽略 nil 结果,这表明着语言列表的页面被 dismiss 掉了 .filter { $0 != nil } .map { $0! } .bind(to: viewModel.setCurrentLanguage) .disposed(by: disposeBag) ... // 这里返回 `Observable.never()`,由于 RepositoryListViewController 这个控制器一直都是显示的 return Observable.never() } // 启动 LanguageListCoordinator // 若是点击取消或者选择了一门已经被选择的语言的时候,返回 nil 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()*
由于仓库列表的页面一直都是在视图栈级结构里面的。
咱们完成了咱们最后一步的重构,咱们作了:
以鸟瞰图的方式,咱们的系统是长这样子的:
应用的 Coordinator 管理器启动了第一个 Coordinator 来初始化 View Model,而后注入到了视图控制器并进行了展现。视图控制器发送了相似按钮点击和 cell section 这样的用户事件到 View Model。而 View Model 则提供了处理过的数据回到视图控制器,而且调用 Coordinator 来进入下一个页面。固然,Coordinator 也能够传送事件到 View Model 进行处理。
咱们已经考虑到了不少:咱们讨论的 MVVM 对 UI 结构进行了描述,使用 Coordinators 解决了导航/路由的问题,而且使用 RxSwift 对代码进行了声明式改造。咱们一步步的对应用进行了重构,而且展现了每一步操做的影响。
构建一个应用是没有捷径的。每个解决方案都有其自身的缺点,不必定都适用于你的应用。进行应用结构的选择,重点在于特定状况的权衡利弊。
固然,相比以前而言,Rx,Coordinators 和 MVVM 相互结合的方式有更多的使用场景,因此请必定要让我知道,若是你但愿我写多一篇更深刻边界条件,疑难解答的博客的话。
感谢你的阅读!
做者 Myronenko, UPTech 小组 ❤️
若是你认为这篇博客能够帮助到你,点击下面的 💚 * 让更多人阅读它。粉一下咱们,以便了解更多关于构建优质产品的文章。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、React、前端、后端、产品、设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。