第四章:委托模式

委托模式使一个对象能够使用另一个 "帮助者 "对象来提供数据或执行任务,而不是自己去做这个任务。这种模式有三个部分。

通过依赖委托协议而不是具体的对象,实现起来更加灵活:任何实现了协议的对象都可以作为委托对象使用。

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

使用这种模式来分解大类或创建通用的、可重用的组件。委托关系在整个苹果框架中很常见,尤其是UIKit。以数据源命名的对象和以代表命名的对象实际上都遵循委托模式,因为每个对象都要求另一个对象提供数据或做一些事情。

为什么在苹果框架中不只有一个协议,而有两个?

苹果框架通常使用术语DataSource来分组提供数据的委托方法。例如,UITableViewDataSource被期望提供UITableViewCells来显示。

苹果框架通常使用名为Delegate的协议来分组接收数据或事件的方法。例如,每当有一行被选中,UITableViewDelegate就会被通知。

数据源和代理通常被设置为同一个对象,比如拥有一个UITableView的视图控制器。然而,它们不一定非得如此,有时将它们设置为不同的对象也是非常有益的。

Playground实例

让我们来看看一些代码!

打开Starter目录下的FundamentalDesignPatterns.xcworkspace,然后打开Overview页面,如果还没有的话。你会看到Delegation被列在Behavioral Patterns下面。这是因为委托是关于一个对象与另一个对象的交流。

点击 "委托 "链接,打开该页面。

在这个代码例子中,你将创建一个MenuViewController,它有一个tableView,同时作为UITableViewDataSource和UITableViewDelegate。

首先,创建MenuViewController类,在代码示例后直接添加以下代码,暂时忽略任何编译器错误。

import UIKit

public class MenuViewController: UIViewController {
  // 1 
  @IBOutlet public var tableView: UITableView! { 
    didSet { 
      tableView.dataSource = self 
      tableView.delegate = self 
      } 
    }

  // 2 
  private let items = ["Item 1", "Item 2", "Item 3"]
}

下面是这个的作用。

  1. 在真正的应用程序中,你还需要在Interface Builder中为tableView设置@IBOutlet,或者在代码中创建表视图。你也可以选择直接在Interface Builder中设置tableView.delegate和tableView.dataSource,或者你可以在代码中这样做,如图。

  2. 这些项目将被用作显示在表视图上的菜单标题。

正如Xcode可能抱怨的那样,你实际上需要使MenuViewController符合UITableViewDataSource和UITableViewDelegate。

在类的定义下面添加以下代码。

// MARK: - UITableViewDataSource 
extension MenuViewController: UITableViewDataSource {

  public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) 
    cell.textLabel?.text = items[indexPath.row] return cell }

  public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 
    return items.count 
  }
}

// MARK: - UITableViewDelegate 
extension MenuViewController: UITableViewDelegate {
  public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 
    // To do next....
  }
}

UITableViewDataSource和UITableViewDelegate在技术上都是委托协议。它们规定了一个 "帮助 "对象必须实现的方法。

创建你自己的委托也很容易。例如,你可以创建一个委托,当用户选择一个菜单项时就会被通知。

在下面添加以下代码,导入UIKit。

public protocol MenuViewControllerDelegate: class { 
  func menuViewController( 
    _ menuViewController: MenuViewController, 
    didSelectItemAtIndex index: Int) 
}

接下来,在@IBOutlet var tableView上面添加以下属性。

public weak var delegate: MenuViewControllerDelegate?

在iOS中常见的惯例是在一个对象被创建后设置委托对象。这正是你在这里所做的:在MenuViewController被创建后(无论这在应用程序中如何发生),它期望它的delegate属性将被设置。

最后,你需要在用户选择一个项目时实际通知这个委托。

将UITableViewDelegate扩展中的//接下来要做的...注释替换为以下内容。

delegate?.menuViewController(self, didSelectItemAtIndex: indexPath.row)

通常的惯例是,将委托对象,也就是本例中的MenuViewController,传递给其每个委托方法的调用。这样,如果需要的话,委托对象可以使用或检查调用者。

所以现在你已经创建了你自己的委托协议,当列表中的一个项目被选中时,MenuViewController将委托给它。在一个真正的应用程序中,这将处理当项目被选中时该做什么,例如移动到一个新的屏幕。

很简单,对吗?

你应该注意什么?

委托是非常有用的,但它们可能被过度使用。要小心为一个对象创建太多的委托。

如果一个对象需要几个委托,这可能说明它做得太多了。考虑将对象的功能分割成特定的用例,而不是一个万能的类。

很难说多少才算多,没有什么黄金法则。然而,如果你发现自己不断地在不同的类之间切换,以了解正在发生的事情,那么这就表明你有太多的类。同样地,如果你不能理解为什么某个委托是有用的,那就说明它太小了,你把事情分割得太多了。

你还应该注意创建保留循环。大多数情况下,委托属性应该是弱的。如果一个对象一定要设置一个委托,可以考虑将委托作为一个输入添加到对象的初始化器中,并使用"!"将其类型标记为强制解包,而不是通过"!"标记为可选。这将迫使消费者在使用该对象之前设置委托。

如果你发现自己很想创建一个强大的委托,另一种设计模式可能更适合你的使用情况。例如,你可以考虑使用策略模式来代替。更多细节请参见第5章。

教程项目

游乐场的例子已经让你尝到了实现委托模式的滋味。现在是时候将这一理论用于应用程序中了。你将继续上一章的RabbleWabble应用,并添加一个菜单控制器来选择问题组。

如果你跳过了上一章,或者你想重新开始,请打开Finder并导航到你下载本章资源的地方,然后在Xcode中打开starter/RabbleWabble/RabbleWabble.xcodeproj。

你将创建一个新的视图控制器,让用户从问题组的选项列表中进行选择,而不是仅仅显示基本的短语问题。

在文件层次结构中,右键点击控制器,选择新文件。选择iOS标签,从列表中选择Swift文件,然后点击下一步。输入SelectQuestionGroupViewController.swift作为文件名,点击创建。

将SelectQuestionGroupViewController.swift的内容替换为以下内容。

import UIKit 
public class SelectQuestionGroupViewController: UIViewController { 
  // MARK: - Outlets
  @IBOutlet internal var tableView: UITableView! { 
    didSet { 
      tableView.tableFooterView = UIView() 
    } 
  }

  // MARK: - Properties 
  public let questionGroups = QuestionGroup.allGroups() 
  private var selectedQuestionGroup: QuestionGroup!
}

你将使用tableView来显示一个问题组的列表。每当设置tableView时,你要把tableView.tableFooterView设置为一个空白的UIView。这个技巧是为了防止表视图绘制不必要的空表视图单元格,它在默认情况下会在所有其他单元格绘制完毕后绘制。

你将questionGroups设置为QuestionGroup.allGroups(),这是一个由QuestionGroupData.swift中定义的扩展所提供的方便方法,简单地返回所有可能的QuestionGroup。

简单地返回所有可能的QuestionGroup选项。

以后你将使用selectedQuestionGroup来保持用户所选择的任何一个QuestionGroup。

接下来,你需要使SelectQuestionGroupViewController符合UITableViewDataSource以显示表格视图单元。在文件的末尾添加以下扩展。

// MARK: - UITableViewDataSource 
extension SelectQuestionGroupViewController: UITableViewDataSource {
  public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 
    return questionGroups.count 
  }

  public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 
    return UITableViewCell() 
  }
}

现在,你只需从tableView(_:, cellForRowAt:)返回一个空的UITableViewCell作为占位符。

为了真正实现这一点,你需要一个自定义的UITableViewCell子类。这将使你能够完全控制单元格的外观和感觉。在文件层次结构中,右键单击视图,选择新文件。

选择iOS标签,从列表中选择Swift文件,然后点击下一步。输入QuestionGroupCell.swift作为文件名,然后点击创建。

把QuestionGroupCell.swift的内容替换成以下内容。

import UIKit
public class QuestionGroupCell: UITableViewCell { 
  @IBOutlet public var titleLabel: UILabel!   
  @IBOutlet public var percentageLabel: UILabel! 
}

你将很快创建这个视图并连接出口,但现在,再次打开SelectQuestionGroupViewController.swift。

将现有的tableView(_:, cellForRowAt:)替换为以下内容。

public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 
  let cell = tableView.dequeueReusableCell( withIdentifier: "QuestionGroupCell") as! QuestionGroupCell 
  let questionGroup = questionGroups[indexPath.row] 
  cell.titleLabel.text = questionGroup.title
  return cell 
}

构建并运行,以确保你没有任何编译器警告。然而,你还不应该看到任何不同的东西,因为你还没有真正把SelectQuestionGroupViewController添加到应用程序中。接下来你会做这个。

设置视图

打开Main.storyboard,选择对象库按钮,在出现的新窗口的搜索栏中输入UIViewController。

按住Option键以防止窗口关闭,然后拖放一个新的视图控制器到现有场景的左边。

接下来,在对象库窗口的搜索栏中输入UITableView,并将一个新的表视图拖放到新的视图控制器上。

选择表视图,然后选择 "添加新约束 "图标,并执行以下操作。

在对象库窗口的搜索栏中输入UITableViewCell,并将一个表视图单元拖放到表视图上。

最后,在对象库窗口的搜索栏中输入标签,并将两个新标签拖到表视图单元上。
然后,按下对象库窗口上的红色X,将其关闭。

双击第一个标签,将其文本设置为 "标题"。把它放在单元格的最左边,与顶部和左边的边距对齐(它应该显示蓝色指示器)。双击第二个标签,将其文本设置为0%。把它放在单元格的最右边,与顶部和右侧的边距对齐。

你的场景现在应该看起来像这样。

你现在需要在标签上设置约束。

选择 "标题 "标签,然后选择 "添加新约束 "图标,并执行以下操作。

选择0%的标签,然后选择 "添加新约束 "图标,并进行以下操作。

最后,选择百分比标签,进入尺寸检查器,向下滚动到内容拥抱优先,并将水平设置为750。

很好! 你已经把视图都设置好了。接下来你需要设置类的标识,重复使用标识,并挂上IBOutlets。

选择表视图单元格,转到身份检查器,将类设置为QuestionGroupCell。

仍然选择单元格,切换到属性检查器,将标识符设为QuestionGroupCell。

切换到连接检查器,把titleLabel出口拖到标题标签上,把百分比标签拖到0%标签上。

接下来,选择场景中的黄色视图控制器对象,转到身份检查器,将类设置为SelectQuestionGroupViewController。

仍然选择SelectQuestionGroupViewController,进入Connections Inspector,然后将tableView出口拖放到场景中的表视图上。

接下来,选择场景中的表视图,进入连接检查器,将数据源和委托出口都拖放到黄色视图控制器对象上。

为了在应用程序打开时显示SelectQuestionGroupViewController,你需要将其设置为初始视图控制器。

要做到这一点,将目前指向QuestionViewController场景的箭头拖放至指向SelectQuestionGroupViewController场景。

它应该看起来像这样。

构建并运行,看到问题组显示在表格视图上。真棒!我的天哪

然而,点击一个单元格,应用程序什么也没做。你的下一个工作是把它弄好。

显示选定的问题组

再次打开Main.storyboard,选择SelectQuestionGroupViewController场景。按下编辑器菜单按钮,然后嵌入▸导航控制器。

点击SelectQuestionGroupViewController场景中新添加的导航条,选择导航项,然后进入属性检查器,将标题设置为Select Question Group。

接下来你需要创建一个通往QuestionViewController场景的分隔线。

要做到这一点,选择QuestionGroupCell,然后控制拖拽它到QuestionViewController场景上。

在出现的弹出窗口中选择显示。这就创建了一个转场,每当用户点击表视图单元格就会触发。

建立并运行,尝试点击第一个表格视图单元。真棒;你可以看到问题

按下后退按钮,尝试点击第二个单元格。哦,等等......这些是同样的问题吗?是的,它们确实是。你需要在QuestionViewController上设置选定的QuestionGroup。要做到这一点,你需要让SelectQuestionGroupViewController符合UITableViewDelegate的要求,以便在点击表格视图时被通知。

打开SelectQuestionGroupViewController.swift,在文件的末尾添加以下扩展。

// MARK: - UITableViewDelegate 
extension SelectQuestionGroupViewController: UITableViewDelegate {

  // 1 
  public func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
  selectedQuestionGroup = questionGroups[indexPath.row]
  return indexPath
  }

  // 2 
  public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 
    tableView.deselectRow(at: indexPath, animated: true) 
  }

  // 3 
  public override func prepare(for segue: UIStoryboardSegue, sender: Any?) {

    guard let viewController = segue.destination as? QuestionViewController else { 
      return 
    }
    viewController.questionGroup = selectedQuestionGroup 
  }
}

下面是这些方法中的每一种都在做什么。

  1. 这个方法将selectedQuestionGroup设置为被选中的那一个。你必须在这里而不是在tableView(_:, didSelectRowAt:)中这样做,因为didSelectRowAt:是在segue执行后触发的。如果你在didSelectRowAt:中设置selectedQuestionGroup,那么应用程序将在viewController.questionGroup = selectedQuestionGroup这一行崩溃,因为

selectedQuestionGroup仍然是空的。

  1. 在tableView(_:, didSelectRowAt:)中,你只需取消对表视图单元格的选择。这只是一个小技巧,所以如果你以后回到这个视图控制器,你不会看到任何选定的单元格。

  2. 在prepare(for:, sender:)中,你要确保segue.destination实际上是一个QuestionViewController(以防万一!),如果是的话,你要把它的

questionGroup到选定的QuestionGroup。

建立并运行,并尝试像之前那样选择第一个和第二个表视图单元格,以验证其工作是否符合预期。

干得好!

创建一个自定义的委托

这个应用程序已经开始运行了,但仍然缺少一些东西。

  1. 在QuestionViewController上实际显示问题组的标题不是很好吗?你肯定会的。

  2. 你也无法看到QuestionViewController中还有多少问题。如果能显示出来就好了!

  3. 此外,如果你点击QuestionViewController中的所有问题(按绿色复选或红色X按钮),最后什么也没有发生。如果能发生点什么就好了!

  4. 最后,按照惯例,"呈现 "控制器在按下 "取消 "按钮时,会通知其调用者,通常是通过一个代理。目前还没有取消的选项,但有一个返回按钮。如果能用一个自定义栏的按钮项来代替它,那就更好了。

虽然这听起来有点费事,但所有这些实际上只是几行代码而已。你可以做到这一点!

为了解决第一个问题,打开QuestionViewController并替换。

public var questionGroup = QuestionGroup.basicPhrases()

与以下内容。

public var questionGroup: QuestionGroup! { 
  didSet { 
    navigationItem.title = questionGroup.title 
  } 
}

建立并运行,瞧,标题就显示在导航栏上了!

要解决第二个问题,请在其他属性之后直接添加以下内容。

private lazy var questionIndexItem: UIBarButtonItem = { 
  let item = UIBarButtonItem(title: "", 
                             style: .plain, 
                             target: nil, 
                             action: nil)

  item.tintColor = .black

  navigationItem.rightBarButtonItem = item

  return item 
}()

最后,在showQuestion()的末尾添加以下一行。

questionIndexItem.title = "\(questionIndex + 1)/" + "\(questionGroup.questions.count)"

建立和运行,并尝试点击问题。很酷,对吗?

解决后两个问题有点棘手。你需要为它们创建一个自定义的委托。幸运的是,这也是很容易做到的。

在QuestionViewController.swift的顶部添加以下内容,下面是导入UIKit。

public protocol QuestionViewControllerDelegate: class {

  // 1 
  func questionViewController(
    _ viewController: QuestionViewController, 
    didCancel questionGroup: QuestionGroup, 
    at questionIndex: Int)

  // 2 
  func questionViewController(
    _ viewController: QuestionViewController, 
    didComplete questionGroup: QuestionGroup)
}

以下是你将如何使用这些方法。

  1. 当用户按下 "取消 "按钮时,你将调用questionViewController(_:didCancel:at:)

取消按钮时,你将调用questionViewController(_:didCancel:at:),这个按钮你还没有创建。

  1. 当用户完成所有的问题时,你将调用questionViewController(_:didComplete:)。

完成所有的问题。

你还需要一个属性来控制这个委托。在//MARK下面添加以下内容: - 实例属性。

public weak var delegate: QuestionViewControllerDelegate?

接下来,你需要设置这个委托。打开SelectQuestionGroupViewController.swift,在prepare(for:sender:)的末尾添加以下内容。

viewController.delegate = self

然而,这将导致编译器错误,因为你还没有让SelectQuestionGroupViewController符合QuestionViewControllerDelegate。

为了解决这个问题,请在文件的末尾添加以下扩展。

// MARK: - QuestionViewControllerDelegate 
extension SelectQuestionGroupViewController: QuestionViewControllerDelegate {

  public func questionViewController( 
    _ viewController: QuestionViewController,
    didCancel questionGroup: QuestionGroup, 
    at questionIndex: Int) {

      navigationController?.popToViewController(self, animated: true)
  }

  public func questionViewController( 
  
    _ viewController: QuestionViewController, 
    didComplete questionGroup: QuestionGroup) {
      navigationController?.popToViewController(self, animated: true)
    }
}

现在你只需弹出SelectQuestionGroupViewController,不管调用哪个委托方法。

接下来你需要适当地调用这些委托方法。

打开QuestionViewController.swift,将viewDidLoad()替换为以下内容,暂时忽略编译器关于缺少方法的错误。

public override func viewDidLoad() { 
  super.viewDidLoad() 
  setupCancelButton() 
  showQuestion() 
}

接下来,在viewDidLoad()下面添加以下两个方法。

private func setupCancelButton() {

  let action = #selector(handleCancelPressed(sender:))   
  let image = UIImage(named: "ic_menu") 
  navigationItem.leftBarButtonItem =UIBarButtonItem(
    image: image, 
    landscapeImagePhone: nil, 
    style: .plain, 
    target: self, 
    action: action)
}

 @objc private func handleCancelPressed(sender: UIBarButtonItem){ 
   delegate?.questionViewController( 
     self, 
     didCancel: questionGroup, 
     at: questionIndex) 
}

这设置了一个新的取消按钮作为navigationItem.leftBarButtonItem,当它被按下时调用handleCancelPressed(sender:)来通知代理。

构建并运行,试试你的新取消按钮吧

最后,仍然在QuestionViewController.swift中,向下滚动,用下面的内容替换//TODO: - 处理这个...!注释。

delegate?.questionViewController(self, didComplete: questionGroup)

建立并运行,选择 "基本短语 "单元,因为这只有几个问题。按红色的X或绿色的检查按钮,直到你到达终点,看看应用程序现在是如何弹回SelectQuestionGroupViewController的。很好!

关键点

你在本章中了解了委托模式,包括如何使用苹果提供的委托以及如何创建你自己的委托。以下是你学到的关键点。

RabbleWabble开始出现了! 然而,要使它成为下一个App Store的成功,还有很多事情要做。

继续阅读下一章,了解策略设计模式并继续构建RabbleWabble。


上一章 目录 下一章