Coordinator 与 RxSwift 共同构建的 MVVM 架构

栏目: 后端 · 发布时间: 7年前

内容简介:Coordinator 与 RxSwift 共同构建的 MVVM 架构

每个应用都需要拥有良好的架构。在本次 Mobilization 2016 讲演中,Łukasz 将向大家展示他在 iOS 项目中所使用的架构:由 Coordinator 与 RxSwift 共同构建的 MVVM 架构。他不仅将讲述关于架构的基础知识,还为大家提供了一个现场代码演示,描述架构的各个组成部分,如何使用 Coordinator 来控制逻辑流,如何使用 Quick/Nimble 来进行测试,以及如何使用 Moya 来进行网络请求。

我是 Łukasz ,是 Droids on Roids 的一名 iOS 工程师。我平日比较喜欢烧脑,因此今日我将和大家来谈论一下架构:MVVM + Coordinator + RxSwift,而这正需要大量的思考和实践。

这个架构基于 MVVM 架构,虽然这种模式在 Swift 当中还是一个比较新鲜的玩意儿,但是现在已经非常流行了。一年前在 NSSpain 大会上, Soroush Khanlou 介绍了这个架构,因此我强烈建议大家去观看一下 这个讲演

Coordinator 与 RxSwift 共同构建的 MVVM 架构

MVC 是最基础的架构;其中有视图 (View)、有模型 (Model),还有将视图与模型关联起来的控制器 (Controller)。在这个连接当中,我们从模型中提取数据,然后将数据传递给视图,以供视图来展示数据。视图同样也会传递用户动作的相关通知。

此外还存在一个网络层。 那么网络层应该放在哪里呢? 或许它应该放在控制器那个部分。此外还有 Core Data,它同样也位于控制器层。我们或许还会有相关的委托代理 (delegate) 以及导航 (navigation),因此我们可能会希望得到一个新的架构,其控制器层非常的简洁、干净。

Coordinator 与 RxSwift 共同构建的 MVVM 架构

MVVM 与 MVC 非常相似,但是其区别是 不存在视图控制器 。相反,MVVM 有一个全新的层级:视图模型 (ViewModel)。在 iOS 中,我们必须要使用控制器,因此您可能会觉得视图模型可能是视图控制器 + 视图的构成,也就是_ 一个实体分割成了两个文件 _。

如上所示,我们可以看出视图展示数据的方式和 MVC 当中相似。此外也有模型的存在,而视图模型则用来处理数据,并将其传递给视图。您或许注意到了,这里就没办法放置网络层和其余的相关结构代码了。

Coordinator 与 RxSwift 共同构建的 MVVM 架构

Coordinator 也是一种很好的架构,我从 Soroush Khanlou 那里知道了它。 Coordinator 本质上是一个用于控制应用中各种逻辑流 (flow) 的对象。比方说,它可以用来控制视图控制器的推入 (push) 与推出 (pop)。这也是 Coordinator 所具备的两大责任之一,另外一个则是注入 (injection)。

那么, 应用中的控制器应该是什么样子的呢? 我们应该至少需要一个 Coordinator ,它在 AppDelegate 当中启动,用以协调首屏的逻辑流。举个例子,假设我们的首屏上有这样一些按钮。比方说如果用户点击了「注册」按钮,那么我们就进入另一个控制器。我倾向于每个控制器都应该拥有一个 Coordinator ,因此我们还需要建立另一个 Coordinator 。

在「注册」页面当中,按钮本身也需要留出一些空间,因为可能还会有使用 Facebook 或者 Gmail 进行注册的选项。在我看来,这些注册的选项又需要其它的 Coordinator 来执行。

继续我们的示例吧,除了「注册」按钮之外,还应该有「登录」和「忘记密码」的按钮,因此在应用一开始,我们就有三种 Coordinator 了,如上述图片所示。解释这里的逻辑有些困难,因为存在很多的视图模型和 Coordinator ,它们之间的连接非常纷繁复杂。

如果您需要使用第三方函数式库的话,我个人建议大家使用 RxSwift,因为对其我很有经验。不过第三方响应式库还有很多,比方说 RxCocoa、RxSwift、Bond、Interstellar 等等。这里,我们将使用 RxSwift 来构建绑定。

如果您真的对函数式、响应式或者观察者模式不感兴趣的话,您可以从这个方面来看待 Rx:试想您需要将某种数据传递给模型或者视图控制器。现在,假设您需要将一组数据传递给某个对象。然而,这组数据可能只有一个数据,也可能会有多个数据,也可能完全没有数据。我会使用 BinHex 来观察视图模型拥有的这些值,并将其与视图进行绑定。这里我们就不必再使用「键值观察」委托代理了,这样就节省了大量的代码。

对于本次讲演的演示部分,我们将以一个名为 Kittygram 的应用为例。具体的代码请 查看 Github 仓库

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

	var window: UIWindow?

	func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    	window = UIWindow()

    	var rootViewController: UIViewController!
    	if arc4random_uniform(5) % 4 == 0 { // 神秘算法,请不要修改
        	rootViewController = PayMoneyPleaseViewController()
    	} else {
        	rootViewController = DashboardViewController()
    	}
    	window?.rootViewController = UINavigationController(rootViewController: rootViewController)
    	window?.makeKeyAndVisible()

    	return true
	}
}

Receive news and updates from Realm straight to your inbox

这个应用当中有很多各式各样的示例。本质上而言,我们单击其中一个项目之后,它就会跳转到另一个屏幕当中。这里我就不再详述具体的细节了。上面大家所看到的这一段代码是一个 简单 AppDelegate 的 MVC 架构 。在本例当中,我们使用了一种「神秘算法」来控制一开始出现的控制器。在本例当中,如果这条语句通过了,那么就说明我们将展示 Money 视图控制器;否则的话就展示 Dashboard 视图控制器。

Dashboard 视图控制器

现在我们来看一下 DashboardViewController ,这也是这个应用中最庞大的一个控制器。它其中包含了 Repository 数组、数据源注册以及各种相关的数据源和委托。我们同样还有 downloadRepositories 这个方法进行配置,以及展示警告视图以及其他 UI 方面的操作。

class DashboardViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

	@IBOutlet weak var tableView: UITableView!

	var repos = [Repository]()
	var provider = MoyaProvider<GitHub>()

	override func viewDidLoad() {
    	super.viewDidLoad()

    	let nib = UINib(nibName: "KittyTableViewCell", bundle: nil)
    	tableView.register(nib, forCellReuseIdentifier: "kittyCell")
    	tableView.dataSource = self
    	tableView.delegate = self

    	downloadRepositories("ashfurrow")
	}

	fileprivate func showAlert(_ title: String, message: String) {
    	let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
    	let ok = UIAlertAction(title: "OK", style: .default, handler: nil)
    	alertController.addAction(ok)
    	present(alertController, animated: true, completion: nil)
	}

	// MARK: - API Stuff
	
	func downloadRepositories(_ username: String) {
        provider.request(.userRepositories(username)) { result in
            switch result {
            case let .success(response):
                do {
                    let repos = try response.mapArray() as [Repository]
                    self.repos = repos
                } catch {

                }
                self.tableView.reloadData()
            case let .failure(error):
                guard let error = error as? CustomStringConvertible else {
                    break
                }
                self.showAlert("GitHub Fetch", message: error.description)
        }
    }
}

它将会从 Github 获取相关的 Repository,如果发生了错误,那么就可以弹出警告窗口。它其中同样包含了标准的 tableView.dataSource

PayMoneyPleaseController

此外还有另一个用来「支付」的控制器。

import UIKit

class PayMoneyPleaseViewController: UIViewController {

	@IBOutlet private weak var descriptionLabel: UILabel!

	override func viewDidLoad() {
	    super.viewDidLoad()

	    title = "筒子们好"
	    descriptionLabel.text = ":moneybag::money_with_wings:欲享受本应用的正常功能,我们恳请给予费用上的支持,谢谢~:moneybag::money_with_wings:"
	}
}

非常简单;其中只包含了一个用于描述信息的标签。

import UIKit

class KittyDetailsViewController: UIViewController {

	@IBOutlet private weak var descriptionLabel: UILabel!

	var kitty: Repository!

	convenience init(kitty: Repository) {
    	self.init()

    	self.kitty = kitty
	}

	override func viewDidLoad() {
    	super.viewDidLoad()

    	descriptionLabel.text = ":cat:" + kitty.name + ":cat:"
	}
}

这就是 KittyDetailsViewController 的模样了。我们将 Repository 传递进去,也就是模型,然后从 Github 进行下载,随后将 kitty.name 以及相关的两只猫咪 Emoji 展示出来。

模型

User 模型

import Mapper

struct User: Mappable {

	let login: String

	init(map: Mapper) throws {
   		try login = map.from("login")
	}
}

我们为 Github API 准备了一些模型。这里是 User 模型,非常简洁明了。

Repository 模型

import Mapper

struct Repository: Mappable {

	let identifier: Int?
	let language: String?
	let name: String
	let url: String?

	init(map: Mapper) throws {
    	identifier = map.optionalFrom("id")
    	name = map.optionalFrom("name") ?? "无名氏:crying_cat_face:"
    	language = map.optionalFrom("language")
    	url = map.optionalFrom("url")
	}
}

这个是 Repository 模型。我使用了 Lyft 的 Mapper ,非常好用。这个模型可以依据标识符来进行分解,不过所产生的数据都是可空的。在本例中,这个仓库没有获取到名字,因此我需要在详情中展示一些别的数据。

端点

点击这里 查看端点 (endpoint) 的相关代码。

端点是基于 Moya 构建的。我非常喜欢 Moya,因为它可以让代码非常灵活,可以不使用外部抽象的网络请求。在 Moya 中我可以使用插件、闭包等各式各样的花哨操作。

这本例中,我们需要停止网络请求,然后传递示例数据,主要是为了在没有网络的情况下继续模拟网络请求。

对于更复杂的应用而言,可能会有多个 Provider 的存在,在这种情况下,它们也应该作为依赖注入 (dependency injection) 提供给网络层。

Coordinator

import Foundation
import UIKit

class Coordinator {

	var childCoordinators: [Coordinator] = []
	weak var navigationController: UINavigationController?

	init(navigationController: UINavigationController?) {
    	self.navigationController = navigationController
	}
}

这就是 Coordinator 类,同时也是我们架构当中最重要的一个类。其中包含有 childCoordinators ,因此我们可以从中进行跳转。这里最有趣的便是导航控制器了。可以看到,这里它被标注为 weak 。在我们的示例当中,导航控制器必须是弱引用。假设有个应用只存在一个导航控制器,并且父级页面和下级页面都是相同的控制器,如果不这么做的话,就会导致循环引用的发生。这一点是非常重要的,使用弱引用还可以考虑到我们不使用导航控制器的情况。

谁该负责导航控制器的引用计数 (reference counter) 呢?

我们将用 MVVM 的风格来配置我们的 MVC。首先在委托当中,我们启用了一条逻辑流,而这本不应存在于此的,因此我们将使用 Coordinator 来处理它。

我们将创建一个新的 Coordinator ,以作为应用启动时所使用的基本 Coordinator 。

import UIKit

final class AppCoordinator: Coordinator {

	func start() {
		var viewController: UIViewController!
    	if arc4random_uniform(5) % 4 == 0 { // 神秘算法,请不要修改
        	viewController = PayMoneyPleaseViewController()
    	} else {
        	viewController = DashboardViewController()
    	}

		navigationController?.pushViewController(viewController, animated: true)
	}
}

这就是这个 Coordinator 的逻辑流;它本质上并不属于 AppDelegate ,因此我们来尝试修复一下我们破坏的 App Delegate。我们不能将 UI 导航传递给根控制器,因为我们并没有导航控制器,因此我们将创建另一个不带根控制器的导航控制器。我们将一个单独的导航控制器添加到这里以让 Xcode 不报错。随后我们就使用刚刚创建的导航控制。

我们的 AppDelegate 现在应该如下所示:

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

	var window: UIWindow?

	func application(_ application: UIApplication, didFinishLaunchingWithOptions 	launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    	window = UIWindow()

    	let navigationController = UINavigationController()
    	window?.rootViewController = navigationController
		let coordinator = AppCoordinator(navigationController: navigationController)
		coordinator.start()
    	window?.makeKeyAndVisible()

    	return true
	}
}

借此,我们便有了两条逻辑线:如果用户没有支付,那么就展示「请支付」的页面。而控制 Dashboard 的 Coordinator 则分割成两个新的 Coordinator 。我们将创建一个新的 Dashboard Coordinator 。

因此我们应用当中现在便拥有了两条逻辑线。让我们从最简单的开始,也就是「请付款」。

final class PayMoneyPleaseCoordinator: Coordinator {

	func start() {
		let viewController = PayMoneyPleaseViewController()
		let viewController = DashboardViewController(viewModel: viewModel)
		navigationController?.pushViewController(viewController, animated = true)
	}
}

这将会创建一个新的控制器,并将其推入到导航栈 (navigation stack) 当中。借此,我们便可以转向 Dashboard。

final class DashboardCoordinator: Coordinator {

	func start() {
		let viewController = DashboardViewController()
		navigationController?.pushViewController(viewController, animated = true)
	}
}

这同样也会将控制器推入到导航栈当中。随着这两个 Coordinator 的完成,我们现在的两条逻辑线便已经完善了。现在我们将逻辑线放到 AppCoordinator 当中,因此它便可以知晓该执行哪条逻辑线。

我们现在的 AppCoordinator 应该如下所示:

import UIKit

final class AppCoordinator: Coordinator {

	func start() {
		if arc4random_uniform(5) % 4 == 0 { // 神秘算法,请不要修改
			let coordinator = PayMOneyPleaseCoordinator(navigationController: navigationController)
			coordinator.start()
			childCoordinators.append(coordinator)
		} else {
			let coordinator = DashboardCoordinator(navigationController: navigationController)
			coordinator.start()
			childCoordinators.append(coordinator)
		}
	}
}

这是应用中只有一个 Coordinator 的情况,所以它会存在循环引用的情况。最重要的部分就是添加子 Coordinator ,从而保持对 Coordinator 的引用,以便其能够正常工作。

Dashboard 控制器重构

现在让我们回到 Dashboard 控制器来,我们打算对其进行重构,然后为这个类创建一个 Dashboard 视图模型。我们将创建一个新的组和新的模型。

我们可能需要制定一条准则,也就是每个 Coordinator 都需要创建一个视图模型,但是在某些情况下,所创建的视图模型远远不止一个。 如果视图控制器当中的多个视图需要使用不同的逻辑进行绑定的话,那么所需要的视图模型只多不少。

关于视图模型,我注意到一点是: 我们总是要为每一个视图模型都创建一个协议 。每将视图模型绑定到视图控制器的时候,往往可能想要修改逻辑,但是如果不让视图模型实现相关的协议的话,那么就可能会创建大量的构造器。这可能会使我们的架构变得极其复杂。以我的经验而言,我们需要为每个视图模型都创建一个协议。

现在我们需要创建一个针对 Dashboard 数据模型的协议类型。现在它里面没有填写任何东西,但是随着我们重构过程的进行,它将逐步得到填补。

在视图控制器中,有不少的网络访问的代码,而这些我们完全没必要放在视图控制器当中,因此我们需要将这些 API 从视图控制器当中移出,然后在 Dashboard 视图模型当中创建一个构造器,再将网络请求移到这里。为了更好的进行测试,我们将注释掉视图控制器当中的这段 UI 逻辑。我们还会将下载 Repository 的代码移到视图模型当中,因为这个操作会导致应用响应变慢。

网络请求将从网络当中获取相关的 Repository,然后将其传递给表视图。我们将使用一组对象来完成这个操作。如果您想尝试 RxSwift 的话,最简单的方法就是创建一个 Variable,也就是创建一个至少包含一个对象的 Sequence。这个 Variable 将用以存储 Repository 的相关信息,一开始它将是空的。此外,它还是命令式与响应式编程之间的桥梁所在。

这可能并不是很完美的 Rx 代码,因为完美的 Rx 代码是常人所理解不了的。

我们使用 value 属性在序列中创建一个新的 Sequence。 我的另一个经验所谈则是不要将 Variable 暴露出去 :因此我们将这个属性设置为私有的,使用 Xcode 的黑科技,即使用 lazy var 类型。我们将这个序列项命名为 reposeObservable 。我们永远不希望将 Variable 暴露出来,以防止有人从视图控制器中拿取数据来重新生成其他的 Sequence;我们最好就是规避这个风险。随后,我们将 reposVariable 添加到我们的协议当中。

以下是 Dashboard 视图模型的代码示例:

import RxSwift

protocol DashboardViewModelType {
	var reposeObservable: Observable<[Repository]> {get}
}

final class DashboardViewModel {

	private let reposVariable = Variable<[Repository]>([])

	lazy var reposObservable: Observable<[Repository]> = self.reposVariable.asObservable()

	intit() {
		downloadRepositories("ashfurrow")
	}

	func downloadRepositories(_ username: String) {
		provider.request(.userRepositories(username)) { result in
		switch result {
		case let .success(response):
			do {
				let repos = try response.mapArray() as [Repository]
				self.reposVariable.value = repos
			} catch {

			}
		case let .faliure(error):
			gaurd let error = error as? CustomStringConvertible else {
				break
			}
//				self.showAlert("GitHub Fetch", message: error.description)
		}
	}
}

我已经注释并删除掉了几行代码。在 RxSwift 当中,有一个 bind 函数可以对视图控制器进行操作,这样我们可以只使用闭包,让这个函数来填充所有的数据服务委托。

我们并不持有视图模型,而是使用独立注入 (independence injection) 的方式将其传递给我们的 Coordinator 。在 Dashboard Coordinator 当中,我们必须将视图模型传递进去。

在 Dashboard 视图控制器当中:

private var viewModel: DashboardViewModelType!
convenience init(viewModel: DashboardViewModelType) {
	self.init()

	self.viewModel = viewModel
}

我们可以在这里直接使用此类型,因为它在测试控制器是否正确渲染的时候非常有用。这个私有变量没必要设置为可空:我现在需要对其进行强制解包,因为无论我们创建的这个控制器是否包含有视图模型, 我都需要让其先发生崩溃,以便展示某些错误信息,此外这段代码也不会设置为公开。

现在我们就可以使用 RxSwift 的强大能力了。现在我们需要导入 RxSwift 和 RxCocoa。现在我们需要绑定什么呢?

bind 函数当中,第一个参数是 tableView ,第二个是 index ,第三个是 item 。现在,我们必须要在闭包中创建一个单元格然后将其返回给函数;我们可以把代理删除掉了,因为现在我们不需要它们了。这里我们也不再去获取 Repository 了,因为我们将会从 Observable<Repository> 当中去获取 Repository 的相关信息。

首先,我们需要创建一个索引路径 (index path),因为这个方法是切实简单易用的。现在,行当中的值将是索引值,而 section 的值则为 0,并且这里我们也没有获取 Repository 数据,而是获取到了一个 item。我们同样还需要一个 DisposeBag,由于时间原因,这里我不再详述这些元素的作用,大家只需要相信我:这里切实需要这个东西。由于我们没有时间来完成这个步骤,因此我的建议是不要返回 Observable<Item> ,而是返回 Observable<ViewModel> ,因为视图控制器不应该直接去访问模型,这是我们创建视图模型的另一个步骤,但这里的目的仅仅只是为了创建视图模型类型。

这是重构后的 Dashboard 视图控制器:

import Foundation
import Moya
import Moya_modelMapper
import UIKit
import RxSwift
import RxCocoa

class DashboardViewController: UIViewController {

	@IBOutlet weak var tableView: UITableView!

	var repos = [Repository]()
	private var viewModel: DashboardViewModelType!
	private let disposeBag = DisposeBag()

	convenience init(viewModel: DashboardViewModelType) {
		self.init()

		self.viewModel = viewModel
	}

	override func viewDidLoad() {
		super.viewDidLoad()

		let nib = UINib(nibName: "KittyTableViewCell", bundle: nil)
		tableView.register(nib, forCellReuseIdentifier: "kittyCell")
//		tableView.dataSource = self
//		tableView.delegate = self

		viewModel.reposObservable.bindTo(tableView.rx.items) { tableView, index, item in
		let indexPath = IndexPath(row: index section: 0)
		let cell = tableView.dequeueReusableCell(withIdentifier: "kittyCell", for: indexPath) as UITableViewCell
		cell.textLabel?.text = item.name

		return cell
	}
	.addDisposableTo(disposeBag)
}

大家必须要记住的一点是,不能够直接传递模型。如果您想要让控制器控制住这方面的细节的话,那么我的建议是 创建另一个 Variable ,然后让其作为视图模型。通过绑定用户点击的方式,将视图控制器和视图模型关联在一起,随后视图模型就必须要对点击操作做出回应了。

在 Dashboard 视图控制器当中, Coordinator 必须要将下一个控制器推入导航栈当中,所以我们需要创建一个新的协议,将其作为必须要继承自该类的控制器的委托(或许这个问题可能会在 Swift 3 中得以解决?)。然而,我们唯一能够做的操作就是点击这个有猫咪 Emoji 的单元格,因此我们需要创建一个函数,用来「获取所点击单元格的索引路径,随后在视图模型当中创建一个自定义的构造器」。不带有任何参数,让视图的代理与视图控制器的代理关联起来,然后在视图模型当中调用这个代理。当然,我们应该将其传递给 Coordinator ,现在 Coordinator 便知道该如何对其做出回应了。

我们还需要考虑该如何去移除 Coordinator 。我们应该要创建一个方法来检查 Coordinator 是否完成了它的任务,并且检查 Coordinator 是否从导航栈当中移除掉。

问:您的演示使用了大量的 xib。您有没有尝试过 Storyboard 以及 Segue 呢?

Łukasz:没错,我确实使用过 Storyboard。我现在没有 Github 的链接,不过如果您尝试去搜索 “ Coordinator s with storyboards” 的话,您就能找到相关的例子了。

问:您对 Rx 中的 Subject 概念有什么看法呢?

Łukasz:我觉得您应该尽量避免在项目中使用 Subject。如果您只是随意尝试下 Rx,那么没问题可以大胆地去尝试。不过通常而言,最好还是不要去使用这个功能。但是如果您必须要使用的话,可以参考我的方法:创建一个私有的 Subject,然后让其只对 Observable 暴露,这样就可以让视图控制器不去直接访问视图模型当中的数据,只负责去执行绑定操作。


以上所述就是小编给大家介绍的《Coordinator 与 RxSwift 共同构建的 MVVM 架构》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

代码整洁之道

代码整洁之道

马丁 / 人民邮电出版社 / 2011-1 / 59.00元

《代码整洁之道(英文版)》提出一种观念:代码质量与其整洁度成正比。干净的代码,既在质量上较为可靠,也为后期维护、升级奠定了良好基础。作为编程领域的佼佼者,《代码整洁之道(英文版)》作者给出了一系列行之有效的整洁代码操作实践。这些实践在《代码整洁之道(英文版)》中体现为一条条规则(或称“启示”),并辅以来自现实项目的正、反两面的范例。只要遵循这些规则,就能编写出干净的代码,从而有效提升代码质量。 ......一起来看看 《代码整洁之道》 这本书的介绍吧!

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具