美文网首页
RXSwift — 使用MVVM和Coordinators构建一

RXSwift — 使用MVVM和Coordinators构建一

作者: 沈枫_ShenF | 来源:发表于2019-06-18 10:32 被阅读0次

    在本文中,我将通过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。

    小结

    最后,我们现在整个项目架构可以用下图来表示:

    相关文章

      网友评论

          本文标题:RXSwift — 使用MVVM和Coordinators构建一

          本文链接:https://www.haomeiwen.com/subject/afxmzqtx.html