[譯] MVVM, Coordinators 和 RxSwift 的抽絲剝繭

MVVM, Coordinators 和 RxSwift 的抽絲剝繭

去年,咱們的團隊開始在生產應用中使用 Coordinators 和 MVVM。 起初看起來很可怕,可是從那時起到如今,咱們已經完成了 4 個基於這種模式開發的應用程序。在本文中,我將分享咱們的經驗,並將指導你探索 MVVM, Coordinators 和響應式編程。前端

咱們將從一個簡單的 MVC 示例應用程序開始,而不是一開始就給出一個定義。咱們將逐步進行重構,以顯示每一個組件如何影響代碼庫以及結果如何。每一步都將以簡短的理論介紹做爲前提。react

示例

在這篇文章中,咱們將使用一個簡單的示例程序,這個程序展現了 GitHub 上不一樣開發語言得到星數最多的庫列表,並把這些庫以星數多少進行排序。包含兩個頁面,一個是經過開發語言種類進行篩選的庫列表,另外一個則是用來分類的開發語言列表。android

Screens of the example app
Screens of the example app

用戶能夠經過點擊導航欄上的按鈕來進入第二個頁面。在這個開發語言列表裏,能夠選擇一個語言或者經過點擊取消按鈕來退出頁面。若是用戶在第二個頁面選擇了一個開發語言,頁面將會執行退出操做,而倉庫列表頁面也會根據已選的開發語言來進行內容刷新。ios

你能夠在下面的連接裏找到源代碼文件:git

這個倉庫包含四個文件夾:MVC,MVC-Rx,MVVM-Rx,Coordinators-MVVM-Rx。分別對應重構的每個步驟。讓咱們打開 MVC folder 這個項目,而後在進行重構以前先看一下。github

大部分的代碼都在兩個視圖控制器中:RepositoryListViewControllerLanguageListViewController。第一個視圖控制器獲取了一個最受歡迎倉庫的列表,而後經過表格展現給了用戶,第二個視圖控制器則是展現了一個開發語言的列表。RepositoryListViewControllerLanguageListViewController 的一個代理持有對象,遵循下面的協議:編程

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 這個文件的全局範圍內有兩個變量:currentLanguagerepositories。這種狀態變量使得類變得複雜了起來,而若是應用出現了意料以外的崩潰,這也會是一種常見的 BUGS 來源。總而言之,當前的代碼中存在着好幾個問題:swift

  • 視圖控制器包攬了太多的責任;
  • 咱們須要被動地處理狀態的變化;
  • 代碼不可測。

是時候去見一下咱們新的客人了。後端

RxSwift

這個組件將容許咱們被動的響應狀態變化和寫出聲明式代碼。

Rx 是什麼?其中有一個定義是這樣的:

ReactiveX 是一個經過使用可觀察的序列來組合異步事件編碼的類庫。

若是你對函數編程不熟悉或者這個定義聽起來像是火箭科學(對我來講,仍是這樣的),你能夠把 Rx 想象成一種極端的觀察者模式。關於更多的信息,你能夠參考 開始指導 或者 RxSwift 書籍

讓咱們打開 倉庫中的 MVC-RX 項目,而後看一下 Rx 是怎麼改變代碼的。咱們將從最廣泛的 Rx 應用場景開始 - 咱們替換 LanguageListViewControllerDelegate 成爲兩個觀測變量:didCanceldidSelectLanguage

/// 展現一個語言的列表。
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 變成了 didSelectLanguagedidCancel 兩個對象。咱們在 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 框架來重構了示例應用。因此這種寫法到底給咱們帶來了什麼好處呢?

  • 全部邏輯都是被聲明式地寫到了同一個地方。
  • 咱們經過觀察和響應的方式來處理狀態的變化。
  • 咱們使用 RxCocoa 的語法糖來簡短明瞭地設置列表視圖的數據源和代理。

咱們的代碼仍然不可測試,而視圖控制器也仍是有着不少的邏輯處理。讓咱們來看看咱們的架構的下一個組成部分。

MVVM

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。 可是二者有什麼區別呢?

  • 視圖控制器更輕量化;
  • 數據處理的邏輯與視圖控制器分離;
  • MVVM 使咱們的代碼能夠測試;

咱們的 View Controllers 還有一個問題 - RepositoryListViewController 知道 LanguageListViewController 的存在而且管理着導航流。讓咱們用 Coordinators 來解決它。

Coordinators

若是你尚未聽到過 Coordinators 的話,我強烈建議你閱讀 Soroush Khanlou [這篇超讚的博客] (khanlou.com/2015/10/coo…

簡而言之,Coordinators 是控制咱們應用程序的導航流的對象。 他們幫助的有:

  • 解耦和重用 ViewControllers;
  • 將依賴關係傳遞給導航層次;
  • 定義應用程序的用例;
  • 實現深度連接;

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 提供了三個功能:

  • 啓動 coordinator 工做(即呈現視圖控制器)的抽象方法 start()
  • 在經過的子 coordinator 上調用 start() 並將其保存在內存中的通用方法 coordinate(to: )
  • 被子類使用的 disposeBag

爲何 *start* 方法返回一個 *Observable*,什麼又是 *ResultType** 呢?

ResultType 是表示 coordinator 工做結果的類型。更多的 ResultType 將是 Void,但在某些狀況下,它將會是可能的結果狀況的枚舉。start 將只發出一個結果項並完成。

咱們在應用程序中有三個 Coordinators:

  • Coordinators 層級結構的根 AppCoordinator
  • RepositoryListCoordinator`;
  • 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 的輸出。在 LanguageListCoordinatorstart() 方法完成後,咱們會過濾結果,若是有一門語言被選中了,咱們就將其做爲參數來調用 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()* 由於倉庫列表的頁面一直都是在視圖棧級結構裏面的。

結果

咱們完成了咱們最後一步的重構,咱們作了:

  • 把導航欄的邏輯移除出了視圖控制器,進行了解耦;
  • 將視圖模型注入到視圖控制器中;
  • 簡化了故事板;

以鳥瞰圖的方式,咱們的系統是長這樣子的:

MVVM-C 架構設計
MVVM-C 架構設計

應用的 Coordinator 管理器啓動了第一個 Coordinator 來初始化 View Model,而後注入到了視圖控制器並進行了展現。視圖控制器發送了相似按鈕點擊和 cell section 這樣的用戶事件到 View Model。而 View Model 則提供了處理過的數據回到視圖控制器,而且調用 Coordinator 來進入下一個頁面。固然,Coordinator 也能夠傳送事件到 View Model 進行處理。

結論

咱們已經考慮到了不少:咱們討論的 MVVM 對 UI 結構進行了描述,使用 Coordinators 解決了導航/路由的問題,而且使用 RxSwift 對代碼進行了聲明式改造。咱們一步步的對應用進行了重構,而且展現了每一步操做的影響。

構建一個應用是沒有捷徑的。每個解決方案都有其自身的缺點,不必定都適用於你的應用。進行應用結構的選擇,重點在於特定狀況的權衡利弊。

固然,相比以前而言,Rx,Coordinators 和 MVVM 相互結合的方式有更多的使用場景,因此請必定要讓我知道,若是你但願我寫多一篇更深刻邊界條件,疑難解答的博客的話。

感謝你的閱讀!


做者 Myronenko, UPTech 小組 ❤️


若是你認爲這篇博客能夠幫助到你,點擊下面的 💚 * 讓更多人閱讀它。粉一下咱們,以便了解更多關於構建優質產品的文章。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索