在本文中,我将通过MVVM、Coordinators架构和RxSwift完成一个示例。
刚开始我先用MVC来构建,然后将一步一步地进行重构,以展示每个组件如何影响代码,以及结果如何,并配有简短的理论文字说明,主要是看看如何通过MVVM、Coordinators架构和RxSwift构建起项目。
示例内容
该示例应用程序如下图,它按照开发语言来显示在GitHub上最流行的开源库的列表。它有两个页面:一个是按语言过滤的开源库列表,另一个是开发语言列表:
用户可以通过点击导航栏中的一个按钮来显示第二个界面。在选择“语言”界面上,他可以选择一种语言,或者点击“取消”按钮来返回。如果用户选择了一种语言,就会跳到开源库界面,开源库列表将根据选择的语言进行更新。
好了,知道我们最终要做的是什么了,接下来,我们先用MVC来构建,然后一步一步用MVVM模式,Coordinators架构和RxSwift来重构它。
用MVC模式搭建
首先,创建两个视图控制器:RepositoryListViewController和LanguageListViewController。第一个展示当下最流行的开源库列表,第二个显示开发语言列表。RepositoryListViewController是LanguageListViewController的委托代理,并且遵循以下协议:
protocol LanguageListViewControllerDelegate: class {
func languageListViewController(_ viewController: LanguageListViewController,
didSelectLanguage language: String)
func languageListViewControllerDidCancel(_ viewController: LanguageListViewController)
}
RepositoryListViewController也是tableView的委托和数据源。它处理界面之间的导航切换、格式化需要显示的模型数据以及执行网络请求。这样导致大量的代码堆在viewController中!
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
tableView.delegate = self
setupUI()
reloadData()
refreshControl.addTarget(self, action: #selector(RepositoryListViewController.reloadData), for: .valueChanged)
}
@objc
fileprivate func reloadData() {
refreshControl.beginRefreshing()
navigationItem.title = currentLanguage
githubService.getMostPopularRepositories(byLanguage: currentLanguage) { [weak self] result in
self?.refreshControl.endRefreshing()
switch result {
case let .error(error):
self?.presentAlert(message: error.localizedDescription)
case let .success(newRepositories):
self?.repositories = newRepositories
self?.tableView.reloadData()
}
}
}
在LanguageListViewController选择语言:
extension LanguageListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let language = languages[indexPath.row]
delegate?.languageListViewController(self, didSelectLanguage: language)
}
}
在RepositoryListViewController中通过代理方法刷新数据:
extension RepositoryListViewController: LanguageListViewControllerDelegate {
func languageListViewController(_ viewController: LanguageListViewController, didSelectLanguage language: String) {
currentLanguage = language
reloadData()
dismiss(animated: true)
}
func languageListViewControllerDidCancel(_ viewController: LanguageListViewController) {
dismiss(animated: true)
}
此外,我们还需要在RepositoryListViewController中定义两个全局变量:currentLanguage和repositories。currentLanguage代表当前所选开发语言,我们要根据它来请求到开源库数据repositories。这些全局变量也会给类带来复杂性。
fileprivate var currentLanguage = "Swift"
fileprivate var repositories = [Repository]()
综上所述,我们目前的代码存在以下几个问题:
- 视图控制器承担太多的职责。
- 我们应该响应式地处理状态的变化。
- 代码可测试性太差。
用RxSwift来重构代码
第一步,我们用两个可观察对象: 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两个observables。我们在prepareLanguageListViewController(_:)
方法中使用它们来动态地观察RepositoryListViewController中的事件。
第二步,重构GithubService:
/// - Returns: a list of languages from GitHub.
func getLanguageList() -> Observable<[String]> {
// For simplicity we will use a stubbed list of languages.
return Observable.just([
"Swift",
"Objective-C",
"Java",
"C",
"C++",
"Python",
"C#"
])
}
/// - Parameter language: Language to filter by
/// - Returns: A list of most popular repositories filtered by langugage
func getMostPopularRepositories(byLanguage language: String) -> Observable<[Repository]> {
let encodedLanguage = language.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed)!
let url = URL(string: "https://api.github.com/search/repositories?q=language:\(encodedLanguage)&sort=stars")!
return session.rx
.json(url: url)
.flatMap { json throws -> Observable<[Repository]> in
guard
let json = json as? [String: Any],
let itemsJSON = json["items"] as? [[String: Any]]
else { return Observable.error(ServiceError.cannotParse) }
let repositories = itemsJSON.flatMap(Repository.init)
return Observable.just(repositories)
}
}
第三步,重写视图控制器,将控制器中大部分代码将转移到setupBindings函数,在这个函数中,我们声明性地描述了视图控制器的逻辑:
LanguageListViewController中:
private func setupBindings() {
let languages = githubService.getLanguageList()
languages
.bind(to: tableView.rx.items(cellIdentifier: "LanguageCell", cellType: UITableViewCell.self)) { (_, language, cell) in
cell.textLabel?.text = language
cell.selectionStyle = .none
}
.disposed(by: disposeBag)
tableView.rx.modelSelected(String.self)
.bind(to: _selectLanguage)
.disposed(by: disposeBag)
cancelButton.rx.tap
.bind(to: _cancel)
.disposed(by: disposeBag)
}
RepositoryListViewController中:
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().debug(), currentLanguage.debug()) { _, language in return language }
.debug()
.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)
}
可以看到通过RxSwift重构后,给我们带来了以下好处:
- 所有逻辑都声明式地写在一个地方。
- 我们可以对currentLanguage状态的变化实时的进行观察并响应。
- 我们使用了RxCocoa中的一些语法糖来设置tableView数据源和委托,省去了tableView大量的代理和数据源方法。
不过控制器中的代码依然还有很多,代码可测性还是很差,我们还需要进一步用MVVM进行重构。
MVVM
第一步,先创建一个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()
}
}
现在,视图控制器将所有UI交互(如按钮单击或行选择)委托给视图模型。然后对LanguageListViewController做同样的操作。
第三步,使用RxSwift附带的RxTest框架进行单元测试。
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。前后有哪些变化呢:
- 视图控制器代码更少了。
- 数据转模型逻辑与视图控制器解耦。
- 代码可测试性更高。
还有一个问题,RepositoryListViewController跟LanguageListViewController还是有关联,我们接下来解耦它们。
Coordinators
Coordinators用来做页面跳转的,把之前写在控制器中的跳转逻辑抽到一个类中。
上图显示了应用程序中典型的协调器流。应用程序协调器检查是否存在已存储的有效访问令牌,并决定下一步显示哪个协调器—Login或TabBar。TabBar协调器又有三个子协调器,它们对应于选项卡栏项。
第一步,创建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.")
}
}
BaseCoordinator为具体的协调器提供了两个特性:
- 方法
start()
启动协调器作业(即呈现视图控制器); - 方法
coordinate(to:)
在传递的子协调器上调用start()
并将其保存在内存中;
第二步,使用Coordinators 处理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) })
}
}
第三步,过滤处理数据,绑定视图模型的setCurrentLanguage:
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
}
}
}
目前为止,我们完成了重构的最后一个阶段:
- 将导航逻辑从视图控制器中抽离。
- 将视图模型注入视图控制器。
- 简化了storyboard。
小结
最后,我们现在整个项目架构可以用下图来表示:
网友评论