[译]搞定超级复杂性: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 guideRxSwift 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 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.")
    }
}

通用对象为实例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 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
            }
        }
}

注意我们返回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我这里还有很多没有覆盖的。所以请让我知道是否想要我再来一篇关于极端情况,问题和解决方案的更加深入的文章。

谢谢你的阅读!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容