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

胖淨在掘金發表於2017-09-04

MVVM, Coordinators 和 RxSwift 的抽絲剝繭

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

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

示例

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

Screens of the example app
Screens of the example app

使用者可以通過點選導航欄上的按鈕來進入第二個頁面。在這個開發語言列表裡,可以選擇一個語言或者通過點選取消按鈕來退出頁面。如果使用者在第二個頁面選擇了一個開發語言,頁面將會執行退出操作,而倉庫列表頁面也會根據已選的開發語言來進行內容重新整理。

你可以在下面的連結裡找到原始碼檔案:

這個倉庫包含四個資料夾:MVC,MVC-Rx,MVVM-Rx,Coordinators-MVVM-Rx。分別對應重構的每一個步驟。讓我們開啟 MVC folder 這個專案,然後在進行重構之前先看一下。

大部分的程式碼都在兩個檢視控制器中: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!

另外,你可以注意到 RepositoryListViewController 這個檔案的全域性範圍內有兩個變數:currentLanguagerepositories。這種狀態變數使得類變得複雜了起來,而如果應用出現了意料之外的崩潰,這也會是一種常見的 BUGS 來源。總而言之,當前的程式碼中存在著好幾個問題:

  • 檢視控制器包攬了太多的責任;
  • 我們需要被動地處理狀態的變化;
  • 程式碼不可測。

是時候去見一下我們新的客人了。

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前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章