第23章:协调者模式

协调者模式是一种结构化的设计模式,用于组织视图控制器之间的流动逻辑。它包括以下几个部分。

  1. 协调者是一个协议,规定了所有具体协调者必须实现的方法和属性。具体来说,它规定了关系属性、子女和路由器。它还规定了展示方法、呈现和驳回。

    通过抓住协调者协议,而不是直接抓住具体的协调者,你可以把父协调者和它的子协调者解耦。这使得父协调者可以在一个单一的属性(children)中抓取各种具体的子协调者。

    同样地,通过直接抓住一个路由器协议而不是一个具体的路由器,你可以将协调者和它的路由器解耦。

  2. 具体的协调者实现了协调者协议。它知道如何创建具体的视图控制器以及视图控制器的显示顺序。

  3. 路由器是一个协议,它规定了所有具体路由器必须实现的方法。具体来说,它规定了显示和关闭视图控制器的present和dismiss方法。

  4. 具体的路由器知道如何展示视图控制器,但它并不清楚正在展示的是什么,也不知道下一步将展示哪个视图控制器。相反,协调者会告诉路由器要展示哪个视图控制器。

  5. 具体的视图控制器是MVC中典型的UIViewController子类。然而,他们不知道其他的视图控制器。相反,每当需要执行转换时,它们就会委托给协调者。

这种模式可以只用于应用程序的一部分,也可以作为一种 "架构模式 "来确定整个应用程序的结构。

在本章中,你会看到这两种模式都在发挥作用。在playground的例子中,你将从一个现有的视图控制器中调用一个协调者,而在教程项目中,你将在整个应用程序中采用这种模式。

你应该在什么时候使用它?

使用这种模式可以将视图控制器彼此解耦。唯一能直接了解视图控制器的组件是协调者。

因此,视图控制器的可重用性更强。如果你想在你的应用程序中创建一个新的流程,你只需创建一个新的协调者即可。

playground实例

打开启动器目录中的AdvancedDesignPatterns.xcworkspace,然后打开协调员页面。

对于这个playground的例子,你将创建一个分步骤的指令流程。你可以将其用于任何指令,如应用程序的设置、首次帮助教程或任何其他分步流程。

为了保持这个例子的简单性和对设计模式的关注,你将创建一个 "如何编码 "的流程,该流程将显示一组视图控制器,只有文本。

按住Option键,左击协调员页面旁边的箭头,展开所有的子文件夹。你会看到已经为你添加了几个文件夹。

控制器包含所有具体的视图控制器。这些都是简单的、普通的视图控制器,已经为你实现了。

Coordinators包含两个文件。Coordinator.swift和HowToCodeCoordinator.swift。如果你打开这两个文件,你会发现它们目前是空的。

同样地,路由器包含两个文件。NavigationRouter.swift和Router.swift。这两个文件目前也都是空的。

这些类型是你需要实现的!

创建Router协议

首先,打开Router.swift。这就是你要实现Router协议的地方。

在这个文件中加入以下代码。

import UIKit

public protocol Router: class {

  // 1 
  func present(_ viewController: UIViewController, 
               animated: Bool)

  func present(_ viewController: UIViewController, 
               animated: Bool, 
               onDismissed: (()->Void)?) 
  // 2 
  func dismiss(animated: Bool)
}

extension Router { 
  // 3 
  public func present(_ viewController: UIViewController, 
                      animated: Bool) {

    present(viewController, 
            animated: animated, 
            onDismissed: nil)
  } 
}

你在这里声明了一个叫做Router的协议。下面是这个协议的定义。

  1. 你首先定义了两个现存的方法。唯一的区别是一个需要一个onDismissed闭包,而另一个不需要。如果提供了这个方法,具体的路由器将在视图控制器被解散时执行onDismissed,例如,在使用UINavigationController的具体路由器中,通过 "pop "动作。

  2. 你还要声明dismiss(animated:)。这将解雇整个路由器。根据具体的路由器,这可能会导致弹出到根视图控制器,在父视图控制器上调用dismiss,或任何根据具体路由器的实现而需要的动作。

  3. 你最后为present(_:animated:)定义了一个默认的实现。这只是通过传递nil给onDismissed来调用另一个present。

你可能会想,"难道我不需要一个方法来解散单个视图控制器吗?" 令人惊讶的是,你可能不需要!这个playground的例子也不需要。这个playground的例子和教程项目都不需要它。如果你在自己的项目中确实需要这个方法,请随意声明。

创建 "具体路由器"。

你接下来需要实现具体的Router。打开NavigationRouter.swift,并在其中添加以下代码。

import UIKit

// 1 
public class NavigationRouter: NSObject {

  // 2 
  private let navigationController: UINavigationController 
  private let routerRootController: UIViewController?   
  private var onDismissForViewController:
    [UIViewController: (() -> Void)] = [:]

  // 3 
  public init(navigationController: UINavigationController) { 
    self.navigationController = navigationController    
    self.routerRootController = navigationController.viewControllers.first 
    super.init() 
  }
}

我们来看看这个。

  1. 你将NavigationRouter声明为NSObject的一个子类。这是必须的,因为你以后会让它符合UINavigationControllerDelegate。

  2. 然后你创建这些实例属性。

  1. 最后,我们创建了一个初始化器,它接收一个navigationController,并从中设置navigationController和routerRootController。

你会注意到,这还没有实现Router协议。所以,让我们来实现它吧! 在文件的末尾添加以下扩展。

这使得NavigationRouter符合Router。

  1. 在present(_:animated:onDismissed:)中,你为给定的viewController设置onDismissed闭包,然后将视图控制器推到navigationController上显示。

  2. 在dismiss(animated:)中,你验证routerRootController是否被设置。如果没有,你就简单地在navigationController上调用popToRootViewController(animated:)。否则,你就调用performOnDismissed(for:)来执行on-dismiss动作,然后将routerRootController传给navigationController的popToViewController(_:animated:)。

  3. 在performOnDismiss(for:)中,你要保证给定的viewController有一个onDismiss。如果没有,你就提前返回。否则,你就调用onDismiss,并从onDismissForViewController中移除它。

你在这里需要做的最后一件事是让NavigationRouter符合UINavigationController,这样你就可以在用户按下返回按钮时调用on-dismiss动作。在文件的末尾添加以下扩展。

在navigationController(_:didShow:animated:)里面,你从navigationController.transitionCoordinator中获取from view controller,并确认它不包含在navigationController.viewControllers中。这表明视图控制器被弹出,作为回应,你调用 performOnDismissed

来为给定的视图控制器做on-dismiss的动作。

当然,你也需要实际设置NavigationRouter作为navigationController的委托。

在init(navigationController:)的末尾添加以下内容。

navigationController.delegate = self

有了这个,你的NavigationRouter就完成了!

创建协调员

你的下一个任务是创建协调员协议。打开Coordinator.swift,并在其中添加以下内容。

下面是这个的作用。

  1. 你为子代和路由器声明关系属性。你将使用这些属性在协调员下一步的扩展中提供默认的实现。

  2. 你还声明了present、dismiss和presentChild的必要方法。

你可以为dismiss和presentChild提供合理的默认实现。在文件的末尾添加以下扩展。

下面是这个的作用。

  1. 要解雇一个协调者,你只需在其路由器上调用dismiss。这样做的原因是,无论谁提出协调者,都要负责传递一个onDismiss闭包,以进行任何需要的拆除,这将被路由器自动调用。

    还记得你是如何在NavigationRouter中写下所有的逻辑来处理弹出和驳回的吗?这就是你这么做的原因

  2. 在presentChild中,你只需将给定的儿童追加到儿童中,然后调用child.present。你还通过在孩子的onDismissed动作中调用removeChild(_:)来处理移除孩子的问题,最后,你调用传递到方法本身的所提供的onDismissed。

就像Router没有为单个视图控制器声明一个解雇方法一样,这个协调者也没有为子协调者声明一个解雇方法。道理是一样的:本章的例子不需要它!当然,你可以自由地添加它们。当然,如果有必要的话,请随时在你的应用程序中添加它们。

创建具体的协调者

你需要创建的最后一个类型是具体协调者。打开HowToCodeCoordinator.swift并添加以下代码,暂时忽略你得到的任何编译器错误。

以下是你所做的事情。

  1. 首先,你为children和router声明了属性,它们需要分别符合Coordinator和Router。

  2. 接下来,你创建了一个名为stepViewControllers的数组,你通过实例化几个StepViewController对象来设置它。这是一个简单的视图控制器,显示一个带有多行标签的按钮。

    你将视图控制器的文本设置为Proclaimers乐队的 "I'm Gonna Be (500 miles) "的模仿歌曲歌词。如果你不知道的话,可以谷歌一下。一定要大声唱出这些歌词,特别是如果其他人在附近的话--他们会喜欢的......! 好吧,根据你的歌唱技巧,也许你一个人唱是最好的!

  3. 接下来,你为startOverViewController声明一个属性。这将是最后一个显示的视图控制器,并将简单地显示一个按钮来 "重新开始"。

  4. 接下来,你创建一个指定的初始化器,接受并设置路由器。

  5. 最后,你实现了present(animated:, onDismissed:),这是Coordinator要求的,用来启动流程。

接下来你需要使HowToCodeCoordinator符合StepViewControllerDelegate。把下面的代码添加到文件的末尾;现在继续忽略其他编译器错误。

在stepViewControllerDidPressNext(:)中,你首先尝试获取下一个StepViewController,只要这不是最后一个,就可以通过stepViewController(after:)返回。然后,你把它传递给router.present(:animated:)来显示它。

如果没有下一个StepViewController,你就把startOverViewController传给router.present(_:animated:)。

为了解决其余的编译器错误,你需要让HowToCodeCoordinator符合StartOverViewControllerDelegate。在文件的末尾添加以下代码来实现这一点。

每当startOverViewControllerDidPressStartOver(_:)被调用时,你会调用router.dismiss来结束流程。最终,这将导致返回到启动该流程的第一个视图控制器,因此,用户可以再次启动它。

试用playground例子

你已经创建了所有的组件,你已经准备好将它们付诸行动了

打开协调员页面,在代码示例下面添加以下内容。

我们来看看这个。

  1. 首先,你创建homeViewController,然后用它来创建navigationController。这将是 "主页 "屏幕。如果这实际上是一个iOS应用程序,这将是该应用程序启动时显示的第一个屏幕。

  2. 接下来,你使用navigationController创建路由器,反过来,使用路由器创建协调者。

  3. 如果你打开HomeViewController.swift,你会看到它有一个单一的按钮,最终调用其onButtonPressed闭包。在这里,你设置homeViewController.onButtonPressed来告诉协调者呈现,这将开始其流动。

  4. 最后,你将PlaygroundPage.current.liveView设置为navigationController,这告诉Xcode在助理编辑器中显示navigationController。

运行playground,你应该看到实时预览显示了这个动作。如果你没有,请选择编辑器并确保实时视图被选中。

点选 "如何编码 "来启动流程。点每一个按钮,直到你看到 "重新开始"。一旦你点了这个按钮,协调员就会被解散,你会再次看到《如何编程》。

你应该注意什么?

确保你在使用这种模式时处理好返回功能。特别是,确保你提供任何所需的拆解代码传递到协调者的onDismiss上(animated:onDismiss:)。

对于非常简单的应用程序来说,协调者模式可能显得有些矫枉过正。你将被要求在前期创建许多额外的类;即具体的协调者和路由器。

对于长期或复杂的应用程序,协调者模式可以帮助你提供所需的结构,增加视图控制器的可重用性。

教程项目

你将在本章中建立一个名为RayPets的应用程序。这是Ray的一个 "宠物 "项目:为精明的iOS用户提供一个专属的宠物诊所。

打开Finder,导航到你下载本章资源的地方。然后,在Xcode中打开starter ▸ RayPets ▸ RayPets.xcodeproj。

建立并运行,你会看到这个主屏幕。

但是,如果你点击 "安排访问",就不会发生任何事情。

在调查原因之前,先看一下文件的层次结构。特别是,你会发现有一个 "屏幕 "组,它包含已经实现的视图控制器和视图。

这些是MVC中典型的视图控制器。然而,根据协调者模式,它们并不了解视图控制器的转换。相反,每一个都会在需要转换的时候通知其委托人。

最后,看看Screens ▸ Protocols ▸ StoryboardInstantiable.swift。RayPets中的每个视图控制器都符合这个协议。它使从故事板中实例化一个视图控制器变得更容易。特别是,它提供了一个名为instanceFromStoryboard的静态方法,返回Self以从故事板中创建一个视图控制器。

接下来,进入Screens ▸ Home ▸ Controllers ▸ HomeViewController.swift。你会看到didPressScheduleAppointment的IBAction,它是在点击Schedule Visit时被调用的。这反过来又调用homeViewControllerDidPressScheduleAppointment的委托。

然而,应用程序的这一部分还没有被实现。要做到这一点,你需要实现一个新的具体协调者和路由器。

在文件的层次结构中,你也会看到已经有一个协调者组。在这里面,你会发现Coordinator.swift已经从playground的例子中复制了。

你还会看到一个Routers组。这里面有Router.swift,它同样也是从playground范例中复制的。

创建AppDelegateRouter

你将首先实现一个新的具体路由器。在Routers组中,创建一个名为AppDelegateRouter.swift的新文件,并将其内容改为如下。

这个路由器的目的是为了从AppDelegate中抓取窗口。

在present(_:animated:onDismissed:)中,你只需设置window.rootViewController并调用window.makeKeyAndVisible来显示该窗口。

这个路由器将由AppDelegate直接持有,并不意味着可以被抛弃。因此,你只需忽略对dismiss(animated:)的调用。

创建HomeCoordinator

你接下来需要创建一个协调者来实例化和显示HomeViewController。在Coordinators组中,创建一个名为HomeCoordinator.swift的新文件。将其内容替换为以下内容,暂时忽略编译器的错误。

这个协调者是非常简单的。你为children和router创建了属性,这是协调者协议所要求的,还有一个简单的初始化器来设置router。

在present(animated:onDismissed:)中,你通过调用一个方便的构造方法instantiate(delegate:),将HomeViewController实例化。然后你把它传递给router.present(_:animated:onDismissed:)来显示它。为了解决编译器错误,你需要让HomeCoordinator符合HomeViewControllerDelegate。在文件的末尾添加以下扩展。

你现在只是简单地把这个方法停顿下来。

使用HomeCoordinator

你还需要实际使用HomeCoordinator。要做到这一点,打开AppDelegate.Swift,用以下内容替换它。

以下是你所做的事情。
  1. 你首先为协调者、路由器和窗口创建了懒惰属性。

  2. 然后在application(_:didFinishLaunchingWithOptions:)中,调用coordinator.present来启动HomeCoordinator的流程。

构建并运行,你会看到应用程序显示HomeViewController,就像以前一样。然而,你现在已经准备好在整个应用程序中实现协调者模式了

特别是,接下来你将专注于实现一个新的协调者,用于安排宠物约会,以响应按排访问。

创建PetAppointmentBuilderCoordinator

打开Models ▸ PetAppointment.swift,你会看到一个模型和相关的构建器已经被定义了。宠物约会和宠物约会建构器(PetAppointmentBuilder)。

你将创建一个新的协调者,以便从用户那里收集PetAppointmentBuilder的输入。在协调者组中创建一个名为PetAppointmentBuilderCoordinator.swift的新文件,并将其内容改为以下内容,暂时忽略编译器的错误。

PetAppointmentBuilderCoordinator有一个builder的属性,你将用它来设置来自用户的输入,以及根据Coordinator的协议,为儿童和路由器提供所需的属性。

在present(animated:onDismissed:)中,你通过instantiate(delegate:)实例化一个SelectVisitTypeViewController,然后将其传递给router.present(_:animated:onDismissed)。

听起来很熟悉,对吗?这与HomeCoordinator非常相似,这也是你会看到的使用协调者的重复模式:你实例化一个视图控制器,将其传递给路由器来呈现,并通过委托回调接收反馈。

因此,你需要使PetAppointmentBuilderCoordinator符合

SelectVisitTypeViewControllerDelegate。在文件末尾添加以下扩展的末尾,同样暂时忽略编译器错误。

下面是这个的作用。

  1. 在selectVisitTypeViewController(_:didSelect:)中,你首先设置builder.visitType。

  2. 然后你打开所选的visitType。

  3. 如果访问类型是好的,你就调用presentNoAppointmentViewController()来显示NoAppointmentRequiredViewController。

  4. 如果是生病了,你就调用presentSelectPainLevelCoordinator()来显示一个SelectPainLevelViewController。

NoAppointmentRequiredViewController和SelectPainLevelViewController都需要各自的委托,但PetAppointmentBuilderCoordinator还不符合它们的委托协议。

接下来,在文件的末尾添加以下内容,使PetAppointmentBuilderCoordinator符合SelectPainLevelViewControllerDelegate。

这与你处理前一个委托人互动的方式相似。

  1. 在selectPainLevelViewController(_:didSelect:)中,你首先设置builder.painLevel。

  2. 然后你打开所选的疼痛级别。

  3. 如果情况符合无或少,你就调用presentFakingItViewController()来显示一个FakingItViewController。

  4. 如果情况符合中度、重度或最坏的可能性,你可以presentNoAppointmentViewController()来显示一个NoAppointmentRequiredViewController。

因此,你需要实现另一个委托协议。FakingItViewControllerDelegate。幸运的是,这个很简单。在文件的结尾处添加以下代码。

这种互动是非常直接的。

  1. 在fakingItViewControllerPressedIsFake(_:)中,你只需调用router.dismiss(animated:)来退出协调者的流程。

  2. 在fakingItViewControllerPressedNotFake(_:)中,你再次调用

presentNoAppointmentViewController()来显示一个NoAppointmentRequiredViewController。

等一下--所以无论用户做什么,他们最终都会看到NoAppointmentRequiredViewController。这到底是怎么回事呢?

我和Ray谈过这个问题,他说这是一种营销策略,目的是让客户到公司来......要么就是,有人没有为这个例子的应用写一个后台。因此,似乎没有地方可以真正提交这些数据。你现在要做什么呢?

不怕!就像在现实生活中,当后方的人不在的时候,你会发现他们在做什么。就像在现实生活中,当后端没有准备好时,你就假扮它! 因此,应用程序最终会显示NoAppointmentRequiredViewController,不管之前的选择如何。

你只需要再实现一个协议就可以了。NoAppointmentRequiredViewControllerDelegate。把这段代码添加到这个文件的结尾。

作为对noAppointmentViewControllerDidPressOkay(_:)的回应,你只需调用router.dismiss(animated:)来退出应用程序的流程。

很好! 你现在只需要一个具体的路由器来和这个协调者一起使用。

创建ModalNavigationRouter

你可能想知道,"我难道不能直接使用playground示例中的NavigationRouter吗?"

NavigationRouter需要一个现有的UINavigationController并将

视图控制器到它上面。由于安排访问的流程与主页的流程不同,Ray真的希望这能以模式呈现,而NavigationRouter并不是用来做这个的。

相反,你将创建一个新的路由器,创建一个新的UINavigationController,并使用现有的parentViewController来展示它,以支持这个用例。

在Routers组中,创建一个名为ModalNavigationRouter.swift的新文件,并将其内容改为以下内容。

以下是你所做的事情。

  1. 首先,你将ModalNavigationRouter声明为NSObject的一个子类。成为NSObject的子类是必须的,因为你以后要让它符合UINavigationControllerDelegate。

  2. 接下来,你为parentViewController、navigationController和onDismissForViewController创建实例属性。

  3. 最后,你声明一个初始化器,接受parentViewController。

接下来,你需要让ModalNavigationRouter符合Router的要求。在文件的底部添加以下代码。

下面是这个的作用。

  1. 在present(_:animated:onDismissed:)中,你首先将给定的视图控制器的onDismissForViewController设置为给定的封闭。

然后检查navigationController是否没有任何视图控制器。这意味着你需要适度呈现导航控制器,你可以通过调用presentModally(_:animated:)来实现。否则,你就对navigationController调用pushViewController。

  1. 在presentModally(_:animated:)中,你首先将视图控制器传递给addCancelButton(to:),以便在视图控制器上设置一个取消按钮。如果该按钮被点击,它将调用cancelPressed(),执行ondismiss动作,最终调用dismiss(animated:)。

  2. 接下来,还是在presentModally(_:animated:)中,你用传入的viewController来设置navigationController的viewController。然后,我们在parenterViewController上调用present,以便以模态呈现navigationController。

  3. 在dismiss(animated:)中,你通过navigationController.viewControllers.first调用performOnDismissed。然后你告诉parentViewController,解散它所呈现的视图控制器,也就是navigationController。

  4. performOnDismissed(for:) 与NavigationRouter中的方法完全相同。它检查视图控制器是否有一个现有的onDismiss闭包,如果发现的话就执行它,最后从onDismissForViewController中删除闭包。

太好了! 完成ModalNavigationRouter的唯一剩余任务是使其符合UINavigationControllerDelegate。为此,在文件的末尾添加以下内容。

这就像playground上的NavigationRouter的实现一样。你检查从视图控制器是否被弹出,如果是,就调用performOnDismissed来执行其on-dismiss闭包。

当然,你也需要将 navigationController.delegate 设置为 ModalNavigationRouter。在init(parentViewController:)的结尾处添加以下内容,就在关闭方法的括号前。

navigationController.delegate = self

使用 "宠物预约建造者"

太棒了! 你已经创建了所有必要的部分来显示预约访问的流程。你现在只需要把它们全部放在一起。

还记得HomeCoordinator中的TO-DO吗?是的,这就是你要触发宠物预约建造者(PetAppointmentBuilderCoordinator)流程的地方。

打开HomeCoordinator.swift,将TO-DO注释替换为以下内容。

let router = ModalNavigationRouter(parentViewController: viewController) 
let coordinator = PetAppointmentBuilderCoordinator(router: router)
presentChild(coordinator, animated: true)

你在这里创建了一个新的ModalNavigationRouter,然后你又用它来创建一个新的PetAppointmentBuilderCoordinator,并把它传递给presentChild(_:animated:)来启动这个流程。

构建并运行,然后点选访问时间表,就可以看到正在运行的访问安排流程。

关键点

你在本章中了解了协调者模式。下面是它的关键点。

接下来该怎么做?

协调者模式是组织长期或非常复杂的应用程序的一个伟大模式。它是由Soroush Khanlou首次介绍给iOS社区的。你可以在他的博文中了解更多关于这种模式的根源。

还有其他一些与Coordinator类似的结构和架构模式。其中一个例子是VIPER,它进一步将对象按责任分开。你可以在objc.io的这篇报道中了解更多关于它的信息。


上一章 目录 我就是最后一章