RXSwift — 使用MVVM和Coordinators构建一

2019-06-18  本文已影响0人  沈枫_ShenF

在本文中,我将通过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重构后,给我们带来了以下好处:

不过控制器中的代码依然还有很多,代码可测性还是很差,我们还需要进一步用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为具体的协调器提供了两个特性:

第二步,使用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
            }
        }
}

目前为止,我们完成了重构的最后一个阶段:

小结

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

上一篇 下一篇

猜你喜欢

热点阅读