iOS 协调器模式: 开始

原文:Coordinator Tutorial for iOS: Getting Started
作者:Andriy Kharchyshyn
译者:kmyhy

MVC 模式很好用,但收缩性不好。随着项目规模的增长和复杂化,这种限制就越发明显。在这篇协调器模式教程中,你将学习一种不同的模式:协调器模式。

如果你不熟悉这个词,也没关系!这种架构非常简单,不需要用到任何第三方框架,能很好地适应于你已有的 MVC 项目。

在教程最后,你会对哪种模式最适合于你的 app 做出判断。

注:如果你不熟悉 MVC,请参考《iOS 设计模式》。

开始

首先,下载本教程的资源(你可以在本教程顶部或底部找到下载链接)。

注:本教程中使用的 kanji 数据由 Kanji Alive 公有 API 提供。

Kanji List 是一个学习 Kanji(日文汉字)的 app,现在它是 MVC 模式的。

看一下这个 app 的功能:

  1. KanjiListViewController.swift 中显示一个包含了汉字的列表。
  2. KanjiDetailViewController.swift 显示所选中的汉字的信息,以及使用了这个字的词条列表。
  3. 当用户从列表中选择某个词,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()
  }
}

看一下这段代码:

  1. ApplicationCoordinator 将用 kanjiStorage 从 JSON 中获得数据。现在 kanjiStorage 是一个共享实例,但我们会用依赖注入来代替。
  2. ApplicationCoordinator 声明了一个 window 对象,用于引用 app 的 window 对象,它将通过初始化方法传递给 ApplicationCoordinator。
  3. rootViewController 是一个 UINavigationController.
  4. 初始化属性值。
  5. 因为还没有子协调器,这段代码用于测试对象是否设置正确。
  6. 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
  }
}

代码说明如下:

  1. 保存一个对 applicationCoordinator的引用。
  2. 用刚刚创建的 window 来实例化 applicationCoordinator 对象。
  3. 调用 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
  }
}

分段解释如下:

  1. AllKanjiListCoordinator 的 presenter 属性是一个 UINavigationController.
  2. 因为 AllKanjiListCoordinator 要显示所有汉字的列表, 所以用一个属性来存储这个列表.
  3. 一个将要显示的 KanjiListViewController 对象。
  4. 一个 KanjiStorage 属性,将通过 AllKanjiListCoordinator 的构造函数向它赋值.
  5. 初始化属性值。
  6. 创建将要显示的 UIViewController对象。
  7. 将创建的 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  
  }
}
  1. KanjiDetailCoordinator 的 presenter 是一个 UINavigationController.
  2. 引用 KanjiDetailViewController, 你将在 start() 中显示它.
  3. 引用 KanjiListViewController, 你将在用于选中一个单词时显示它.
  4. 一个属性,当 KanjiDetailViewController 的初始化方法中传递一个 KanjiStorage 给它。
  5. 保存所选汉字的属性。
  6. 初始化属性值。
  7. 创建要显示的的 UIViewController。
  8. 显示创建出来的 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
  }
}
  1. 为了在协议中使用泛型,创建一个关联类型。
  2. 返回一个文件名,它是不带扩展名的类名。
  3. 这是主要的功能,用 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 主题的视频教程

有任何问题,请在评论区留言。

资料下载

猜你喜欢

转载自blog.csdn.net/kmyhy/article/details/80770184