[譯]搞定超級複雜性:MVVM,Coordinators和RxSwift
原文地址:
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 doesnt 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 its 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 languageprivate 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我這裡還有很多沒有覆蓋的。所以請讓我知道是否想要我再來一篇關於極端情況,問題和解決方案的更加深入的文章。
謝謝你的閱讀!
推薦閱讀:
※免費資源丨用TensorFlow構建移動應用
※APP開發標準流程
※Activity生命周期里的「隱秘」
※如何根據你的網站創建一個移動 APP?
※一個渣碩iOS春招總結