这是我参与更文挑战的第4天,活动详情查看: 更文挑战
文章来自对 objccn.io/ 相关书籍的整理笔记。“感谢 objc.io 及其撰稿作者们无私地将他们的知识分享给全世界”。
❗️❗️❗️建议亲手实现 github.com/objcio/app-… 相关内容后再进行阅读❗️❗️❗️
❗️❗️❗️可参考笔者重新实现及添加注释版本github.com/LLLLLayer/A… ❗️❗️❗️
Model-View-Controller (MVC) 模式是所有其他模式的基准线。
MVC 的核心思想是,controller 层负责将 model 层和 view 层撮合到一起工作。Controller 对另外两层进行构建和配置,并对 model 对象和 view 对象之间的双向通讯进行协调。所以,在一个 MVC app 中,controller 层是作为核心来参与形成 app 的反馈回路的:
MVC 基于经典的面向对象原则:对象在内部对它们的行为和状态进行管理,并通过类和协议的接口进行通讯;view 对象通常是自包含且可重用的;model 对象独立于表现形式之外,且避免依赖程序的其他部分。而将其他两部分组合起来成为完整的程序,则是 controller 层的责任。
Apple 将 MVC 描述为三种不同的子模式的集合:
- 合成模式 – view 被组装成为层级,该层级按组区分,由 controller 对象进行管理。
- 策略模式 – controller 对象负责协调 view 和 model,并且对可重用的、独立于 app 的 view 在 app 中的行为进行管理。
- 观察者模式 – 依赖于 model 数据的对象必须订阅和接收更新。
对于 MVC 模式的解释通常相当松散,这些子模式 – 特别是观察者模式 – 很多时候没有被严格遵守。
MVC 模式有一些众所周知的缺陷,首当其冲的就是 controller 层拥有太多的职责的问题。MVC 也面临着难以测试的问题,特别是单元测试和接口测试非常困难,甚至不可能实现。除开这些缺点,MVC 是 iOS app 中使用的最简单的模式。
探索实现
创建
Cocoa MVC 对程序的创建过程大体上遵循 Cocoa 框架所提供的默认的启动流程。这一过程的主要目标是保证这三个对象的创建:UIApplication 对象,application delegate,以及主窗口的根 view controller 。该过程的配置分布在三个文件中,它们的默认名称分别是 Info.plist, AppDelegate.swift 和 Main.storyboard。
这三个对象都属于 controller 层级,它们提供了一个可以对后续启动流程进行配置的地方,因此,controller 层将负责所有的创建工作。
将 View 连接到初始数据
MVC app 中的 view 不直接引用 model 对象;View 将保持独立可重用。相反地,model 对象被存储在 view controller 中,这让 view controller 变成了一个不可重用的类,不过这正是 view controller 的目的:它将 app 相关的特定知识传达给程序中的其他部件。
保存在 view controller 里的 model 对象会赋予 view controller 身份 (它让 view controller 知道自己在程序中的位置,以及如何与 model 层进行通话)。View controller 将 model 对象的相关属性值提取出来,并进行变形,然后将变形后的值设置到它持有的 view 中去。
要通过怎样的方式来为 view controller 设置这个身份对象呢?录音 app 中,在 view controller 上设置一个初始 model 值时,有两种不同的方式:
- 通过判定 controller 在 controller 层级上的位置以及 controller 的类型,直接访问一个全局的 model 对象。
- 开始时将 model 对象的引用设置为 nil 并让所有东西保持为空白状态,直到另一个 controller 提供了一个非 nil 值。
第三种选择是在 controller 初始化时将 model 对象当作参数传递进来 (也就是依赖注入),当有可能时,应该选择这种做法。但是,storyboard 的构建流程通常不允许在 view controller 初始化时向它们传递参数。
FolderViewController 是第一种策略下的一个例子。每个文件夹 view controller 最初 folder 属性的设定都如下所示:
var folder: Folder = Store.shared.rootFolder {
// ...
}
复制代码
也就是说,在初始构建时,每个文件夹 view controller 都假定它表示的是根目录。如果这是一个代表子目录的 view controller,它的父文件夹 view controller 将在 perform(segue:) 时将它的 folder 值设置为其他东西。这种方式可以确保文件夹 view controller 中的 folder 属性不是可选值,所以代码也就不需要包含关于检查文件夹对象是否存在的条件测试部分了,因为这个属性至少会是根文件夹。
在录音 app 中,model 对象 Store.shared 是一个延迟构建的单例。第一个文件夹 view controller 在访问 Store.shared.rootFolder 时,可能是这个共享 store 实例被构建的时 候。
PlayViewController (最上层 split view 中的 detail view) 使用的是初始为 nil 的 model 对象的可选值引用,它遵循的是设置身份引用时的第二种策略。
var recording: Recording? {
// ...
}
复制代码
当 recording 的值是 nil 时,PlayViewController 显示一个空白⻚面 (“没有选中录音”)。这里 nil 是一个预期中的状态。从外界对录音属性的设定,可能是通过文件夹 view controller,也可能是通过状态恢复过程来完成的。 不管是哪种情况,一旦这个主 model 对象被设置,controller 就通过更新 view 来进行响应。
文件夹 view controller 是 controller 响应主 model 变更的另一个例子。导航标题 (在屏幕顶部 导航栏上显示的文件夹名字) 需要在 folder 被设定的时候进行更新:
var folder: Folder = Store.shared.rootFolder {
didSet {
tableView.reloadData()
if folder === folder.store?.rootFolder {
title = .recordings
} else {
title = folder.name
}
}
}
复制代码
作为规则,每当读取初始的 model 数据时,必须同时对它的改变进行观察。在文件夹
view controller 的 viewDidLoad 中,将 view controller 添加为 model 通知的观察者:
override func viewDidLoad() {
super.viewDidLoad()
// ...
NotificationCenter.default.addObserver(self,
selector: #selector(handleChangeNotification(_:)),
name: Store.changedNotiifcation,
object: nil)
}
复制代码
状态恢复
MVC 中的状态恢复需要使用 storyboard 系统,它将扮演 controller 的⻆色。要实现这套系统, 必须在 AppDelegate 中实现下面这些方法:
func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
return true
}
func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
return true
}
复制代码
当这两个方法被实现时,storyboard 系统就会接手。对于应当被 storyboard 系统自动保存和恢复的 view controller,要配置一个恢复 ID。比如,录音 app 的根 view controller 的恢复 ID 是 splitController,这可以通过 storyboard 编辑器的 Identity 面板进行设定。对于录音 app 中,除了 RecordViewController (它是故意做成不可保存的) 以外,为其他每个场景都配置了恢复 ID。
虽然 storyboard 系统可以确保这些 view controller 的存在,但是必须进行额外的工作,才能 够保证每个 view controller 中存储的 model 数据与退出 app 前一致。为了存储这些额外的状 态,每个 view controller 都必须实现 encodeRestorableState(with:) 和 decodeRestorableState(with:)。在 FolderViewController 中它的实现如下:
override func encodeRestorableState(with coder: NSCoder) {
super.encodeRestorableState(with: coder)
coder.encode(folder.uuidPath, forKey: .uuidPathKey)
}
复制代码
FolderViewController 将用来标识 Foldermodel 对象的 uuidPath 进行存
储。解码部分要稍微复杂一些:
override func decodeRestorableState(with coder: NSCoder) {
super.decodeRestorableState(with: coder)
if let uuidPath = coder.decodeObject(forKey: .uuidPathKey) as? [UUID],
let folder = Store.shared.item(atUUIDPath: uuidPath) as? Folder {
self.folder = folder
} else {
if let index = navigationController?.viewControllers.index(of: self),
index != 0 {
navigationController?.viewControllers.remove(at: index)
}
}
}
复制代码
在对 uuidPath 进行解码后,FolderViewController 必须检查条目是否依然存在于 store 之中, 这样它才能将 folder 属性设置为该条目。如果该条目已经不在 store 之中,那么 FolderViewController 必须尝试将它自身从导航 controller 的 view controller 列表中移除。
更改 Model
在最广泛的 MVC 的诠释中,并不包含 model 实现方式的细节,也不包含 model 应该如何变更,或者 view 应该如何响应变更等内容。在最早版本的 macOS 中,所遵循的是更早的一套文档 – view 模式,让像是 NSWindowController 或者 NSDocument 这样的 controller 对象直接更改 model 来响应 view action,并且直接在相同函数里对 model 进行更新的做法是十分普遍的。
在 MVC 实现中,认为对 model 改变的行为不应该和对 view 层级变更的行为发生在同一函数中。这些行为应该不受 model state 的任何影响。在构建阶段结束后,对于 view 层级的变更应该遵循 MVC 中观察者模式的部分,只发生在观察的回调中。
观察者模式是在 MVC 中维持 model 和 view 分离的关键。这种方式的优点在于不论变更究竟是源自哪里 (比如,view 事件、后台任务或者网络),都可以确信 UI 是和 model 数据同步的。而且,在遇到变更请求时,model 将有机会拒绝或者修改这个请求:
从一个文件夹中删除某个条目所需要的步骤:
步骤 1: Table View 发送 Action
在示例 app 中,table view 的 data source 被 storyboard 设置为了文件夹 view controller。为了处理删除按钮的点击,table view 将调用它的 data source 上的 tableView(_:commit:forRowAt:).
步骤 2: View Controller 改变 Model
tableView(_:commit:forRowAt:) 的实现将会 (基于 index path) 查找应该删除的条目,并要求父文件夹将它移除:
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath)
{
folder.remove(folder.contents[indexPath.row])
}
复制代码
没有直接将相应的 cell 从 table view 中移除出去。这个操作只在观察到 model 变更时发生。
Folder 上的 remove 方法会通过调用 item.deleted() 来通知条目它已经被删除了。接下来它将这个条目从文件夹的内容中移除。然后它告诉 store 应该保存数据,这包括了刚刚所进行的更改的详细信息:
func remove(_ item: Item) {
guard let index = contents.index(where: { $0 === item }) else { return }
item.deleted()
contents.remove(at: index)
store?.save(item, userInfo: [
Item.changeReasonKey: Item.removed,
Item.oldValueKey: index,
Item.parentFolderKey: self
])
}
复制代码
如果被移除的项目是一个录音,item.deleted() 将会把相关联的文件从文件系统中删除掉。如果它是一个文件夹,它会递归调用将所有子文件夹和录音都移除。
持久化 model 对象以及发送更改通知发生在 store 的 save 方法中:
func save(_ notifying: Item, userInfo: [AnyHashable: Any]) {
if let url = baseURL, let data = try? JSONEncoder().encode(rootFolder) {
try! data.write(to: url.appendingPathComponent(.storeLocation))
// 错误处理被跳过了
}
NotificationCenter.default.post(name: Store.changedNoti?cation, object: notifying, userInfo: userInfo)
}
复制代码
步骤 3: View Controller 观察 Model 变更
文件夹 view controller 已经在 viewDidLoad 为 store 变更的通知设立了
观察,作为回应,这个观察将触发并调用 handleChangeNotification。
步骤 4: View Controller 改变 View
当 Store 更改的通知到达时,view controller 的 handleChangeNotification 将被执行,并在 view 层级上作出对应的变更。对于通知的最简处理方式可能就是不论任意类型的 model 通知到达时,都重新加载 table view 的数据。通常来说,对于 model 通知的正确处理要基于对通知中描述的数据变更进行的正确的 理解。因此,实现中对 model 变更的特质,也就是发生变化的索引号,以及发生变化的种类,通过通知的 userInfo 字典进行了发送。在本例中,对通知的处理涉及到对 tableView.deleteRows(at:with:) 的调用.
@objc func handleChangeNotification(_ notification: Notification) {
// ...
if let changeReason = userInfo[Item.changeReasonKey] as? String {
let oldValue = userInfo[Item.newValueKey]
let newValue = userInfo[Item.oldValueKey]
switch (changeReason, newValue, oldValue) {
case let (Item.removed, _, (oldIndex as Int)?):
tableView.deleteRows(at: [IndexPath(row: oldIndex, section: 0)],with: .right)
// ...
}
} else {
tableView.reloadData()
}
}
复制代码
这段代码中有一个值得注意的缺失的部分:我们没有更新 view controller 自身的任何数据。 View controller 的 folder 值是一个共享的引用值,它直接使用的是 model 层中的对象,所以它已经是更新过的了。在上面的 tableView.deleteRows(at:with:) 调用之后,table view 将会调用文件夹 view controller 上的 data source 的实现,它们将通过访问共享的 folder 引用返回最新状态的数据。
如果使用值类型,它将在赋值时发生复制,因此 store 中对 folder 的更改不会影响到 view controller 中的 folder。这样一来,我们将会需要额外的逻辑来同步两个 folder 的状态。
这个通知处理还依赖了一个捷径:model 存储条目的顺序和显示的顺序是一致的。这并不理想,model 不应该知道它的数据是如何被显示的。从概念上说,一个更清晰 (但需要更多代码) 的实现应该需要 model 发送 set 的变动信息,而不是 array 的信息,通知的处理者需要使用它自己的排序,将变更之前和之后的状态合并,并确定被删除条目的索引。
现在在 MVC 中完成了 “变更 model” 事件回路。因为只在 model 变更的响应中更新 UI,而不是直接在 view action 的响应中这么做,即使文件夹被以其他方式 (比如一个网络事件) 移除出 model,或者是 model 拒绝这次变更时,UI 也会正确更新。这是一种确保 view 层始终 与 model 层同步的十分健壮的方式。
更改 View State
MVC 的 model 层起源于典型的基于文档的 app:任何在保存操作中写入文档的状态都被当作是 model 的一部分来考虑。其他的任意状态 – 包括像是导航状态,临时的搜索和排序值,异步任务的反馈以及未提交的更改 – 传统意义上是被排除在 MVC 的 model 定义之外的。
在 MVC 中,这些被统称为 view state 的 “其他” 状态没有被包含在模式的描述中。依照传统的面向对象的原则,任意的对象都可以拥有内部状态,这些对象也不需要将内部状态的变化传达给程序的其余部分。
基于这种内部处理,view state 不需要遵守任何一条程序中的清晰路径。任意 view 或者 controller 都可以包含状态,这些状态由 view action 进行更新。view state 的处理尽可能地在本地进行:一个 view 或者 view controller 可以独自响应用户事件,对自身的 view state 进行更新。
大部分的 UIView 拥有内部状态,它们可以响应 view action 并进行更新,而不必将这些改变继续传递下去。比如,一个 UISwitch 就可以响应用户的点击事件,从 ON 状态切换到 OFF:
如果 view 本身不能更改它的状态,那么事件回路就要变⻓一步。比如,当一个按钮的标签需要在它被点击时发生变更 (比如播放按钮) 或者用户点击一个列表中的 cell 时,新的 view controller 需要被推送到导航栈上,都属于这样的变更。
View state 依然存在于一个特定的 view controller 和它的 view 中。不过,对比一个改变自身状态的 view,现在有机会自定义一个 view 的状态变更 (比如下面第一个例子中播放按钮标题的变更) 或者让 view state 跨 view 进行变更 (由下面的第二个例子,推送新的文件夹 view controller ,进行举例说明)。
示例 1: 更新播放按钮
PlayViewController 中的播放按钮会依据播放状态将它的标题在 “Play”,“Pause” 和 “Resume”之间切换。从用户的视⻆来看,当按钮表示为 “Play” 时,按下它会将标题改变为“Pause”;在播放结束前再次按下它会使其变为 “Resume”。
- 步骤 1: 按钮向 View Controller 发送 Action
播放按钮通过 storyboard 中的 IBAction 连接到 PlayViewController 的 play 方法。点击这个按钮会调用 play 方法:
@IBAction func play() {
// ...
}
复制代码
- 步骤 2: View Controller 改变内部状态
play 方法的第一行负责更新音频播放器的状态:
@IBAction func play() {
audioPlayer?.togglePlay()
updatePlayButton()
}
复制代码
- 步骤 3: View Controller 更新按钮
play 方法的第二行调用了 updatePlayButton,该方法依据 audioPlayer 的状态直接为播放按钮设置新的标题:
func updatePlayButton() {
if audioPlayer?.isPlaying == true {
playButton?.setTitle(.pause, for: .normal)
} else if audioPlayer?.isPaused == true {
playButton?.setTitle(.resume, for: .normal)
} else {
playButton?.setTitle(.play, for: .normal)
}
}
复制代码
.pause,.resume 和 .play 是定义在 String 上的本地化后的静态常量。
这个过程中相关的部件数量是最少的:按钮将事件发送给 PlayViewController,接下来,后者为按钮设定新的标题。
示例 2: 推入文件夹 View Controller
文件夹 view controller 中的 table view 负责展示两种对象:录音和子文件夹。当用户点击子文 件夹时,需要配置新的文件夹 view controller,并将它推到导航栈上。因为使用了带有 segue 的 storyboard 来达成这个目的,下面的具体的步骤仅仅只是把上面图表中的步骤松散地关联起来。
- 步骤 1: 触发 Segue
因为在 storyboard 中,子文件夹的单元格通过一个 push segue 与文件夹 view controller 相 连,所以点击子文件夹的单元格将会触发一个 showFolder segue。这会导致 UIKit 创建一个新的文件夹 view controller。
这个步骤是 target/action 模式的变种。在幕后UIKit 做了更多的工作,但是结果是源 view controller 的 prepare(for:sender:) 方法被调用。
- 步骤 2 & 3: 配置新的文件夹 View Controller
prepare(for:sender:) 会通知当前的文件夹 view controller 哪个 segue 正在发生中。检查 segue 的 identifier 后,对新的文件夹 view controller 进行配置:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let identifier = segue.identifier else { return }
if identi?er == .showFolder {
guard
let folderVC = segue.destination as? FolderViewController,
let selectedFolder = selectedItem as? Folder else { fatalError() }
folderVC.folder = selectedFolder
}
// ...
}
复制代码
首先检查目标 view controller 是否拥有正确的类型,以及是否确实选择了一个文件夹。如果两个条件不能同时满足,那么这应该是一个编程上的错误,让 app 崩溃。如果所有事情都和预期一样,将子文件夹设置给新的文件夹 view controller。
Storyboard 的机制会负责实际将新的 view controller 展示出来的工作。当配置好新的 view controller 后,UIKit 会把它推入导航栈 (不需要自己去调用 pushViewController)。 将 view controller 推入导航栈的操作,将使导航 controller 把所推入的 view controller 的 view 装载到 view 层级上。
和上面的播放按钮例子类似,UI 事件 (选择子文件夹单元格) 尽可能地在本地处理。Segue 机制模糊了这个事件确切的路径,但是从代码的⻆度来看,除了初始的 view controller 以外,没有别的部件受到影响。View state 被相关的 view 及 view controller 隐式表示。
如果想要在 view 层级的不同部分共享 view state,需要在 view (或者 view controller) 层级上找到它们共同的祖先,并在那管理状态。比如,想要在播放按钮标签和播放器之间共享播放状态的话,我们可以将状态存储到 PlayViewController 中去。当我们在几乎所有部件中需要某个 view state 时 (比如,一个决定 app 是否应该处于深色模式的布尔值),需要将它放在最顶层的 controller 中 (比如,app 代理)。但是在实践中,将 view state 放到层级的顶层 controller 对象中的做法并不常⻅,因为这会要求层级的每一层之间存在通讯的管道。 所以,大部分会选择使用单例来作为替代。
测试
自动测试可以由好几种不同形式进行。从最小的粒度到最大的粒度,包括:
- 单元测试 (将独立的函数独立出来,并测试它们的行为)。
- 接口测试 (使用接口输入并测试接口输出得到的结果,输入和输出通常都是函数)。
- 集成测试 (在整体上测试程序或者程序的主要部分)。
Cocoa MVC 模式已经有超过 20 年历史了,所以在它被创建的时候,并没有考虑单元测试这件事。
可以测试 model 层,因为它和程序的其他部分是独立的,但是这并不会对测试面向用户的状态有什么帮助。可以使用 Xcode 的 UI 测试,也就是一些运行整个程序并尝试使用 VoiceOver 或者辅助访问的 API 读取屏幕的自动化脚本,但这样的测试速度很慢,容易导致时间上的问题,而且难以提取精确的结果。
如果想要在代码层级对 MVC 的 controller 和 model 层进行测试,唯一可行的选项是写集成测试。集成测试需要构建一个自包含版本的 app,操作其中的某些部分,然后从其他部分读取数据,确保结果在对象之间按照期望的方式传递。
对于录音 app,这种测试需要一个 Store 对象,所以可以创建一个 (只在内存中存在的) 不包含 URL 的 store,并添加一个测试条目 (文件夹和录音):
func constructTestingStore() -> Store {
let store = Store(url: nil)
let folder1 = Folder(name: "Child 1", uuid: uuid1)
let folder2 = Folder(name: "Child 2", uuid: uuid2)
store.rootFolder.add(folder1)
folder1.add(folder2)
let recording1 = Recording(name: "Recording 1", uuid: uuid3)
let recording2 = Recording(name: "Recording 2", uuid: uuid4)
store.rootFolder.add(recording1)
folder1.add(recording2)
store.placeholder = Bundle(for: FolderViewControllerTests.self).url(forResource: "empty", withExtension: "m4a")!
return store
}
复制代码
store.placeholder 是 store 中的一个专⻔用来测试的特性:如果 URL 是 nil,那么这个占位符 就会在 Store.fileURL(for:) 被调用时作为被获取的录音的音频文件返回。构建了 store 之后,需要一个使用该 store 的 view controller 层级:
func constructTestingViews(store: Store,
navDelegate: UINavigationControllerDelegate)
-> (UIStoryboard, AppDelegate, UISplitViewController, UINavigationController, FolderViewController) {
let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main)
as! UINavigationController
navigationController.delegate = navDelegate
let rootFolderViewController = navigationController.viewControllers.first as! FolderViewController
rootFolderViewController.folder = store.rootFolder
rootFolderViewController.loadViewIfNeeded()
// ...
window.makeKeyAndVisible()
return (storyboard, appDelegate, splitViewController, navigationController, rootFolderViewController)
}
复制代码
上面展示了主导航 controller 和根 view controller 的创建方式。函数在后面用类似的方式继续构建副导航 controller,play view controller,split view controller,窗口,以及 app delegate,这形成了一个完整的用户界面。一路向上构建到窗口后,需要调用 window.isHidden = false,否则许多动画和展示行为将不会发生。
注意将导航 controller 的代理设置为了 navDelegate 参数,这个参数将会是负责运行测试的 FolderViewControllerTests 类的实例,根 view controller 的 folder 被设置为了测试用的 store 的 rootFolder。
在测试的 setUp 方法中调用上面的构造函数,来初始化 FolderViewControllerTests 实例的成员。当这一切完成时就可以开始写测试了。
集成测试通常需要对初始环境进行某种程度的配置,才能在它上面执行某个 action 并测量结果。在 app 集成测试中,对结果的测量可能会很简单 (比如访问配置好的环境中的某个对象上的可以读取的属性),也可能会非常困难 (比如在 Cocoa 框架中进行多次异步交互)。
一个相对简单的测试例子是,在删除列表的行的时候,测试 commitEditing 行为:
func testCommitEditing() {
// 验证要调用的行为已被连接
let dataSource = rootFolderViewController.tableView.dataSource
as? FolderViewController XCTAssertEqual(dataSource, rootFolderViewController)
// 确认删除之前条目存在
XCTAssertNotNil(store.item(atUUIDPath: [store.rootFolder.uuid, uuid3]))
// 执行删除行为
rootFolderViewController.tableView(rootFolderViewController.tableView,
commit: .delete, forRowAt: IndexPath(row: 1, section: 0))
// 确认条目已被删除
XCTAssertNil(store.item(atUUIDPath: [store.rootFolder.uuid, uuid3]))
}
复制代码
上面的测试验证了根 view controller 被作为 data source 正确配置,并直接对 data source 的 tableView(_:commit:forRowAt:) 进行了调用。最后验证了这个 action 将条目从 model 中被删除。
当涉及到动画或者潜在的依赖于模拟器的变更时,测试就会更加复杂。在录音 app 中,最复杂的测试应该是在文件夹 controller 中选择某个录音条目,并在 split view 的 detail view 一侧确认录音能被正确显示。这个测试需要处理 split view controller 的折叠和非折叠状态的不同,而且它需要等待导航 controller 的推入行为结束:
func testSelectedRecording() {
// 选择一行,这样 `prepare(for:sender:)` 可以读取选择的行
rootFolderViewController.tableView.selectRow(at: IndexPath(row: 1, section: 0),
animated: false, scrollPosition: .none)
// 处理 split view controller 的折叠和非折叠状态
if self.splitViewController.viewControllers.count == 1 {
ex = expectation(description: "Wait for segue")
// 触发转场
rootFolderViewController.performSegue(withIdenti?er: "showPlayer", sender: nil)
// 等待导航 controller 将折叠的 detail view 推入 waitForExpectations(timeout: 5.0)
// 移动至 `PlayViewController`
let collapsedNC = navigationController.viewControllers.last
as? UINavigationController
let playVC = collapsedNC?.viewControllers.last as? PlayViewController // 测试结果
XCTAssertEqual(playVC?.recording?.uuid, uuid3)
} else {
// 处理非折叠状态
}
}
复制代码
创建的 expectation (ex) 会在主导航 controller 将新的 view controller 推入导航栈时被 fulfill。 主导航 controller 的这个变更发生在 detail view 被折叠到 master view 的时候 (也就是发生在除了 iPhone Plus 横屏模式以外的所有 iPhone 的紧凑显示模式下):
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { ex?.fulfill()
ex = nil
}
复制代码
为文件夹 view controller 写的测试覆盖了大约 80% 的代码行数,并测试了所有重要行为,虽然这些集成测试可以搞定工作,但 testSelectedRecording 测试已经说明了,想要正确书写集成测试需要大量关于 Cocoa 框架是如何操作的知识。
讨论
MVC 有其优点,它是 iOS 开发中阻力最低的架构模式。Cocoa 中的每个类都是在 MVC 的条件下进行测试的。像 storyboard 这样的特性,是与框架和类高度集成的,它可以和使用 MVC 的程序更顺滑地一同工作。在网络上搜索时,相比于其他任何一种设计模式,可以找到更多按照 MVC 进行实现的例子。而且,在所有模式中,MVC 通常都是代码量最少,设计开销最小的模式。
MVC 中有两个最常⻅的问题。
观察者模式失效
对于基础的 MVC 实现,选择使用 Foundation 的 NotificationCenter 来进行 model 通知的广播。作出这个选择的原因是希望使用基本的 Cocoa 来进行实现,而不去使用框架或者其他抽象。
不过,这样的实现要求在很多地方一起对某个值进行正确的更新。比如文件夹 view controller 中的 folder,是在父文件夹 view controller 的 prepare(for:sender:) 方法中被设定的:
guard let folderVC = segue.destination as? FolderViewController,
let selectedFolder = selectedItem as? Folder
else { fatalError() }
folderVC.folder = selectedFolder
复制代码
在之后,这个文件夹 controller 将对 model 的通知进行观察,以获取该值的变更情况。
在通知处理的部分,如果发出通知的对象是当前文件夹,那么对文件夹进行更新:
@objc func handleChangeNotification(_ notification: Noti?cation) { // 处理对当前文件夹的变更
if let item = notification.object as? Folder, item === folder {
let reason = notification.userInfo?[Item.changeReasonKey] as? String
if reason == Item.removed, let nc = navigationController {
nc.setViewControllers(nc.viewControllers.filter { $0 !== self }, animated: false)
} else {
folder = item
}
}
// ...
复制代码
最好是有一种方式,能让 folder 的初始设定和在 viewDidLoad 中建立观察者之间没有时间空隙。如果能把需要对 folder 进行设定的地方统一就更好了。
使用键值观察 (KVO) 来替代通知是可选项之一,但是,在大部分情况下这通常需要同时观察多个不同的键路径 (比如在观察父文件夹的 children 属性的同时还要观察当前的子文件夹),这让 KVO 和通知的方式相比,实际上并没有更加稳定。由于它需要每个被观察的属性都声明为 dynamic,这也让它在 Swift 中远远没有在 Objective-C 中那样流行。
对观察者模式最简单的改进方式是,将 NotificationCenter 进行封装并为它实现 KVO 所包含的初始化的概念。这个概念会在观察被建立的同时发送一个初始值,这允许将设定初始值和观察后续值的操作合并到一个单一管道中去。
将 viewDidLoad 中的观察的代码和上面 handleChangeNotification 中的代码合并为下面的代码:
observations += Store.shared.addObserver(at: folder.uuidPath) {
[weak self] (folder: Folder?) in
guard let strongSelf = self else { return }
if let f = folder { // 更改
strongSelf.folder = f
} else { // 删除
strongSelf.navigationController.map {
$0.setViewControllers($0.viewControllers.filter { $0 !== self },
animated: false) }
}
}
复制代码
这么做并没有减少很多代码,但是 self.folder 值现在只在两个地方被设置了 (在观察回调中,以及在父文件夹 controller 的 prepare(for:sender:) 中),减少了代码中对它进行更改的路径数量。另外,用户代码中不再需要进行动态类型转换了,在初始值设定和建立观察之间也不在存在空隙:初始值不再是实际的数据,它只是一个标识,通过观察回调是所需要的访问实际数据的唯一方式。
这种类型的 addObserver 可以被实现在 Store 的扩展上,而不需要对 Store 本身进行任何更改,这是这种实现的一个优势:
extension Store {
func addObserver<T: AnyObject>(at uuidPath: [UUID],
callback: @escaping (T?) -> ()) -> [NSObjectProtocol] {
guard let item = item(atUUIDPath: uuidPath) as? T else {
callback(nil)
return []
}
let o = NotificationCenter.default.addObserver(
forName: Store.changedNotification,
object: item,
queue: nil) { notification in
if let item = notification.object as? T, item === item {
let reason = notification.userInfo?[Item.changeReasonKey] as? String
if reason == Item.removed {
return callback(nil)
} else {
callback(item)
}
}
}
callback(item)
return [o]
}
}
复制代码
Store 依然发送同样的通知,只是以另一种方式订阅了这个通知,处理通知的数据 (比如检查 Item.changeReasonKey 是否存在),并且处理一些其他模板代码,这样每个 view controller 需要的工作就能简单一些。
肥大 View Controller 的问题
非常大的 view controller 通常进行了它们的主要工作 (观察 model,展示 view,为它们提供数据,以及接收 view action) 之外的无关工作;它们要么应该被打散成多个各自管理一个较小部分的 view 层级的 controller;要么就是因为接口和抽象没能将一段程序的复杂度封装起来, view controller 做了太多打扫垃圾的工作。
在很多情况下,解决这个问题的最佳途径就是主动地将尽可能多的功能移动到 model 层中。比如排序,数据获取和处理等方法,因为不是 app 的存储状态的一部分,所以通常会被放到 controller 中。但是它们依然与 app 的数据和专用逻辑相关,把它们放在 model 中会是更好的选择。
简单看一些 GitHub 上流行 的 iOS 项目的大尺寸 view controller。
Wikipedia 的 PlacesViewController
这个文件一共有 2,326 行。view controller 包含了下列⻆色:
- 配置和管理地图 view,该 view 中显示位置结果 (400 行)
- 通过 location manager 获取用户位置 (100 行)
- 执行搜索并且收集结果 (400 行)
- 将结果分组,并显示在地图的可⻅区域 (250 行)
- 将搜索建议填充到 table view 并进行管理 (500 行)
- 处理像是搜索建议的 table view 这样的覆盖层的布局 (300 行)
这个例子展示了 view controller 肥大化的三大病因:
- 管理了超过一个的主 view (地图 view 和搜索建议 table view)。
- 创建和执行异步任务 (比如获取用户位置),虽然 view controller 只对任务的结果感兴趣 (本例中,是用户的位置)。
- Model/专用逻辑 (搜索和处理结果) 在 controller 层被操作。
可以通过将主 view 分开到它们自己的更小的 controller 中简化场景的 view 依赖。它们甚至都不需要是 UIViewController 的实例,而只需要是场景所拥有的子对象即可。在父 view controller 中剩下的工作就只有集成和布局了 (而且原来复杂的布局也能够随着 view controller 的简化而被重构)。
可以创建工具类来执行像是获取用户位置信息这种异步任务,在 controller 中,唯一需要的代码只是创建任务和回调闭包。
从 app 设计架构的视⻆来说,最大的问题在于 controller 层中的 model 或者专用逻辑。这些代码缺少一个实际用来执行搜索和收集搜索结果的模型。在 view controller 中确实有一个 dataStore 对象,但是代码没有围绕它进行任何抽象,所以它自己并没有帮上什么忙。View controller 实际上还是自己做了所有的搜索和数据处理工作,这些任务本来应该在另外的地方被处理。甚至是像按照可视区域对结果分组这样的操作,也应该由模型或者是在模型和 view controller 之间的转换对象来进行执行。
将部分 view controller 中的代码抽离出来,在本质上并没有降低整个程序的复杂度。但是,这么做确实降低了 view controller 本身的复杂度。在优化一个 view controller 时,可以对照 MVC 的行为框图,来逐一确认某项操作是否真的合适被放在 view controller 中。将非直接相关的部分移出去,是重构工作中最为直接简单,容易实现,同时也是最有效果的步骤。
WordPress 的 AztecPostViewController
该文件有 2,703 行,view controller 的职责包括:
- 通过代码创建子 view (300 行)
- 自动布局约束和放置标题 (200 行)
- 管理文章发布流程 (100 行)
- 设置子 view controller,并处理它们的结果 (600 行)
- 协调,观察和管理 text view 的输入 (300 行)
- 显示警告和警告的内容 (200 行)
- 追踪媒体文件上传 (600 行)
媒体文件的上传是一个专用服务,应该很容易被移动到它自己的模型层服务中去。 同时,剩余代码的 75% 都能够通过改善程序其他部件的接口来移除掉。
在 AztecPostViewController 中所处理的模型,服务或者 child view controller 都没有任何一个能用一行代码来使用。显示弹窗警告的代码在不同的地方出现了好多次。Child view controller 的设置花费了 20 行,而在它结束后,又花了 20 行来进行处理。虽然 text view 是自定义的 Aztec.TextView,但在 view controller 中依然有上百行的代码在调整它的行为。
在这些例子中,其他部件都无法完成的它们自己的工作,而 view controller 总是被用来修补它们。这些行为应当尽可能地集成到各自的部件中去。当无法更改一个部件的行为时,可以围绕这个部件写一个封装,而不是将这些逻辑放到 view controller 里去。
Firefox 的 BrowserViewController
这个文件的⻓度是 2,209 行。这个 view controller 包含了 1,000 行以上的代理实现。
BrowserViewController 是整个程序的顶层 view controller,这些代理实现表示低层级的 view controller 使用 BrowserViewController 来在程序中进行行为中继。
没错,controller 层确实有在程序间传递行为的责任,所以这个例子的问题并不是模型或者专用逻辑的职责被放到了错误的层中。不过这些代理中很多部分其实和 BrowserViewController 管理的 view 并没有关系 (它们只是想要访问存储在 BrowserViewController 中的其他部件或者状态)。
相比于将这个责任放在已经承担了很多其他责任的 view controller 上,这些代理回调其实可以全部重新移动到一个专⻔用来在 controller 层进行中继处理的协调器或者抽象的 (非 view controller 的) controller 上。
使用代码而不是 Storyboard
如果不使用 storyboard,可以选择用代码来定义 view 层级。这样的变更能在构建阶段更多的控制力,其中最大的优点在于可以更好地掌控依赖。
比如,在使用 storyboard 时,没有办法能够保证在 prepare(for:sender:) 中所有 view controller 所需要的属性都被设置了。已经使用了两种不同的方式进行模型传递:有一个默认值的方式 (比如 FolderViewController),以及使用可选值类型 (比如 PlayViewController)。两种方式都没有保证对象一定被传递;如果们忘记了这件事,代码将默默地继续运行,但是要么值是错误的,要么会是一个空白值。
当摆脱 storyboard 时,可以在构建过程中得到更多的控制权,并可以让编译器确保必要的参数被正确传递。使用 instantiate 方法时,编译器将帮助并确保提供了 folder。理想情况下,instantiate 应当是一个初始化方法,但是这只有在完全将 storyboard 移除的时候才可能办到。
可以通过为 view 类添加自定义的初始化方法,或者写一个函数来构建特定的 view 层级的方式,将相同的技术应用到 view 的构建过程中去。总体上,不使用 storyboard 可以利用 Swift 的所有语言特性:泛型 (比如配置一个泛型的 view controller),一等函数 (比如,使用函数配置 view 外观或者设置回调),带有关联值的枚举 (比如依据模型而互斥的状态) 等等。
在扩展中进行代码重用
要在不同的 view controller 间共享代码,一个常⻅的方法是创建一个包含共通功能的父类。然后 view controller 就可以通过子类来获得这些功能了。这种技术可以工作,但是它有一个潜在的不足:只能为新类选定单个父类。这种方式还经常会导致一个共享的父类包括了项目中全部的共享的功能。这样的类通常会变得非常复杂,难以维护。
在 view controller 中共享代码的另一种选择是使用扩展。在多个 view controller 中都出现的方法有时候能够被添加到 UIViewController 的扩展中去。这样一来,所有的 view controller 就 都能获取这个方法了。比如,可以为 UIViewController 添加一个显示文本警告的简便方法。
为了扩展能够有用,通常需要 view controller 具有一些特定的能力。比如,某个扩展可能需要 view controller 拥有一个被显示的 activity indicator,或者需要 view controller 上有某个特定的方法可用。我们可以在协议里确保这些能力。举个例子,可以共享处理键盘的代码,这些代码会在键盘显示或隐藏时对 view 进行缩放。如果我们使用了自动布局,那么我们可以指定我们需要一个可缩放的底部约束:
protocol ResizableContentView {
var resizableConstraint: NSLayoutConstraint { get }
}
// 接下来,我们可以为每个实现了该协议的 UIViewController 添加扩展:
extension ResizableContentView where Self: UIViewController {
func addKeyboardObservers() {
// ...
}
}
复制代码
现在,任何一个实现了 ResizableContentView 的 view controller 同时也获得了 addKeyboardObservers 方法。可以在想要共享代码但又不想引入子类的其他情况下,使用相同的技术。
利用 Child View Controller 进行代码重用
Child view controller 是在 view controller 之间共享代码的另一种选项。比如,要是想要在文件夹 view controller 的下部显示一个小的播放器,可以在文件夹 view controller 上添加一个 child view controller,这样,播放器的的逻辑也被包含了,而且不会弄乱文件夹 view controller。相比与在文件夹 view controller 中重复一遍相关代码,这种做法要容易得多,也更好维护。
如果有某个单个的 view controller,但是它包含两个完全不同的状态,也可以将它拆分为两个 view controller (每个 controller 管理一个状态),并用一个容器 view controller 在这两个 child view controller 之间进行切换。可以把 PlayViewController 拆分为两个独立的 view controller:一个负责显示 “没有选中的录音” 的文本,另一个显示录音。容器 view controller 可以按照状态的不同,在两者之间进行切换。这种方式有两个好处:首先,如果将标题 (和其他一些属性) 写为可配置的话,这个空白的 view controller 就可以被重用。其次,PlayViewController 不再需要处理当录音为 nil 时的情况;只需要在拥有录音时候创建并展示它就可以了。
提取对象
许多大的 view controller 都有很多⻆色和职责。虽然想要发现某个地方可以重构并非一件易事,但是通常一个⻆色或者职责都可以被提取为一个单独的对象。区分 Apple 所定义的协调 controller (coordinating controller) 和调解 controller (mediating controller) 会很有意义. 一个协调 controller 是 app 特定的,而且一般来说是无法重用的 (比如,几乎所有的 view controller 都是协调 controller)。
调解 controller 则是一个可重用的 controller 对象,通过配置,它可以被用来执行特定的任务。 比如 AppKit 框架提供了像是 NSArrayController 或者 NSTreeController 这样的类。在 iOS 中, 可以构建出类似的部件。通常用来遵守某个协议的代码 (比如文件夹 view controller 中遵守 UITableViewDataSource 的部分),比较适合被提取为调解 controller。将这些遵守协议的代码抽离到单独的对象中,可以有效减少 view controller 的代码量。首先,可以将文件夹 view controller 中 table view 的 data source 不加修改地提取出来,然后,在文件夹 controller 本身中,将 table view 的 data source 设置为这个新的数据源。
class FolderViewDataSource: NSObject, UITableViewDataSource {
var folder: Folder
init(_ folder: Folder) {
self.folder = folder
}
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
// ...
}
lazy var dataSource = FolderViewDataSource(folder)
override func viewDidLoad() {
super.viewDidLoad() tableView.dataSource = dataSource
// ...
}
复制代码
还需要对 view controller 的 folder 进行观察,并在它变化时改变 data source 中的 folder。以这些额外的通讯作为代价,可以将 view controller 分为两个部分。这种分离没有带来太大的开销,但是当两个部件紧密耦合在一起,并且需要对很多状态进 行通讯和共享的时候,所带来的开销可能会非常大,反而使得事情变得更加复杂。
如果有多个类似的对象,可能能够将它们泛型化。在上面的 FolderViewDataSource 中,可以将其中的存储属性从 Folder 变为 [Item] (一个 Item 可以是一个文件夹,也可以是一条录音)。让 data source 对 Element 类型进行泛型抽象,并将 Item 相关的逻辑移除出去。表格单元格的配置 (通过 configure 参数) 和删除逻辑 (通过 remove 参数) 现在是从外面传递进来的
class ArrayDataSource<Element>: NSObject, UITableViewDataSource {
// ...
init(_ contents: [Element],
identifier: @escaping (Element) -> String,
remove: @escaping (_ at: Int) -> (),
configure: @escaping (Element, UITableViewCell) -> ()) {
// ...
}
// ...
}
// 想要以我们的文件夹和录音为它进行配置,我们需要下面的代码:
ArrayDataSource(folder.contents,
identifier: { $0 is Recording ? "RecordingCell" : "FolderCell" },
remove: { [weak self] index in
guard let folder = self?.folder else { return }
folder.remove(folder.contents[index]) },
configure: { item, cell in
cell.textLabel!.text = "\((item is Recording) ? "a" : "b" })
复制代码
在小型的示例 app 中,将代码泛型化并没有带来太多收益。但是在更大一些的 app 中这种技术可以减少重复代码,能以类型安全的方式进行 cell 重用,并使 view controller 更加简单。
简化 View 配置代码
如果 view controller 需要构建和更新非常多的 view,那么将这部分 view 配置的代码提取出来会很有帮助。特别是对于那些不需要双向通讯的,“设置完后就可以不再关心” 的情况,这样做能够简化的 view controller。举例来说,当有一个很复杂的 tableView(_:cellForRowAtIndexPath:) 时,可以将部分代码从 view controller 中移出来:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let item = folder.contents[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: identifier,
for: indexPath)
cell.configure(for: item) // 被提取的代码 return cell
}
extension UITableViewCell {
func configure(for item: Item) {
textLabel!.text = "\((item is Recording) ? "A" : "B") \(item.name)"
}
}
复制代码
还可以使用这种模式在不同的 view controller 之间共享配置和布局代码。很容易看到,configure(for:) 现在不依赖于 view controller 的任何状态,所有的状态都是通过参数传递进去的,这样一来,cell 就很容易测试了。