原文:Coordinator Tutorial for iOS: Getting Started
作者:Andriy Kharchyshyn
译者:kmyhy
MVC 模式很好用,但收缩性不好。随着项目规模的增长和复杂化,这种限制就越发明显。在这篇协调器模式教程中,你将学习一种不同的模式:协调器模式。
如果你不熟悉这个词,也没关系!这种架构非常简单,不需要用到任何第三方框架,能很好地适应于你已有的 MVC 项目。
在教程最后,你会对哪种模式最适合于你的 app 做出判断。
注:如果你不熟悉 MVC,请参考《iOS 设计模式》。
开始
首先,下载本教程的资源(你可以在本教程顶部或底部找到下载链接)。
注:本教程中使用的 kanji 数据由 Kanji Alive 公有 API 提供。
Kanji List 是一个学习 Kanji(日文汉字)的 app,现在它是 MVC 模式的。
看一下这个 app 的功能:
- KanjiListViewController.swift 中显示一个包含了汉字的列表。
- KanjiDetailViewController.swift 显示所选中的汉字的信息,以及使用了这个字的词条列表。
- 当用户从列表中选择某个词,app 会显示 KanjiListViewController.swift,用列表显示出词中的汉字。
还有一个 Kanji.swift,它是数据模型,以及一个 KanjiStorage.swift, 这是一个共享对象。用于保存解析后的汉字数据。
很简单吧!?
这个实现中的问题
你可能会想,“这个 app 能用啊。有什么问题?”
这个问题很好,让我们来看看。
打开 Main.storyboard.
呵,有意思。这个 app 有一个从 KanjiListViewController 到 KanjiDetailViewController 的 segue,还有一个从 KanjiDetailViewController 到 KanjiListViewController 的 segue。
这是因为 app 具有这样的业务逻辑:
- 首先 push 到 KanjiDetailViewController.swift。
- 然后 push 到 KanjiListViewController.swift.
如果你觉得这些 segue 有问题,那么你猜对了一半。
segue 将两个 UIViewController 绑定在一起,这让这些 controller 很难被重用。
重用的问题
打开KanjiListViewController.swift. 注意这个属性 kanjiList:
var kanjiList: [Kanji] = KanjiStorage.sharedStorage.allKanji() {
didSet {
kanjiListTableView?.reloadData()
}
}
kanjiList 是 KanjiListViewController 的数据源,它默认显示共用的 KanjiStorage 中包含的所有汉字。
还有一个属性 word。这个属性允许你在第一个窗口和第三个窗口之间重用 KanjiListViewController:
var word: String? {
didSet {
guard let word = word else {
return
}
kanjiList = KanjiStorage.sharedStorage.kanjiForWord(word)
title = word
}
}
通过设置 word 属性,你可以改变 kanjiList 和导航条上的标题:
思考一下。 KanjiListViewController 知道这些事情:
- 可能有某个单词被选中。
- 这个单词中包含汉字。
- 如果没有选中某个词,app 应该显示 kanjiList 中的所有汉字。
- KanjiListViewController 知道它有一个 UINavigationController,如果单词被选中,那么它必须修改标题。
对于一个显示一个列表的 UIViewController 来说,简单问题复杂化了。
解决这个问题的一种方式是将一个汉字数组传递给 KanjiListViewController,但谁来负责传递呢?
你可以创建一个 UINavigationController 子类,将数据注入给这个子类,但这些代码应该放在 UINavigationController 子类中吗?UINavigationController 难道不应该是一个简单的 UIViewController 容器吗?
还有另外一个问题是 KanjiDetailViewController.swift 的prepare(for:sender:) 方法:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
super.prepare(for: segue, sender: sender)
guard let listViewController = segue.destination as? KanjiListViewController else {
return
}
listViewController.shouldOpenDetailsOnCellSelection = false
listViewController.word = sender as? String
}
这段代码表明 KanjiDetailViewController 知道下一个 UIViewController,也就是 KanjiListViewController.
如果准备重用 KanjiDetailViewController,这些代码会很难控制,因为这个方法要用一个长长的 switch 语句,以便它判断下一个 Push 出来的控制器是哪个。因此这些逻辑不应当放在另一个控制器中——它在两个控制器中创建了强连接,使它们承担了本不应该承担的职责。
协调器模式
协调器模式是解决上述问题的有效方法。这种模式由 Soroush Khanlou(@khanlou)第一个通过他的博客和 [MSSPain 大会的演讲](presentation at the NSSpain conference)引入到 iOS 社区的。
它的思想是创建一个隔离对象——一个协调器——负责 app 的流向。协调器不知道它的父协调器,但它可以启动它的子协调器。
协调器在保持 UIViewController 相互独立的同时创建、显示和解散 UIViewController。和 UIViewController 负责管理 UIView 一样,协调器负责管理了 UIViewController。
协调器协议
该写点代码了!
首先,创建一个协调器协议。点击主菜单的 File\New\File…。选择 iOS\Source\Swift File 将文件命名为 Coordinator.swift. 然后点击Next 、 Create.
编辑新文件为:
protocol Coordinator {
func start()
}
你可能难以置信,但这就是全部了!这个协议只有一个 start() 方法!
实现协调器模式
因为你想让协调器去处理 app 的流向,你必须提供一个用代码创建 UIViewController 的方法。对于本教程来说,你将用 .xib 文件来替代 .storyboard 文件。这样,你可以用下面的方法来创建 UIViewController:
UIViewController(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)
注:协调器模式并不是一定要使用 .xib 文件。你会在教程最后看到,
你可以用代码来创建 UIViewController,也可以从 storyboard 文件中实例化它们。
将 .xib 文件以拖拽的方式添加到项目的 target 中。用助手编辑器,打开 KanjiDetailViewController.xib 和 KanjiDetailViewController.swift,确认所有的 outlet 都连接正确。对 KanjiListViewController.xib 和 KanjiListViewController.swift 进行同样的检查。
注:在文件检查器中,确认两个 .xib 文件都已经加入到 target 中。
你需要将应用程序入口从 Main.storyboard 替换掉。
右键点击 Main.storyboard, 选择 Delete 然后点击 Move to Trash.
在文件导航器中,点击 KanjiList 项目,打开 KanjiList target > General, 将 Main interface 一栏中的 Main 删除:
你可以自己创建程序启动的入口。
App 协调器
接下来创建 app 协调器。点击 File\New\File… 选择 iOS\Source\Swift file。命名文件为 ApplicationCoordinator.swift. 点击 Next、Create.
编辑新文件内容为:
import UIKit
class ApplicationCoordinator: Coordinator {
let kanjiStorage: KanjiStorage // 1
let window: UIWindow // 2
let rootViewController: UINavigationController // 3
init(window: UIWindow) { //4
self.window = window
kanjiStorage = KanjiStorage()
rootViewController = UINavigationController()
rootViewController.navigationBar.prefersLargeTitles = true
// Code below is for testing purposes // 5
let emptyViewController = UIViewController()
emptyViewController.view.backgroundColor = .cyan
rootViewController.pushViewController(emptyViewController, animated: false)
}
func start() { // 6
window.rootViewController = rootViewController
window.makeKeyAndVisible()
}
}
看一下这段代码:
- ApplicationCoordinator 将用 kanjiStorage 从 JSON 中获得数据。现在 kanjiStorage 是一个共享实例,但我们会用依赖注入来代替。
- ApplicationCoordinator 声明了一个 window 对象,用于引用 app 的 window 对象,它将通过初始化方法传递给 ApplicationCoordinator。
- rootViewController 是一个 UINavigationController.
- 初始化属性值。
- 因为还没有子协调器,这段代码用于测试对象是否设置正确。
- start() 是关键。重要的是,这里让窗口随着 rootViewController 一起显示。
你需要做的就是调用 start 来创建 ApplicationCoordinator。
打开 AppDelegate.swift 编辑内容:
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private var applicationCoordinator: ApplicationCoordinator? // 1
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplicationLaunchOptionsKey: Any]?) -> Bool {
let window = UIWindow(frame: UIScreen.main.bounds)
let applicationCoordinator = ApplicationCoordinator(window: window) // 2
self.window = window
self.applicationCoordinator = applicationCoordinator
applicationCoordinator.start() // 3
return true
}
}
代码说明如下:
- 保存一个对 applicationCoordinator的引用。
- 用刚刚创建的 window 来实例化 applicationCoordinator 对象。
- 调用 applicationCoordinator 的 start 方法。
Build & run,你会看到:
汉字列表协调器
现在来显示 KanjiListViewController。这需要用到另外一个 Coordinator. 这个协调器显示一个汉字列表,后面还会启动另外一个协调器用于显示 KanjiDetailViewController.
新建一个文件 AllKanjiListCoordinator.swift.
编辑如下代码:
import UIKit
class AllKanjiListCoordinator: Coordinator {
private let presenter: UINavigationController // 1
private let allKanjiList: [Kanji] // 2
private var kanjiListViewController: KanjiListViewController? // 3
private let kanjiStorage: KanjiStorage // 4
init(presenter: UINavigationController, kanjiStorage: KanjiStorage) {
self.presenter = presenter
self.kanjiStorage = kanjiStorage
allKanjiList = kanjiStorage.allKanji() // 5
}
func start() {
let kanjiListViewController = KanjiListViewController(nibName: nil, bundle: nil) // 6
kanjiListViewController.title = "Kanji list"
kanjiListViewController.kanjiList = allKanjiList
presenter.pushViewController(kanjiListViewController, animated: true) // 7
self.kanjiListViewController = kanjiListViewController
}
}
分段解释如下:
- AllKanjiListCoordinator 的 presenter 属性是一个 UINavigationController.
- 因为 AllKanjiListCoordinator 要显示所有汉字的列表, 所以用一个属性来存储这个列表.
- 一个将要显示的 KanjiListViewController 对象。
- 一个 KanjiStorage 属性,将通过 AllKanjiListCoordinator 的构造函数向它赋值.
- 初始化属性值。
- 创建将要显示的 UIViewController对象。
- 将创建的 UIViewController push 到 presenter。
现在你需要创建并启动 AllKanjiListCoordinator。打开 ApplicationCoordinator.swift 在 rootViewController 属性下添加一个属性:
let allKanjiListCoordinator: AllKanjiListCoordinator
然后在 init(window:) 将 // Code below is for testing purposes // 5 之后的代码换成:
allKanjiListCoordinator = AllKanjiListCoordinator(presenter: rootViewController,
kanjiStorage: kanjiStorage)
最后,在 start() 的这一句:
window.rootViewController = rootViewController
之后添加:
allKanjiListCoordinator.start()
Build & run. 现在看到的结果和重构之前是一样的了!
但是,当你选择一个汉字,程序崩溃。因为当 cell 选中时,执行了一个不存在的 segue。
必须解决这个问题!在 cell 选中时,不要直接操作一个 action ,而是调用委托方法。这需要从 UIViewController 中删除这个 action。
打开 KanjiListViewController.swift 在类定义之前添加:
protocol KanjiListViewControllerDelegate: class {
func kanjiListViewControllerDidSelectKanji(_ selectedKanji: Kanji)
}
在 KanjiListViewController 添加属性:
weak var delegate: KanjiListViewControllerDelegate?
将 tableView(_:didSelectRowAt:) 修改为:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let kanji = kanjiList[indexPath.row]
delegate?.kanjiListViewControllerDidSelectKanji(kanji)
tableView.deselectRow(at: indexPath, animated: true)
}
好了!刚才你对 KanjiListViewController 类进行了精简。当有人选中 cell 时,唯一的责任就是显示出汉字列表并通知委托对象。不过,还没有人监听呢。
打开 AllKanjiListCoordinator.swift 在文件最后,AllKanjiListCoordinator 类之外添加:
// MARK: - KanjiListViewControllerDelegate
extension AllKanjiListCoordinator: KanjiListViewControllerDelegate {
func kanjiListViewControllerDidSelectKanji(_ selectedKanji: Kanji) {
}
}
这让 AllKanjiListCoordinator 实现KanjiListViewControllerDelegate 协议。后面会在 kanjiListViewControllerDidSelectKanji(_:) 中调用 DetailsCoordinator 的 start() 。
最后,在 start() 的初始化 kanjiListViewController 之后添加:
kanjiListViewController.delegate = self
现在 build & run! 尽管能够运行了,但 kanjiListViewControllerDidSelectKanji 方法还是空的,因此当选中某个 cell 时什么也不会发生。别急,很快就会添加这些代码了。
什么时候创建协调器?
这时,你会好奇“什么时候需要创建一个单独的协调器?”。这个问题没有标准答案。协调器用于 app 的特定部件,这些部件可以在不同的地方显示。
对于显示汉字详情来说,你可以在 AllKanjiListCoordinator 中创建一个新的汉字详情视图控制器,当委托方法被回调时 Push 它。你需要问自己一个问题,“AllKanjiListCoordinator 需要知道和 UIViewController 相关的细节吗?”。很显然,答案是否。通过创建一个单独的详情协调器,你将以一个独立的组件来结束,这个组件可以显示一个汉字的详情,不需要依赖于 app 中的任何部分。这很强大!
假设有一天你想集成 Spotlight 的搜索功能。如果你将详情显示到一个独立的协调器中,事情就变得简单了:创建一个新的 DetailsCoordinator 然后调用 start()。
重点是协调器能够创建构成 app 的独立组件。
汉字详情协调器
现在创建 KanjiDetailCoordinator.
新建一个文件 KanjiDetailCoordinator.swift 编辑内容为:
import UIKit
class KanjiDetailCoordinator: Coordinator {
private let presenter: UINavigationController // 1
private var kanjiDetailViewController: KanjiDetailViewController? // 2
private var wordKanjiListViewController: KanjiListViewController? // 3
private let kanjiStorage: KanjiStorage // 4
private let kanji: Kanji // 5
init(presenter: UINavigationController, // 6
kanji: Kanji,
kanjiStorage: KanjiStorage) {
self.kanji = kanji
self.presenter = presenter
self.kanjiStorage = kanjiStorage
}
func start() {
let kanjiDetailViewController = KanjiDetailViewController(nibName: nil, bundle: nil) // 7
kanjiDetailViewController.title = "Kanji details"
kanjiDetailViewController.selectedKanji = kanji
presenter.pushViewController(kanjiDetailViewController, animated: true) // 8
self.kanjiDetailViewController = kanjiDetailViewController
}
}
- KanjiDetailCoordinator 的 presenter 是一个 UINavigationController.
- 引用 KanjiDetailViewController, 你将在 start() 中显示它.
- 引用 KanjiListViewController, 你将在用于选中一个单词时显示它.
- 一个属性,当 KanjiDetailViewController 的初始化方法中传递一个 KanjiStorage 给它。
- 保存所选汉字的属性。
- 初始化属性值。
- 创建要显示的的 UIViewController。
- 显示创建出来的 UIViewController。
然后来创建 KanjiDetailCoordinator. 打开 AllKanjiListCoordinator.swift 添加属性:
private var kanjiDetailCoordinator: KanjiDetailCoordinator?
然后,在扩展部分在 kanjiListViewControllerDidSelectKanji(_:) 空方法中添加代码:
let kanjiDetailCoordinator = KanjiDetailCoordinator(presenter: presenter,
kanji: selectedKanji,
kanjiStorage: kanjiStorage)
kanjiDetailCoordinator.start()
self.kanjiDetailCoordinator = kanjiDetailCoordinator
在这个方法中,当用户选择一个汉字时,你创建并启动了 KanjiDetailCoordinator。
Build & run。
看到了吗,现在当用户在 KanjiListViewController 中选择 cell 时, KanjiDetailViewController 正确显示了。但是,如果你从 KanjiDetailViewController 选择一个单词,app 崩溃。因为和之前一样,segue 不存在了。
快来搞定它!
打开 KanjiDetailViewController.swift. 和 KanjiListViewController 一样, 在类声明之上添加委托协议:
protocol KanjiDetailViewControllerDelegate: class {
func kanjiDetailViewControllerDidSelectWord(_ word: String)
}
然后,为 KanjiDetailViewController 添加下列属性:
weak var delegate: KanjiDetailViewControllerDelegate?
要触发这个委托,将 tableView(_:didSelectRowAt:) 修改成:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
defer {
tableView.deselectRow(at: indexPath, animated: true)
}
guard indexPath.section == 1,
let word = selectedKanji?.examples[indexPath.row].word else {
return
}
delegate?.kanjiDetailViewControllerDidSelectWord(word)
}
很好!现在选中某个 cell 时 app 不会崩溃了,但它仍然无法打开一个列表。
要解决这个问题,请打开 KanjiDetailCoordinator.swift, 在文件最后添加代码 —— 请在 KanjiDetailCoordinator 类之外 :
// MARK: - KanjiDetailViewControllerDelegate
extension KanjiDetailCoordinator: KanjiDetailViewControllerDelegate {
func kanjiDetailViewControllerDidSelectWord(_ word: String) {
let wordKanjiListViewController = KanjiListViewController(nibName: nil, bundle: nil)
let kanjiForWord = kanjiStorage.kanjiForWord(word)
wordKanjiListViewController.kanjiList = kanjiForWord
wordKanjiListViewController.title = word
presenter.pushViewController(wordKanjiListViewController, animated: true)
}
}
在 start() 方法中,在实例化 KanjiDetailViewController 之后添加:
kanjiDetailViewController.delegate = self
Build & run。app 将和重构之前一模一样。
收尾项目
还有几个地方有待改善。
首先,在 KanjiListViewController.swift 和 KanjiDetailViewController.swift 中删除 prepare(for:sender:). 因为在这种情况下不再需要它们了.
在 KanjiListViewController.swift, 删除变量:
var word: String? {
didSet {
guard let word = word else {
return
}
kanjiList = KanjiStorage.sharedStorage.kanjiForWord(word)
title = word
}
}
这个变量也用不到了。
看一眼 KanjiListViewController.swift 中的 var shouldOpenDetailsOnCellSelection = true。这个标记用于指定 UIViewController 是否会 push KanjiDetailViewController。因为这个逻辑不再位于 KanjiListViewController 中了,你需要做一点修改。
将:
var shouldOpenDetailsOnCellSelection = true
替换成:
var cellAccessoryType = UITableViewCellAccessoryType.disclosureIndicator
然后在 tableView(_:cellForRowAt:) 中将:
cell.accessoryType = shouldOpenDetailsOnCellSelection ? .disclosureIndicator : .none
修改成:
cell.accessoryType = cellAccessoryType
这个属性设置 cell 的 accessory 类型。现在有个问题,当某个单词被选中时,你显示出来的汉字列表会带有一个 disclosure indicator。
要解决这个问题,请打开 KanjiDetailCoordinator.swift 找到 kanjiDetailViewControllerDidSelectWord(_:). 在初始化 wordKanjiListViewController 之后,设置它的 cell accessory 类型为:
wordKanjiListViewController.cellAccessoryType = .none
wordKanjiListViewController 的 cellAccessoryType 设成了 .none, 因为这个属性的设置逻辑被放在了 KanjiDetailCoordinator 中。这避免了 wordKanjiListViewController 知道太多 app 的细节。
然后是最后一个地方。打开 KanjiStorage.swift 删除静态变量:
static let sharedStorage = KanjiStorage()
光删除它还不够,在 KanjiListViewController.swift 中还使用了它,在那里将它设置成 kanjiList 的默认值。 不需要将 kanjiList 的默认值设置成整个 KanjiSotrage,只需要显示一个空列表。
将这句:
var kanjiList: [Kanji] = KanjiStorage.sharedStorage.allKanji()
修改为:
var kanjiList: [Kanji] = []
现在你的 app 已经完成了,它的结构更清晰和更灵活,同时取消了共享实例。
你可以在本文顶部或底部找到最终项目的下载地址。
协调器模式在小项目中可能显得过于复杂,但当项目变得越来越大时,这种模式会让项目更简单,UIViewController 更独立。协调器模式是好的,因为当在已有项目中集成它时,它的结构更清晰,改动更小。
附:故事板中的协调器模式
如果你喜欢用故事板,你可以创建一个新文件 Extensions.swift:
import UIKit
protocol StoryboardInstantiable: NSObjectProtocol {
associatedtype MyType // 1
static var defaultFileName: String { get } // 2
static func instantiateViewController(_ bundle: Bundle?) -> MyType // 3
}
extension StoryboardInstantiable where Self: UIViewController {
static var defaultFileName: String {
return NSStringFromClass(Self.self).components(separatedBy: ".").last!
}
static func instantiateViewController(_ bundle: Bundle? = nil) -> Self {
let fileName = defaultFileName
let sb = UIStoryboard(name: fileName, bundle: bundle)
return sb.instantiateInitialViewController() as! Self
}
}
- 为了在协议中使用泛型,创建一个关联类型。
- 返回一个文件名,它是不带扩展名的类名。
- 这是主要的功能,用 defaultFileName 来查找故事板,从故事板中初始化并返回第一个 UIViewController。
在上面的代码中,你为 UIViewController 创建了一个 StoryboardInstantiable 扩展。通过它,你只需要让任意 UIViewController 遵守 StoryboardInstantiable 协议,就能够实例化图。
要实例化该 UIViewController,要在该 UIViewController 文件底部添加代码:
extension MyViewController: StoryboardInstantiable {
}
在 UIViewController 类中,使用:
let viewController = MyViewController.instantiateViewController()
例如,如果你想在 KanjiDetailViewController 中用这句代码,那么应该这样做:
extension KanjiDetailViewController: StoryboardInstantiable {
}
class KanjiDetailsCoordinator: Coordinator {
let viewController = KanjiDetailViewController.instantiateViewController()
}
能力越大责任越大。要使用这个扩展,你需要为每个 ViewController 使用单独的故事板。故事板的名字必须和 ViewController 的类名匹配。 ViewController 必须设置成 initial view controller。
作为一个练习,你可以将这个模式从 .xib 文件改成 .storyboard 文件,并在评论区留下你的想法。
接下来做什么?
总结一下协调器模式。最大的好处就是能够轻易地改变 app 的流向(显示登录页面、显示教程页面等等)。用一个练习来测试一下,修改项目,试图创建、显示一个登录页面来代替列表。
如果想进一步学习协调器模式,下面的视频中对它进行了很好的阐述:
Coordinators presentation at NSSpain by Soroush Khanlou
Boundaries in Practice by Ayaka Nonaka at try! Swift
MVVM with Coordinators & RxSwift – Łukasz Mróz
协调器模式是 iOS 开发中非常有用的设计模式之一。要学习其他设计模式,请看我们的 iOS 设计模式系列视频。本站还包含了其它深度阐释 iOS 主题的视频教程。
有任何问题,请在评论区留言。