ukasz mro  z twitter

Coordinator 与 RxSwift 共同构建的 MVVM 架构

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


概述 (0:00)

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

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

MVC (1:03)

Image 1

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

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

MVVM (2:15)

Image 2

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

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

Coordinators (3:11)

Image 3

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

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

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

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

RxSwift (5:06)

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

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

演示 (7:03)

对于本次讲演的演示部分,我们将以一个名为 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 视图控制器 (8:50)

现在我们来看一下 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 (9:27)

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

import UIKit

class PayMoneyPleaseViewController: UIViewController {

	@IBOutlet private weak var descriptionLabel: UILabel!

	override func viewDidLoad() {
	    super.viewDidLoad()

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

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

Kitty 控制器 (9:41)

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 = "🐱" + kitty.name + "🐱"
	}
}

这就是 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") ?? "无名氏😿"
    	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 是否从导航栈当中移除掉。

问答时间到 (37:43)

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

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

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

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

About the content

This talk was delivered live in October 2016 at Mobilization. The video was transcribed by Realm and is published here with the permission of the conference organizers.

Łukasz Mróz

Łukasz started as a back-end web developer and quickly found a new home in iOS. He’s in love with Swift, learning, and everything reactive. Endorsed on LinkedIn for coffee skills.

4 design patterns for a RESTless mobile integration »

close