原文:UndoManager Tutorial: How to Implement With Swift Value Types
作者:Lyndsey Scott
译者:kmyhy
注: 本教程基于 Xcode 10 和 iOS 12。
金无足赤,人无完人。只要你实现了 UndoManager,你的用户也没有必要做完人。
UndoMananger 为 app 提供了一种简单的 undo/redo 机制。通过让事物逐步“局部化”,你还能在一定程度上减少在推断中偶然出现的缺陷。
在本教程中,你将编写一个 People Keeper 的 app,使用 Swift 的值类型改善你的局部推断(local reasoning),学习如何通过改进局部推断来实现完美的 undo/redo。
注:本教程假设你拥有中级 iOS 和 Swift 开发基础。如果你刚开始学习 iOS / Swift 开发,请先阅读我们的《教你用 Swift 编写 iOS app》系列教程。
通过 Dowload Materials 按钮下载教程代码。编译运行 app:
这个 app 中有一些你可能会遇到并记住的人。点击 Bob、Joa 或 Sam,你将在 cell 下面看到他们的体征、喜好、忌讳。
点击 PeopleListViewContoroller(左图)中的 Bob,会打开 PersonDetailViewController(右图)。右边一系列截图显示了 PersonDetailViewController 的 scroll view 中的内容。
要理解示例代码,请浏览项目文件并仔细阅读其中的注释。添加、编辑联系人的工作就留给你自己去完成了。
如果 Sam 剃掉了胡子、Joan 戴起了眼镜怎么办?又或者,在那个特别寒冷的冬天,Bob 突然对那个冬天中的每一样东西都不喜欢了怎么办?在真实背景中,能够修改 People Keeper 中的人物是非常有用的。
首先,如果在 PersonDetailViewController 中选择某个新特性,那么预览页应该随之改变。为此,在 PersonDetailViewController 的 UICollectionViewDelegate 和 UICollectionViewDataSource 扩展中添加代码:
override func collectionView(_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath) {
// 1
switch Section(at: indexPath) {
// 2
case .hairColor:
person.face.hairColor = Person.HairColor.allCases[indexPath.row]
case .hairLength:
person.face.hairLength = Person.HairLength.allCases[indexPath.row]
case .eyeColor:
person.face.eyeColor = Person.EyeColor.allCases[indexPath.row]
// 3
case .glasses:
person.face.glasses = true
// 4
case .facialHair:
person.face.facialHair.insert(Person.FacialHair.allCases[indexPath.row])
// 5
case .likes:
person.likes.insert(Person.Topic.allCases[indexPath.row])
person.dislikes.remove(Person.Topic.allCases[indexPath.row])
case .dislikes:
person.dislikes.insert(Person.Topic.allCases[indexPath.row])
person.likes.remove(Person.Topic.allCases[indexPath.row])
default:
break
}
// 6
collectionView.reloadData()
}
当 cell 被选中,需要做如下动作:
接下来,实现反选操作。在 collectionView(_:didSelectItemAt:) 之下,添加:
// 1
override func collectionView(_ collectionView: UICollectionView,
shouldDeselectItemAt indexPath: IndexPath) -> Bool {
switch Section(at: indexPath) {
case .facialHair, .glasses, .likes, .dislikes:
return true
default:
return false
}
}
override func collectionView(_ collectionView: UICollectionView,
didDeselectItemAt indexPath: IndexPath) {
switch Section(at: indexPath) {
// 2
case .facialHair:
person.face.facialHair.subtract([Person.FacialHair.allCases[indexPath.row]])
case .likes:
person.likes.subtract([Person.Topic.allCases[indexPath.row]])
case .dislikes:
person.dislikes.subtract([Person.Topic.allCases[indexPath.row]])
case .glasses: // 3
person.face.glasses = false
default:
break
}
collectionView.reloadData()
}
在上面的委托方法中:
Build & run app。现在你会看到选中后的效果:
现在你已经是一个 People Keeper 技术的高手了。你可以去炫耀这款新式武器了。当某天有个厉害的开发者看到这个 app 时,发现可以用 foundation 中的一个强大的类来保护你的市场地位,抵抗那些邪恶的竞争者……
UndoManager 是一个通用的 undo 栈,用于简化 app 的状态管理。它可以保存你想保存的任何对象或 UI 状态,通过一个闭包、方法或 invocation ,你可以跟踪和回溯这些状态。如果实现正确,它很容易实现 undo/redo 功能,但经验比较少的开发者很可能在实现 UndoManager 时导致致命的错误。下面两个 undo 栈例子中,一个是有问题的,一个是正确的。
Undo 栈 #1 是一系列小步骤,每一步都会修改模型并让视图保持一致。尽管这种策略理论上是可行的,但是随着操作列表的增长,出错的可能性也会增加,因为精确地匹配模型中的每一个变更到视图中会越来越困难。
要理解这个,请做一个练习:
当你第一次 pop undo 操作栈之后,模型会变成什么样?
答案:Bob,Sam
第二次 undo 之后呢?
答案:Bob, Kathy
第三次之后呢?
答案:Bob, Kathy, Mike
无论你的结果是什么,你都可以想象得到,反复删除和插入操作多次后,后续的插入、删除、更新需要对索引进行的计算有多复杂。undo 栈是依赖于顺序的,顺序错误会导致数据模型和视图不一致。这个错误有点眼熟吧:
要避免上面的错误,就不要将数据模型和 UI 变更分开记录,而是记录整个模型:
要撤销一个操作,你可以用 undo 栈中的模型替换当前模型。Undo 栈 1 和 栈 2 做同样的工作,但栈 2 是不依赖顺序的,同时出错的可能性更小。
在 PersonDetailViewController.swift 底部加入:
// MARK: - Model & State Types
extension PersonDetailViewController {
// 1
private func personDidChange(from fromPerson: Person) {
// 2
collectionView?.reloadData()
// 3
undoManager.registerUndo(withTarget: self) { target in
let currentFromPerson: Person = self.person
self.person = fromPerson
self.personDidChange(from: currentFromPerson)
}
// 4
// Update button UI
DispatchQueue.main.async {
self.undoButton.isEnabled = self.undoManager.canUndo
self.redoButton.isEnabled = self.undoManager.canRedo
}
}
}
上面的代码执行了以下步骤:
然后,在 collectionView(_:didSelectItemAt:) 和 collectionView(_:didDeselectItemAt:) 的头部添加:
let fromPerson: Person = person
保存一份原 person 的实例。
在这两个委托方法的最后,将 collectionView.reloadData() 替换为:
personDidChange(from: fromPerson)
这样就注册了一个能恢复到 fromPerson 的 undo 操作。我们将collectionView?.reloadData() 删除,是因为在personDidChange(from:) 已经调用了它,没有必要调用两次。
在 undoTapped() 方法中加入:
undoManager.undo()
然后在 redoTapped() 中添加:
undoManager.redo()
分别用于进行 undo 和 redo 操作。
接下来,实现通过摇晃设备触发 undo/redo。在 viewDidAppear(_? 底部添加:
becomeFirstResponder()
在 viewWillDisappear(_? 底部添加:
resignFirstResponder()
在 viewWillDisappear(_? 后面添加:
override var canBecomeFirstResponder: Bool {
return true
}
当用户摇晃手机进行 undo/redo 时,NSResponder 会在 responder 链中查找下一个能够返回 NSUndoManager 对象的 reponder。当你将 PersonDetailViewController 设置为 first responder 后,它的 undoManager 会负责响应摇晃手势,并用一个 option 表示 undo/redo 操作。
Build & run。切换到 PersonDetailViewController,改变头发颜色,然后点击 undo/redo 或者摇晃手机。
注意点击 undo/redo 时不会改变预览图。
来 debug 一下,在 registerUndo(withTarget:handler:) 闭包开头加上:
print(fromPerson.face.hairColor)
print(self.person.face.hairColor)
再次 build & run。修改头发颜色多次,undo 然后 redo。现在,注意 debug 控制台,你会看到,在 undo/redo 时,两个打印语句都只输出了最终选择的那个颜色。是 UndoManager 出错了吗?
NO! 这个问题是其它代码导致的。
局部推理性是一个概念,它能够不依赖于上下文理解代码的片段。
例如在本教程中,你使用了闭包,懒加载、协议扩展和精简代码路径来使你的一部分代码易于理解,那就不需要再去阅读它们的范围之外的代码了——只需要阅读“局部的”代码。
那怎么解决这个 bug 呢?你可以用提升局部推理性来修改这个 bug。通过理解引用类型和值类型之间的区别,你会知道怎样让你的代码拥有更好的局部控制能力。
在 Swift 中,引用类型和值类型是两种不同的“类型”。对于引用类型,比如一个类,对同一实例的不同引用将共享同一内存。值类型不同——比如 struct、enum 和 tuple —— 它们每一个实例都拥有独立的数据。
要理解这对你面临的难题有什么用,请尝试用你刚学的引用类型与值类型之间的区别来回答下列问题:
如果 Person 是一个类:
var person = Person()
person.face.hairColor = .blonde
var anotherPerson = person
anotherPerson.face.hairColor = .black
问:person.face.hairColor == ??
答案:.black
如果 Person 是一个 struct:
var person = Person()
person.face.hairColor = .blonde
var anotherPerson = person
anotherPerson.face.hairColor = .black
问:person.face.hairColor == ??
答案:.blonde
有问题的引用会损害局部推理,因为对象的值可能在你的控制下发生变化,在没有上下文的情况下是不能使用的。
因此在 Person.swift 中,将 Person 类修改为:
struct Person {
这样 Person 就变成值类型了,拥有单独的内存。
Build & run。然后,修改人物的特征,undo 然后 redo,看看有什么变化:
undo 和 redo 选项现在工作正常了。
然后,为 name 的修改添加 undo/redo 能力。回到 PersonDetailViewController.swift ,在 UITextFieldDelegate 扩展中添加:
func textFieldDidEndEditing(_ textField: UITextField) {
if let text = textField.text {
let fromPerson: Person = person
person.name = text
personDidChange(from: fromPerson)
}
}
当编辑完 text field 后,将新 name 设置给 Person,然后注册 undo 操作。
Build & run。现在,进行名字、特征的修改,undo、redo 等等。大部分功能都正常,但有一个小问题。如果你选择 name 字段,然后按返回键,不进行任何编辑,undo 按钮会激活,说明有一个 undo 操作被注册到 undoManager 中了,虽然你根本未进行任何修改:
为了解决这个问题,你需要对原 name 和新 name 进行比对,只有二者值不同时才注册 undo。但这样做的局部推理就很差了——尤其是当人物的属性列表变大的时候,比较简单的做法是比较整个 person 对象而非比对单一属性。
在 personDidChange(from:) 一开始添加:
if fromPerson == self.person { return }
理论上,这是对老对象和新对象进行了比较,但实际上却会报错:
Binary operator '==' cannot be applied to operands of type 'Person' and 'Person!'
正如它所说,Person 对象并没有内置的 compare 方法,因为其中有几个属性是自定义类型。你必须自己定义比较方法。幸好,struct 有一个简单的解决方法。
回到 Person.swift ,添加一个扩展,让它遵守 Equatable:
// MARK: - Equatable
extension Person: Equatable {
static func ==(_ firstPerson: Person, _ secondPerson: Person) -> Bool {
return firstPerson.name == secondPerson.name &&
firstPerson.face == secondPerson.face &&
firstPerson.likes == secondPerson.likes &&
firstPerson.dislikes == secondPerson.dislikes
}
}
现在,如果两个 Person 的名字、面孔、喜好、忌讳相等,那么他们相等,否则不等。
注:你可以对 Face 和 Topic 对象使用 ==(_:_?,而无需让他们实现 Equatable,因为他们仅仅是由 String 构成的对象,而 String 在 Swift 中本来就是 Equatable 对象。
回到 PersonDetailViewController.swift。Build & run。if fromPerson == self.person 上的错误将消失。现在你的这句代码 ok 了,待会还要完全删除它。用一个 diff 取代它,将有利于提升你的局部推理。
在编程语言中,diff 用于比较两个对象是否不同或有多不同。通过创建一个 diff 值类型,可以将(1) 原对象、(2) 修改过的对象、(3) 以及它们的比较方法都放在一个单一的、“局部”的地方。
在 Person 结构体最后添加:
// 1
struct Diff {
let from: Person
let to: Person
fileprivate init(from: Person, to: Person) {
self.from = from
self.to = to
}
// 2
var hasChanges: Bool {
return from != to
}
}
// 3
func diffed(with other: Person) -> Diff {
return Diff(from: self, to: other)
}
上述代码定义了:
在 PersonDetailViewController,将 private func personDidChange(from fromPerson: Person) { 替换为:
private func personDidChange(diff: Person.Diff) {
现在参数变成了 Diff 而非仅仅 from 对象。
然后,将 if fromPerson == self.person { return } 换成:
guard diff.hasChanges else { return }
利用了 diff 的 hasChanges 属性。
同时删除先前添加的两句 print 语句。
在将 personDidChange(from:) 替换为 personDidChange(diff:) 之前,先来看一眼 collectionView(_:didSelectItemAt:) 和 collectionView(_:didDeselectItemAt:) 方法。
在每个方法中,注意 person 对象在类一开始就保存了原始值,但到最后也没有用到它。你可以通过将这个对象的创建移到更近的地方,来提升代码的局部推理性。
在同一扩展的 personDidChange(diff:) 方法之前添加方法:
// 1
private func modifyPerson(_ mutatePerson: (inout Person) -> Void) {
// 2
var person: Person = self.person
// 3
let oldPerson = person
// 4
mutatePerson(&person)
// 5
let personDiff = oldPerson.diffed(with: person)
personDidChange(diff: personDiff)
}
上述代码解释如下:
在 collectionView(_:didSelectItemAt:)、collectionView(_:didDeselectItemAt:) 和 textFieldDidEndEditing(_? 方法中调用 modifyPerson(_?,将 let fromPerson: Person = person 替换为:
modifyPerson { person in
将 personDidChange(from: fromPerson) 替换为:
}
这样就将代码放到了 modifyPerson(_? 闭包中。
同样,在 undoManager 的 registerUndo 闭包中,将 let currentFromPerson: Person = self.person 替换成:
target.modifyPerson { person in
将 self.personDidChange(from: fromPerson) 替换成:
}
这样代码就被一个闭包简化了。这种设计方式将修改代码集中到一处,保证我们 UI 的局部推理性。
选中类中所有代码,点击菜单 Editor > Structure > Re-Indent 重排闭包的缩进。
在 personDidChange(diff:) 的 guard diff.hasChanges else { return } 之后、collectionView?.reloadData() 之前添加:
person = diff.to
将类的 person 属性设置为更新后的 person。
同样,在 target.modifyPerson { person in … } 闭包中,将 self.person = fromPerson 替换为:
person = diff.from
当 undo 时恢复之前的 person。
Build & run。查看人物详情视图,每一样功能都正常了。你的 PersonDetailViewController 代码已经写完了!
现在,点击 < PeopleKeeper 返回按钮。呃……我们的修改去哪里了呢?你必须将修改传给 PeopleListViewController。
在 PersonDetailViewController 头部添加:
var personDidChange: ((Person) -> Void)?
和 personDidChange(diff:) 方法不同,这个 personDidChange 变量会保存一个闭包,这个闭包用修改后的 person 作为参数。
在 viewWillDisappear(_? 方法开头,添加:
personDidChange?(person)
当 view 消失,返回到主界面时,修改后的 person 会传给这个闭包。
现在需要给这个闭包赋值。
回到 PeopleListViewController, 找到 prepare(for:sender:)。当转换到人员的详情视图时,prepare(for:sender:) 会发送一个 person 对象给目标控制器。同样,你可以在这个方法中添加一个闭包,以接收从目标控制器返回的 person 对象。
在 prepare(for:sender:) 最后添加:
detailViewController?.personDidChange = { updatedPerson in
// 暂时空缺: 更新数据模型和 UI
}
这句代码对 detailViewController 的 personDidChange 闭包进行初始化。最终你会在占位注释的地方编写更新数据和 UI 的代码,在这之前,还有一些准备工作要做。
打开 PeopleModel.swift。在 PeopleModel 的最后、类的内部添加:
struct Diff {
// 1
enum PeopleChange {
case inserted(Person)
case removed(Person)
case updated(Person)
case none
}
// 2
let peopleChange: PeopleChange
let from: PeopleModel
let to: PeopleModel
fileprivate init(peopleChange: PeopleChange, from: PeopleModel, to: PeopleModel) {
self.peopleChange = peopleChange
self.from = from
self.to = to
}
}
这段代码主要做了以下事情:
要计算出被插入、删除或修改的 person 到底是哪个,要在 Diff 结构体之后添加这个函数:
// 1
func changedPerson(in other: PeopleModel) -> Person? {
// 2
if people.count != other.people.count {
let largerArray = other.people.count > people.count ? other.people : people
let smallerArray = other.people == largerArray ? people : other.people
return largerArray.first(where: { firstPerson -> Bool in
!smallerArray.contains(where: { secondPerson -> Bool in
firstPerson.tag == secondPerson.tag
})
})
// 3
} else {
return other.people.enumerated().compactMap({ index, person in
if person != people[index] {
return person
}
return nil
}).first
}
}
上述代码分解为以下几个步骤:
在 changedPerson(in:) 下面添加方法:
// 1
func diffed(with other: PeopleModel) -> Diff {
var peopleChange: Diff.PeopleChange = .none
// 2
if let changedPerson = changedPerson(in: other) {
if other.people.count > people.count {
peopleChange = .inserted(changedPerson)
} else if other.people.count < people.count {
peopleChange = .removed(changedPerson)
} else {
peopleChange = .updated(changedPerson)
}
}
//3
return Diff(peopleChange: peopleChange, from: self, to: other)
}
来看一下上面的代码:
然后在 PeopleListViewController.swift 最后添加:
// MARK: - Model & State Types
extension PeopleListViewController {
// 1
private func peopleModelDidChange(diff: PeopleModel.Diff) {
// 2
switch diff.peopleChange {
case .inserted(let person):
if let index = diff.to.people.index(of: person) {
tableView.insertRows(at: [IndexPath(item: index, section: 0)], with: .automatic)
}
case .removed(let person):
if let index = diff.from.people.index(of: person) {
tableView.deleteRows(at: [IndexPath(item: index, section: 0)], with: .automatic)
}
case .updated(let person):
if let index = diff.to.people.index(of: person) {
tableView.reloadRows(at: [IndexPath(item: index, section: 0)], with: .automatic)
}
default:
return
}
// 3
peopleModel = diff.to
}
}
和 PersonDetailViewController 的 personDidChange(diff:) 一样,peopleModelDidChange(diff:) 方法主要做了以下工作:
刚刚在 PersonDetailViewController 添加了一个 modifyPerson(_? 方法,现在再在 peopleModelDidChange(diff:) 方法前面添加一个 modifyModel(_? 方法:
// 1
private func modifyModel(_ mutations: (inout PeopleModel) -> Void) {
// 2
var peopleModel = self.peopleModel
// 3
let oldModel = peopleModel
// 4
mutations(&peopleModel)
// 5
tableView.beginUpdates()
// 6
let modelDiff = oldModel.diffed(with: peopleModel)
peopleModelDidChange(diff: modelDiff)
// 7
tableView.endUpdates()
}
这段代码解释如下:
回到 prepare(for:sender:) 方法,将占位注释替换为:
self.modifyModel { model in
model.people[selectedIndex] = updatedPerson
}
这会将用户所点击的索引所对应的 person 更新为修改后的版本。
最后一步。将 class PeopleModel { 替换为:
struct PeopleModel {
Build & run。选择某人的详情视图,进行某些修改,然后返回人员列表,改变会传递过来:
接着,你需要为人员列表添加删除、添加功能。
要实现删除,将 tableView(_:editActionsForRowAt:) 的占位注释替换为:
self.modifyModel { model in
model.people.remove(at: indexPath.row)
}
这会将指定行索引的 person 删除,无论从数据模型还是 UI 上。
要实现插入,需要添加一个 addPersonTapped() 方法:
// 1
tagNumber += 1
// 2
let person = Person(name: "", face: (hairColor: .black, hairLength: .bald, eyeColor: .black, facialHair: [], glasses: false), likes: [], dislikes: [], tag: tagNumber)
// 3
modifyModel { model in
model.people += [person]
}
// 4
tableView.selectRow(at: IndexPath(item: peopleModel.people.count - 1, section: 0),
animated: true, scrollPosition: .bottom)
showPersonDetails(at: IndexPath(item: peopleModel.people.count - 1, section: 0))
代码解释如下:
Build & run。进行添加,修改等操作。你已经可以从人员名单中添加、删除 person 了,同时改动应该能够在两个控制器之间同步:
但是还没完 —— PeopleListViewController 的 undo/redo 还不能用。是时候做一点反破坏代码来保护你的联系人列表了!
在 peopleModelDidChange(diff:) 末尾添加:
// 1
undoManager.registerUndo(withTarget: self) { target in
// 2
target.modifyModel { model in
model = diff.from
}
}
// 3
DispatchQueue.main.async {
self.undoButton.isEnabled = self.undoManager.canUndo
self.redoButton.isEnabled = self.undoManager.canRedo
}
在这里,你:
在 undoTapped() 中加入:
undoManager.undo()
在 redoTapped() 中加入:
undoManager.redo()
分别调用了 undo 和 redo 方法。
最后,为控制器添加摇晃手势。在 viewDidAppear(_? 中添加:
becomeFirstResponder()
在 viewWillDisappear(_? 中添加:
resignFirstResponder()
在 viewWillDisappear(_? 下面添加:
override var canBecomeFirstResponder: Bool {
return true
}
这样控制器就能响应摇晃手势进行 undo/redo 了。
OK!Build & run。你可以编辑、添加、撤销、重做、摇晃手机了。
大功告成!
下面的 Download Materials 按钮可以下载示例代码供你参考。
要进一步了解 UndoManager API,你可以尝试一下分组撤销、对撤销动作进行命名、使撤销重做无效以及使用内置通知。
要进一步了解值类型,请尝试为 Person 和 PeopleModel 添加属性,让你的 app 更加健壮。
如果你想让 PeopleKeeper 真正能为你所用,请对数据进行持久化。更多信息,请看我们的 “Updated Course: Saving Data in iOS”。
有任何问题、建议或意见,请到论坛发帖。
Download Materials