(翻译) 如何将数据源和代理从ViewController中抽离
原创作者:Paul Hudson
原文链接:How to move data sources and delegates out of your view controllers
这是解决 “臃肿的ViewController” 这个问题系列教程中的第二部分:
- 如何在 iOS app 中使用协调模式
- 如何把数据源和代理从你的 ViewController 抽离出来
- 如何把关于视图构建的代码搬离 ViewController
创建混乱和可读性差的 ViewController 的最简单方法之一是忽略单一责任原则,即程序中的每个部分一次只负责一件事。
忽略这一原则的一个比较有代表性的情况是编写如下代码:
class MegaController: UIViewController, UITableViewDataSource, UITableViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate, UITextFieldDelegate, WKNavigationDelegate, URLSessionDownloadDelegate {
}
如果我问你上面那个ViewController是做什么的,你能够一口气把它说完吗😂?
我不是说你必须每个事情都按照单一原则来做 —— 有时候纯粹的因为业务场景会阻止这种情况的发生,正如你很快就会看到的那样。
然而,ViewController没有理由实现如此多的委托和数据源,事实上这样做会降低视图控制器的可组合性和可重用性。如果将这些协议分割成不同的对象,然后可以在其他ViewController中重用这些对象,或者在同一ViewController中使用不同的对象以在运行时获得不同的行为,这是一个巨大的改进。
在本文中,我想带你经历一些 demo,这些示例将公共数据源和委托从ViewController中取出,这样你就可以轻松地应用到自己的项目中。
在开始之前,请使用 Xcode 创建一个新的iOS应用程序。虽然造成一个非常灾难性的应用程序模版有很多原因,但这导致的结果是:它会成为你自己所有的日常工作一个摇摇欲坠的基础。
我可以写很多关于如何修复它的问题的文章,但是在这里,我们将做最少的工作来修复它的两个问题:ViewController充当它的表视图的数据源和委托。
分离数据源
Apple 的默认模板在 MasterViewController.swift
中有代码,使其充当表视图委托。虽然这对于简单的应用程序或者你正在学习的应用程序来说是很好的,但是对于严肃的应用程序,你应该(总是)把它分成自己的类,然后根据需要进行复用。
这里的过程非常简单,所以让我们一步一步地进行。
首先,转到 “File” 菜单并选择 “New > File”。从 Xcode 提供的列表中选择 Cocoa Touch Class
,然后按 Next。将其设为 NSObject
的子类,给它命名为 “ObjectDataSource
”,然后单击 Next 并创建。
下一步是将所有表视图数据源代码从 MasterViewController.swift
移到 ObjectDataSource.swift
中。所以,选择所有这些代码并将其剪切到剪贴板:
// MARK: - Table View
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return objects.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let object = objects[indexPath.row] as! NSDate
cell.textLabel!.text = object.description
return cell
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
// Return false if you do not want the specified item to be editable.
return true
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
objects.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .fade)
} else if editingStyle == .insert {
// Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view.
}
}
它们都没有任何业务在 ViewController 中,因此打开 ObjectDataSource.swift
并将其粘贴到该类中。
在使用 ObjectDataSource
之前,我们需要对其进行三个小的更改:
- 从所有方法定义中移除
override
。这在 ViewController 中是必需的,因为我们继承了UITableViewController
,但现在我们没有了。- 通过在
NSObject
旁边添加UITableViewDataSource
,使类符合UITableViewDataSource
,如下所示:class ObjectDataSource:NSObject,UITableViewDataSource{
。- 将
var objects=[Any]()
从MasterViewController
上的属性移动到ObjectDataSource
上的属性。
这就完成了 ObjectDataSource
,但是在 MasterViewController
中留下了问题,因为它试图引用一个它不再拥有的对象数组。
要解决这个问题,我们必须在 MasterViewController
中进行两个更改:使用新的 ObjectDataSource
类给它一个数据源属性,然后在使用对象的任何地方引用该数据源。
首先,打开 MasterViewController.swift
并将此新属性赋予类:
var dataSource = ObjectDataSource()
其次,把对象的两个引用更改为 dataSource.objects
。这意味着将 insertNewObject()
更改为:
dataSource.objects.insert(NSDate(), at: 0)
并将 prepare()
方法更改为:
let object = dataSource.objects[indexPath.row] as! NSDate
是的,我知道。苹果的模板代码很差,但是请记住,我们正在努力做最少的工作来解决我们的两个问题。
在这一点上,代码编译得很干净,但它还不能工作。为此,我们需要在MasterViewController
的 viewDidLoad()
方法中进行最后一次更改。添加此行:
tableView.dataSource = dataSource
这将告诉表视图从我们的自定义数据源加载其数据,现在应用程序将返回到它启动时的相同状态。不同之处在于视图控制器已经从 84 行代码减少到 54 行代码,而且你现在可以在其他地方使用该数据源。
这绝对是一个改进,尽管在实践中,如果你正在使用一个数据模型,您可能希望将其移到 coordinator 中,或者如果在 ViewController 中处理数据获取,则可能将其留在 ViewController 中。
分离代理
单一责任原则 有助于我们将应用程序设计成更小、更简单的部分,然后将这些部分组合在一起,形成更复杂的组件。然而,正如我之前所说,有时候作为一个务实的开发人员会让你走上一条不同的道路,我想在继续之前简单地讨论一下。
您已经看到了将表视图数据源输出到它们自己的对象中是多么简单,所以您可能认为我们将创建另一个对象作为表视图委托。然而,这一问题更大,原因有二:
- 代理/委托 通常需要与数据源对话才能执行任何操作。例如,当一个单元格被点击时,需要查看数据源以了解这意味着什么。
-
UITableViewDataSource
和UITableViewDelegate
之间的划分是奇怪的,似乎是任意的。例如,数据源具有titleForHeaderInSection
,而委托具有viewForHeaderInSection和heightForRowAt
。
这意味着将 UITableViewDelegate
拆分为自己的类可能会遇到很多困难。因此,我经常看到两种解决方案:
- 将
UITableViewDataSource
和UITableViewDelegate
处理合并到单个类中。这违背了单一责任原则,但如果它避免了耦合代码,那将是更大的胜利。 - 将 代理/委托 代码留在 ViewController 中。只要方法实现没有包含繁重的逻辑,这并不一定是一个坏的解决方案 —— 只不过是有返回值或着调用协调器而已。如果你发现自己加入了业务逻辑,你应该重新思考。
你喜欢哪个取决于你的个人风格,但在我自己的项目中,我更喜欢保持我的 ViewController 尽可能简单。这意味着它们只是处理视图生命周期事件(viewDidLoad()
,等等),存储一些 @IBOutlets
和 @IBActions
,偶尔根据我正在做的事情来处理模型存储。
记住:这里的目标是让你的应用程序设计更简单、更容易维护和更灵活 —— 如果你增加复杂性只是为了遵循一个原则,你最终会遇到问题。
更简单的代理/委托
尽管 UITableViewDataSource
和 UITableViewDelegate
很难完全分离,但并不是所有的委托都是这样的。相反,许多委托很容易分割成不同的类,这样做,可以增加相同类型的可重用性。
让我们看一个实际的例子:你希望嵌入一个 WKWebView
,它只允许访问少数被认为对它的安全的子网站。在一个简单的实现中,你可以将 WKNavigationDelegate
添加到 ViewController 中,给它一个ChildFriendlyStates
数组作为属性,然后编写一个委托方法,如下所示:
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let host = navigationAction.request.url?.host {
if childFriendlySites.contains(where: host.contains) {
decisionHandler(.allow)
return
}
}
decisionHandler(.cancel)
}
重申一下,当你构建一个小型应用程序时,这种方法是非常好的,因为要么你只是在学习,需要动力,要么你正在构建一个原型,只是想看看什么是有效的。
但是,对于任何较大的应用程序,特别是那些受大规模ViewController困扰的应用程序,你应该将这类代码分成自己的类型:
- 创建一个名为
ChildFriendlyWebDelegate
的类。这需要从NSObject
继承,以便它可以使用WebKit
,并符合WKNavigationDelegate
。 - 在文件中
import WebKit
。 - 将
childfriendlysis
属性和导航委托代码放在其中。 - 在 ViewController 创建
ChildFriendlyWebDelegate
的实例,并使其成为web视图的导航委托。
这里有一个简单的实现:
import Foundation
import WebKit
class ChildFriendlyWebDelegate: NSObject, WKNavigationDelegate {
var childFriendlySites = ["apple.com", "google.com"]
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let host = navigationAction.request.url?.host {
if childFriendlySites.contains(where: host.contains) {
decisionHandler(.allow)
return
}
}
decisionHandler(.cancel)
}
}
这解决了同样的问题,同时巧妙地从ViewController中分割出一个离散块。但你可以而且应该更进一步,比如:
func isAllowed(url: URL?) -> Bool {
guard let host = url?.host else { return false }
if childFriendlySites.contains(where: host.contains) {
return true
}
return false
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if isAllowed(url: navigationAction.request.url) {
decisionHandler(.allow)
} else {
decisionHandler(.cancel)
}
}
这将你的业务逻辑分开(“这个网站允许吗?,这意味着现在可以编写测试,而不必尝试模拟 WKWebView
。我之前说过,但值得一提的是:任何封装了比在方法中 return
简单值的控制器代码 —— 在接触到用户界面时都将更难测试。在这个重构的代码中,所有的知识都存储在 isAllowed()
方法中,因此很容易测试。
这一变化为我们的应用程序带来了另一个更微妙但同样重要的改进:如果我们一开始完成的功能是希望孩子的监护人输入密码来解锁完整的网络,现在我们可以通过将 webView.navigationDelegate
设置为 nil
来启用它,让它允许所有站点。
最终实现的结果是一个我们有了更简单的视图控制器,更可测试的代码,和更灵活的功能 —— 所以为什么你不雕刻这样的功能呢?