防御型代码
接下来按照我说的去做以下步骤:中断app并且使用菜单Simulator → Reset Contents and Settings重置模拟器。
(仅仅是把app删掉是不行的,你需要整个重置模拟器)
然后重新运行app,观察app崩溃时的信息:
fatal error: Index out of range
编译器指向的报错地点时在viewDidAppear()中的这一行:
let checklist = dataModel.lists[index]
这里发生了什么?显然这时index的值不是-1,否则代码不会执行到if语句内部。
事实上此时index是0,因为这是app刚启动的时候,UserDefault中不应该有任何东西。app还没有将任何东西写进“ChecklistIndex”键中。那么为什么是0呢?因为UserDefault的integer(forKey)方法在找不到键对应的值时,默认返回0,而此时0不是一个可用的行索引,因为此时app中不存在任何具体的待办事项,所以lists数组是空的,所以索引为0的lists的对象也是不存在的。这就是app挂掉的原因。
幸运的是UserDefault可以让你设置这个默认值,让我们在数据模型中完成这件事。
打开DataModel.swift,添加以下方法:
func registerDefaults() {
let dictionary: [String: Any] = ["ChecklistIndex": -1]
UserDefaults.standard.register(defaults: dictionary)
}
这里创建了一个新的字典实例,并且将-1添加到键值“ ChecklistIndex”中。
这里的方括号不是数组的意思,而是字典。数组和字典的区别是这样的,字典看起来是这个样子:
[ key1: value1, key2: value2, . . . ]
而数组看起来是这个样子:
[ value1, value2, value3, . . . ]
这样当键中不存在值的时候,就会从你刚才建的字典当中请求值了。
改变一下DataModel.swift中的init方法:
init() {
loadChecklists()
registerDefaults()
}
再运行一下app,现在它不会挂掉了。
为什么我们是在数据模型中做这个事呢?是这样的,我并不想要在任何时刻都调用UserDefault。
事实上,我们要把全部UserDefaule相关的东西都移到DataModel中,还是打开DataModel.swift,添加以下代码:
var indexOfSelectedChecklist: Int {
get {
return UserDefaults.standard.integer(forKey: "ChecklistIndex")
}
set {
UserDefaults.standard.set(newValue, forKey: "ChecklistIndex")
}
}
这是你以前没见过的一种东西。它看起来似乎是定义了一个新的整数型实例变量indexOfSelectedChecklist,但是这里的get{}和set{}是什么呢?
这叫做计算属性。
这里没有任何存储空间分配给这些属性(所以它们不算是真正的变量)
取而代之的是,当app试图从indexOfSelectedChecklist中读取值时,get花括号中的代码会被执行。而当app试图写入一个新的值到indexOfSelectedChecklist中时,set花括号中的代码会被执行。
从现在开始你可以通过简单的使用indexOfSelectedChecklist这个变量来自动更新UserDefault,这非常酷,不是吗?
你做了这个工作之后,其他的代码就再也不用操心UserDefaults了。其他的对象仅仅是调用数据模型中的indexOfSelectedChecklist属性就可以了。
隐藏执行细节是面向对象编程的一个重要原则,这就是我们为什么要这样做的原因。
如果你后期决定将这些设置存储到其他地方,比如iCloud或者数据库里,你仅仅需要修改这一个地方就可以了。其他的代码根本不用操心这些事,这是非常方便的。
使用新的计算属性来更新一下AllListsViewController.swift:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationController?.delegate = self
let index = dataModel.indexOfSelectedChecklist //修改这里
if index != -1 {
let checklist = dataModel.lists[index]
performSegue(withIdentifier: "ShowChecklist", sender: checklist)
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
UserDefaults.standard.set(indexPath.row, forKey: "ChecklistIndex")
//修改这里
dataModel.indexOfSelectedChecklist = indexPath.row
let checklist = dataModel.lists[indexPath.row]
performSegue(withIdentifier: "ShowChecklist", sender: checklist)
}
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
if viewController === self {
//修改这里
dataModel.indexOfSelectedChecklist = -1
}
}
现在代码的可读性也增加了不少。AllListsViewController不用再操心如何把值放入UserDefault了,而仅仅是使用它。
再次运行app,确保一切工作正常。
现在app可以记住你离开时的界面了,非常棒。但是新的功能带来了一些小小的bug,过程是这样的:
启动app,新增一个新的待办分类,再添加一条新的待办事项。然后在Xcode把app中断掉。
因为你没有实现退回到主界面,所以你新增的分类和待办事项事项并没有被保存到Checklists.plist中。
然而UserDefaults却把离开是选择的待办事项保存下来了,这就是问题所在,UserDefault保存下来的索引现在不存在了。
再次运行app,猜猜会怎么样?app挂了,报错为:
fatal error: Index out of range
如果你不能重现这个错误,那么就在indexOfSelectedChecklist中的set语句中加上一句,这样就可以强迫UserDefaults在indexOfSelectedChecklist每次发生变化时,进行一次保存了:
set {
UserDefaults.standard.set(newValue, forKey: "ChecklistIndex")
UserDefaults.standard.synchronize()
}
app挂掉的原因是UserDefaults和Checklists.plist中的内容不同步。UserDefaults认为app中应该有一行被选中了,但是实际上这一行并不存在。所以你每次运行app,都会挂掉。
正常情况下是不会发生这种事的,但是由于你是通过Xcode中的Stop按钮直接中断了app,导致plist文件没有被保存。
用户在实际在手机上使用app的过程中一般都会先按Home键将app转到后台。这样Checklists.plist文件就有机会被保存下来,和UserDefaults保持同步。
然而,操作系统有时也会直接中断掉app。
虽然在实际使用过程中这种可能性很小,你还是需要防范于未然。向这种类型的bug,你是无法收到反馈的,因为你不知道用户会怎样使用你的app。
向这种地方做些防御型代码就显得非常重要。你的代码必须总是检查这种情况,并且游刃有余的处理它们。
在我们这个情况中,你可以通过修改AllListsViewController中的viewDidAppear方法来修复这个问题。
将viewDidAppear方法中的if语句修改为下面这个样子:
if index >= 0 && index < dataModel.lists.count {
你现在增加了一些判断条件来取代仅仅是判断index不等于-1。现在是判断index位于0和数据模型中的checklists数量之间的一个值。
这样就避免了向dataModel.lists[index]请求某个index的对象时,这个对象不存在的情况。
你之前可能没见过&&操作符。这个操作符的意思是逻辑与。它经常像下面这样使用:
if something && somethingElse {
// do stuff
}
这个代码读作:如果something为真并且something else也为真,那么执行if花括号内的语句。
在viewDidAppear()中,你仅仅是当index在0和checklists数量之间时才执行转场。
有个这个防御措施,你就可以保证app不会在checklists不存在时进行转场了,即使数据是不同步的。
⚠️:即使app可以记得用户离开时选择了哪条待办事项,它还是有不足的地方,它不会记得用户是否新增或者编辑了这行或者新增和编辑界面是否打开。
这种类型的界面在设计时就被认为是临时的。你打开这些界面做一些操作然后就关闭它们。如果app被转到了后台,这些界面没有被保存下来问题也不大。
至少对我们这个app是如此。假如你有一个app,在某个界面上的功能是一些非常复杂的操作,那么你也许需要当app中断时,将这些操作保存下来,以免用户重复操作。
在本节课程中你使用UserDefaults来记住那个界面被打开过,但是实际上iOS系统有一个专门的API用于这种功能的开发,它们是State Preservation和Restoration。
首次运行时的用户体验
让我们用UserDefaults做些其他事情。当你第一次运行app时,如果app自动为你创建好一条默认的待办事项分类是非常好的一个体验,名字仅命名为“List”就可以了,并且切换到这条之后它可以立刻打开新增待办事项界面,让你添加待办事项。
这也是标准的笔记本类型的app应该具备的功能:打开app后自动新建一个文件,可以让你立刻开始输入,并且你也可以回到导航层级的上一级查看所有笔记的列表。
为了达到这个目的,你需要使用UserDefaults来跟踪用户是否是首次运行app。如果是,则新增一个Checklist对象。
你可以把这些逻辑全部放入DataModel中。
把新的设置放入registerDefaults()方法,是一个非常好的选择。你可以新建一个名为“FirstTime”的键。
打开DataModel.swift,然后将registerDefaults()方法修改为下面这个样子:
func registerDefaults() {
let dictionary: [String: Any] = ["ChecklistIndex": -1,"FirstTime": true]
UserDefaults.standard.register(defaults: dictionary)
}
FirstTime被设置为布尔型,因为它只有两种可能,要么是要么否:是第一次运行或者不是第一次运行。
如果是app安装后第一次运行,那么“FirstTime”的值应该是true。
还是在DataModel.swift中,添加一个新的方法handleFirstTime():
func handleFirstTime() {
let userDefaults = UserDefaults.standard
let firstTime = userDefaults.bool(forKey: "FirstTime")
lists.append(checklist)
indexOfSelectedChecklist = 0
userDefaults.set(false, forKey: "FirstTime")
userDefaults.synchronize()
}
这里你用UserDefaults来检查“FirstTime”键的值。如果“FirstTime”为true,那么这就是app第一次运行。在这种情况下,你创建一个新的Checklist对象到数组中去。
你同时设置indexOfSelectedChecklist为0,用作第一个Checklist对象的索引,来保证app会自动通过AllListsViewController的viewDidAppear()方法转场到新的列表上。
最后,你将“FirstTime”设置为false,这样再次运行app时,就不会在执行这些逻辑了。
在DataModel的init方法中新增一个调用方法:
init() {
loadChecklists()
registerDefaults()
handleFirstTime()
}
重置模拟器,并且移除掉相关数据,并且再次运行app。看看是否会自动新增一个待办事项分类,并且自动跳转到新增待办事项条目的界面。
改进用户体验
这里还有我们需要添加的一些小特色,让我们在把我们的小app打磨一下。毕竟,你是在做一个真正可以使用的app,如果你想要app有更高的排名,你就必须在这些细节上付出更多。
展示未完待办事项的数量
在主界面,给每个待办事项分类添加上展示还没有完成的具体待办的数量:
首先,你需要一个方法来对这些待办事项计数。
打开Checklist.swift,添加一个新的方法:
func countUnCheckedItems() -> Int {
var count = 0
for item in items where !item.checked {
count += 1
}
return count
}
使用这个方法,你就可以向任何Checklist对象请求其中还没有被打上对勾符号的待办事项的数量。这个方法返回一个整数型的值。
你使用了for循环历遍items数组中的ChecklistItem对象。如果一个item对象的checked属性被设置为false,你就将局部变量count加1。
记住,感叹号放在前面的意思是取反。所以如果item.checked为true,那么取反后就是false,这条数据被我们过滤掉了。
⚠️:如果感叹号放在前面,那么它的意思就是逻辑非,就像这里这样。当一个感叹号放在后面时,它就是和可选型相关的操作。这是swift中一个符号多个意思的一个例子。当有多义词出现时,正确的做法是通过上下文来理解它。
当循环结束,你就已经历遍了所有对象了,然后你返回这些对象的总数给调用者。
练习:如果你用let来代替var定义count的话,会发生什么呢?
答案:当count是一个常量时,Swift不会允许你修改这个值,所以count += 1这一行会报错。
顺便说一下,你也可以把for语句修改为下面这个样子:
for item in items {
if !item.checked {
count += 1
}
}
这样看起来就熟悉多了,个人而言,我喜欢简短的for in where语句。
打开AllListsViewController.swift,对makeCell(for)方法做点小小的改动,将style: .default修改为style: .subtitle。
其他地方不动,除了使用.subtitle代替.default用作cell的风格以外。“subtitle”风格会在主标题下添加一个略小一点的次级标题。你可以使用cell的detailTextLabel属性来读取这个次级标题。
在tableView(cellForRowAt)中,在return cell前添加一行代码:
cell.detailTextLabel!.text = "\(checklist.countUnCheckedItems()) Remaining"
你调用Checklist中的countUncheckedItems()方法,并且将count放入一个新的字符串,这个字符串就位于次级标签的文本属性中。
和以前一样(...)的作用是字符串插值。注意一下,你甚至可以在字符串插值中使用方法,多棒啊。
将文本放入cell的标签中,你使用了以下语句:
cell.textLabel!.text = someString
cell.detailTextLabel!.text = anotherString
这里的感叹号是必须的,因为textLabel和detailTextLabel都是可选型。
textLabel属性仅会出现在table view cell内建的cell风格中;对于自定义的cell设计,它的值是nil。同样的,并不是所有的cell风格都包含detail label和detailTextLabel,在这种情况下,它们都是nil。
这里是使用了Subtitle风格,是可以确保你同时拥有上面两个label。因为对于Subtitle来说它们永远不会为nil,所以你可以使用感叹号来强制解包。解包后的可选型就变成了你实际需要的对象。
使用感叹号的时候一定要小心,因为如果这个可选型为nil的话,你的app会立刻被挂掉。
你也可以把上面的语句写成下面这个样子:
if let label = cell.textLabel {
label.text = someString
}
if let label = cell.detailTextLabel {
label.text = anotherString
}
这样就非常安全了,但是显得繁琐一些,在刚才的情况中使用感叹号会很便利。
运行app,现在每个分类都应该可以展示其中剩余的待办事项数量了。
还剩一个问题:这个剩余待办事项的数量永远不会发生变化。如果你进入一个目录给几条待办事项事项打上对勾符号,或者新增几条待办事项,这个数字永远不会发生变化。这是因为你创建了这些cell以后从来没有去更新它们。(试着自己解决一下这个问题呢)
练习:试着列举一下所有需要更新这个数字的情况。
答案:1、用户在待办事项上打上对勾,每当一个打上一个对勾,这个数字就应该减1,而每次取消掉一个对勾,这个数字就应该加1。
2、用户新增一条待办事项时,这个数字应该加1。
3、用户删除掉某条待办事项时,这个数字应该减1,注意一下,此时对勾符号的状态没有变化,但是实际上讲,待办事项就是少了一条。
所有这些变化都由ChecklistViewController负责控制,但是“剩余待办”的标签却是在AllListsViewController中。
所以你怎么样使All Lists View Controller知道这些事情的发生呢?
如果你觉得,这非常简单,我应该使用一个委托,那么你就逐渐开始入门了。你可以做一个新的ChecklistViewControllerDelegate协议,在以下事情发生时开始发送消息:
1、用户出发对勾符号的开关
2、用户新增待办事项
3、用户删除待办事项
但是这个委托,就是AllListsViewController应该做出什么响应呢?仅仅是在以上情况发生时,更新一下cell的detailTextLabel的文本信息。
委托是非常不错的一个方法,但是在此处你不会这样做,这里有一个更简单的方法,聪明的程序员都会选择这个简单的途径解决这个问题。
打开AllListsViewController.swift,并且添加viewWillAppear()方法做以下事情:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
tableView.reloadData()
}
不要把这个方法和viewDidAppear()弄混了。它们的不同之处在于其中的动词,will和did,viewWillAppear()在viewDidAppear()之前被调用,当视图即将可视化但是动画还没有开始执行的时候viewWillAppear()会被调用,而viewDidAppear()是在视图在屏幕上已经可视化以后的时候被调用。这里相差的时间可能就是半秒钟左右。
iOS的API经常这样做:标注有will的方法会在标注为did的方法之前被调用。有时你需要提前做一些事情,而时候你需要稍候做一些事情,这里提供了两个方法,就可以让你灵活的选择,那个是更合适的了。
⚠️:API(ay-pee-eye)是Application Programming Interface(应用程序接口)的简称。当有人提到iOS API时,他的意思是iOS系统中全部的框架,对象,协议和方法。
iOS的API包罗万象,比如UIKit,Foundation,Core Graphics等等。同样的,如果有人提到Facebook API或者Google API的时候,它们的意思就是这些公司提供的你可以用来开发的对象。
这里,viewWillAppear()告诉table view更新它全部的内容。这会使得tableView(cellForRowAt)在每一行可视化前被重新调用一次。
当你点击ChecklistViewController的导航栏上的back按钮后,AllListsViewController界面会重新回到屏幕上,但是在这之前viewWillAppear()被调用了。因为执行了tableView.reloadData(),app会更新所有的cell,包括detailTextLabel。
重新加载所有cell看起来开销有些大,但是在这种情况下,这是最简单的办法。因为我们的app也不会包含太多的数据,并且同一时间界面上大概也只有14行会被可视化,所以也不用太担心内存消耗。而这又为你节省了做一个委托的大量时间。
有时委托是最好的办法,而有时,你仅仅需要重新加载所有数据就可以了。
运行app,看看是否生效。
练习:当某个分类中没有剩余待办事项时,将标签更新为“All Done”
答案:改变一下tableView(cellForRowAt)中的语句:
let count = checklist.countUnCheckedItems()
if count == 0 {
cell.detailTextLabel!.text = "All Done"
} else {
cell.detailTextLabel!.text = "\(checklist.countUnCheckedItems()) Remaining"
}
你将数量放入了一个局部变量,因为你引用了它两次。计算一次并且将数量存入一个临时变量比计算两次要好的多。
练习:当某个分类中不存在待办事项时,将detailTextLable更新为“No Items”
答案:
let count = checklist.countUnCheckedItems()
if checklist.items.count == 0 {
cell.detailTextLabel!.text = "No Items"
} else if count == 0 {
cell.detailTextLabel!.text = "All Done"
} else {
cell.detailTextLabel!.text = "\(checklist.countUnCheckedItems()) Remaining"
}
仅仅是判断countUncheckedItems()是不够多,因为当它返回0时,你并不清楚是不存在待办事项还是所有待办事项都已经被打了对勾符号了。你需要通过checklist.items.count去判断checklist中的items的总数,才能知道是否是不存在待办事项。
像这样的小细节,会使你的app有非常良好的用户体验。问问你自己,你是觉得“0 Remaining”还是“All Done”更加贴心。