改进数据模型
上面的代码已经可以工作了,但是你仍旧可以做的更好一些。你为Checklist和ChecklistItem做了数据模型对象,但是读取和存储Checklists.plist文件的代码目前位于AllListsViewController中。根据良好的代码编写原则,我们应该把它也放入数据模型中。
我喜欢为我的许多app都创建一个顶级的数据模型。对于这个app而言,数据模型会包含Checklist对象的数组。你可以将用于读取和存储的代码移到新的数据模型对象中。
新建一个Swift文件。将它保存为DataModel.swift(你不需要将它设置为任何东西的子类)
在DataModel.swift中添加以下代码:
import Foundation
class DataModel {
var lists = [Checklist]()
}
这样就定义了一个新的数据模型对象并且给了它一个lists属性。
不像Checklist和ChecklistItem,数据模型不需要建立在NSObject之上。它也不需要符合NSCoding协议。
数据模型会接管读取及保存AllListsViewController中的待办事项。
把下面的几个方法从AllListsViewController.swift中剪切出来,再粘贴到DataModel.swift中:
func documentsDirectory()
func dataFilePath()
func saveChecklists()
func loadChecklists()
并且在DataModel.swift添加一个init()方法:
init() {
loadChecklists()
}
这样就保证了,一旦DataModel对象被创建,它就会去读取Checklists.plist。
lists实例变量在声明时就已经有了一个初始值,所以在init()方法中,我们不用管它。
同时,你也不需要调用super.init(),因为DataModel没有父类。
回到AllListsViewController.swift,并且做出如下改动:
移除lists实例变量
移除init?(coder)方法
添加一个新的实例变量:
var dataModel: DataModel!
这里的感叹号是必须的,因为当app启动时的一个短暂时间内dataModel会为nil。但是并不需要把dataModel声明为可选型,带上一个问号,因为一旦dataModel有值后,就再也不会为nil了。
此时Xcode应该为你指出了AllListsViewController.swift中有几处报错。你不能再直接引用lists变量了,因为它已经被你删掉了。取而代之的是,你要向DataModel请求它的lists属性。
任何AllListsViewController中调用了lists的地方,都要修改为dataModel.lists,一共需要修改这么几处地方。
tableView(numberOfRowsInSection)
tableView(cellForRowAt)
tableView(didSelectRowAt)
tableView(commit, forRowAt)
tableView(accessoryButtonTappedForRowWith) listDetailViewController(didFinishAdding) listDetailViewController(didFinishEditing)
要改的地方真多,还好改动都不大。
你创建了一个新的数据模型,它包含Checklist数组,并且可以保存和读取checklists和其中的数据。
而AllListsViewController已经不在使用自己的数组,转而使用DataModel对象,通过读取dataModel的属性实现。
但是DataModel是在哪里创建的?在整个代码中还没有说明dataModel = DataModel()。
做这件事情最佳的地方就是在app delegate中。你可以认为app delegate是整个app中最高层级的地方。因此,让它拥有这个数据模型是最合理的。
然后app delegate向任何需要DataModel的视图控制器传递这一对象。
打开AppDelegate.swift,添加一个新的属性:
let dataModel = DataModel()
这样就创建了一个DataModel对象,并且将它放入了一个名为dataModel的常量里。
虽然AllListsViewController中已经有了一个叫做dataModel的实例变量,但是它们俩是独立存在的。这里你仅仅是将DataModel对象放入AppDelegate的dataModel属性中。
轻微修改一下saveData方法:
func saveData() {
dataModel.saveChecklists()
}
如果你现在运行app的话,app会挂掉,因为AllListsViewController引用的DataModel此时还是nil。我告诉过你,nil不是啥好事。
向AllListsViewController分享DataModel实例最佳的地方就是在application(didFinishLaunchingWithOptions) 方法中,这个方法在app一启动时就会被调用。
将这个方法修改为:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
let navigationController = window!.rootViewController as! UINavigationController
let controller = navigationController.viewControllers[0] as! AllListsViewController
controller.dataModel = dataModel
return true
}
这里通过故事模版找到AllListsViewController(和之前一样),然后设置它的dataModel属性。现在All Lists界面又可以读取Checklist对象了。
由于我们改了太多东西,需要清理一下缓存,选择菜单Product->Clean清理一下,然后运行app。看看一切是否工作正常。
我仍然对var和let感到困惑不解
如果var用于定义变量,而let用于定义常量,那么为什么你可以在AppDelegate中这样使用呢?
let dataModel = DataModel()
你会想,如果某样东西是常量,那么它就是无法被改变的,对吗?
那么为什么app会允许你向DataModel中添加新的Checklist对象呢?很明显,DataModel是随时在发生变化的。
这里是一个小把戏:Swift对值类型和引用类型做了区分,并且let在这两者上的工作方式有些不同。
比如Int(整数型)就是一个值类型,一旦你创建了一个Int型的常量,那么之后你就不能再改变它的值:
let i = 100
i = 200 //错误
i += 1 //错误
var j = 100
j = 200 //正确
j += 1 //正确
这个原理对其他类型Float、String、甚至Array(数组)都是成立的。它们都是所谓的值类型,因为变量和常量直接存储它们的值。
当你分配一个变量的内容到另一个时,值会被拷贝到新的变量中:
var s = "hello"
var u = s // u 拷贝了 "hello"
s += " there" // s 和 u 已经不同了
但是你用class关键字定义的对象是引用类型(比如DataModel),常量和变量并不会实际的包含这个对象,而是包含一个到这个对象的引用。
var d = DataModel()
var e = d // e和d引用同一个对象
d.lists.remove(at: 0) // 改变d的时候e同时会被改变
把上面的变量改为常量,作用是一样的:
let d = DataModel()
let e = d //e和d引用同一个对象
d.lists.remove(at: 0) // 改变d的时候e同时会被改变
所以,对引用类型而言,变量和常量有什么区别呢?
当你使用let时,并不能把对象变成常量,但是可以把到这个对象的引用变成常量,这意思就是说,你不能像下面这样做:
let d = DataModel()
d = someOtherDataModel // 错误: 你不能改变这个引用
这个常量d永远不能指向其他对象,但是对象本身可以发生改变。
理解起来可能会有些困难,但是值类型和引用类型的区别在软件开发中非常重要,花再大的精力你也必须掌握它。
我的建议是,你在写代码的时候全部使用let,直到编译器提示你要修改为var的时候再改成var。
使用用户缺省来记住界面
你现在拥有了一个app,可以使你创建待办事项分类,并且在每个分类中添加具体的待办事项。所有这些数据都被长期存储,甚至app中断后都不会丢失数据。
但是你仍然可以做一些优化工作。
想象一下,用户正在生日分类操作时,因为有其他事切换到了另外的app上,我们的app就被切换到后台了。这就有可能在某一时刻内存会将app移出去,app就中断了。
当用户过会在切换回来时,app并不是停留在生日分类上,而是在主界面。因为app中断后并不是从离开的地方重新开始,而是直接重新开始。
也许你会忽视这一点,毕竟这种情况并不经常发生(除非你打开了好几个游戏app),但是伟大的iOS产品就在于细节。
幸运的是,实现这个功能非常容易。
你可以把这些信息保存在Checklists.plist文件中,但是更加简单的一个方式就是使用UserDefault对象。
UserDefault的工作原理和字典类似,字典是一种存储配对键值的集合类型的对象。你已经见过了数组这种集合类型的对象,可以像列表一样存储对象。字典也是一种非常常见的集合类型,如下图所示:
Swift中字典是有Dictionary对象实现的。
你可以把对象放入字典并且以键为索引引用它的值。这也正是Info.plist的工作方式。
Info.plist文件被iOS系统读取进一个字典,使用多种键(左边的那一排)来获取值(右边的那一排)。键通常都是字符,但是值可以是任何类型的对象。
公平的讲,UserDefault不是一个真正的字典,但是与字典非常相似。
当你向UserDefault中插入新的值时,它们被保存在你app沙盒的某个地方,所以这些值甚至可以在app中断后还存在。
你不会在UserDefault中存储大量的数据,但是对于一些小东西,比如设置类的,或者对屏幕浏览记录做个保存,UserDefault是个不错的选择。
你要做的事情是:
1、在主界面(AllListsViewController)到checklist(ChecklistViewController)的转场上,你要为被选择的行写一个索引放入UserDefault。通过这种方式,你就可以记住被激活的checklist是哪一个。
你可以保存checklist的名称来代替保存这一行的索引值,但是假如有两行的checklist名称一样会发生什么呢?虽然不太可能,但是也不能保证没有。使用行的索引可以确保你总是能得到唯一的一行。
2、当用户点击back按钮回到主界面后,你要移除掉UserDefault中的值。你可以通过设置值为-1来表示没有值。
为什么是-1呢?你是从0开始计数,所以你不能使用0。正数也不在考虑范围内,除非你使用1000000这样的值,因为基本上用户不会增加这么多行,但是理论上这样也不妥当。而-1不是一个有效的索引,并且负数看起来会非常醒目,用来做特殊标示非常合适。(如果你在想,为什么不能用一个可选型呢?nil就代表没有,这是一个好问题,答案是很令人伤心的,因为UserDefault不能处理可选型)
3、如果app启动并且UserDefault的值不是-1的话,那就是说用户之前停留在查看某个checklist内容的行为上,这时你要用代码转场到ChecklistViewController中相应的行上。
说了这么多,我们还不如立刻开始上手做一做。
让我们从主界面的转场开始。回忆一下这个转场是由代码触发,而不是由storyboard。
打开AllListsViewController.swift,将tableView(didSelectRowAt)方法修改为:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
//添加这一行
UserDefaults.standard.set(indexPath.row, forKey: "ChecklistIndex")
let checklist = dataModel.lists[indexPath.row]
performSegue(withIdentifier: "ShowChecklist", sender: checklist)
}
除了添加一行以外,其他部分不要动,这样你就将被选择的这一行的index存储到UserDefaults下的“ChecklistIndex”中了。
为了识别用户是否点击了导航栏上的back按钮,你需要成为导航控制器的委托。成为委托意味着导航栈堆中推入或者弹出视图控制器时,你都会得到一个通知。
理想的添加委托的地方是在AllListsViewController中。
打开AllListsViewController.swift,添加委托协议:
class AllListsViewController: UITableViewController,ListDetailViewControllerDelegate,UINavigationControllerDelegate {
和你看到的一样,一个视图控制器可以同时作为许多对象的委托。
AllListsViewController现在同时是ListDetailViewController和UINavigationController的委托了。
在AllListsViewController.swift的底部添加委托方法:
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
//back按钮被点击了吗?
if viewController === self {
UserDefaults.standard.set(-1, forKey: "ChecklistIndex")
}
无论何时,导航控制器中出现一个新的视图的时候,这个方法都会被调用。
如果back按钮被点击了,新的视图控制器是AllListsViewController自己,这时你设置“ChecklistIndex”的值为-1,意味着此时没有任何一个具体的待办条目被选中。
相等和相同
为了确定AllListsViewController是否是最新的一个视图,你写的代码为:
if viewController === self
这并不是印刷错误,这里就是三个“=”号。
之前你比较两个对象的时候用的都是两个等于符号,比如:
if segue.indentifier == "AddItem" {
你肯定很像知道三个等于号和两个等于号到底有什么不同。它们之间的差别非常微妙,但是却是关于身份同一性的重要问题。(严格来说,这是个哲学问题,T T)
如果你使用==,你是在检查两个变量是否有同一个值。
而当你使用===,你是在检查两个变量是否引用了同一个对象。
想象一下,都两个人,名字都叫张三。他们是两个不同的人,但是名字一样。
如果你用===来比较的话,if 张三1 === 张三2,那么返回结果是不同,因为他们是两个人。但是如果你用==,if 张三1 == 张三2,那么返回结果就是相同,因为他们的名字(值)一样。
另一方面讲,假如两个张三是来自不同时空的同一个张三,那么if 张三1 === 张三2也会返回成功。
顺便说一下,上面的代码如果你写成了两个等号,app也一样会运行正常:
if viewController == self
对于视图控制器而言,你使用两个等号它也会去比较引用而不是值,就像三个等号一样,但是技术上讲使用三个等号显得更加专业。
在app启动时检查那条待办事项被选中了,并且通过代码转场过去,还剩一点工作要做。我们会在viewDidAppear()方法中实现这件事。
打开AllListsViewController.swift,添加这个方法:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationController?.delegate = self
let index = UserDefaults.standard.integer(forKey: "ChecklistIndex")
if index != -1 {
let checklist = dataModel.lists[index]
performSegue(withIdentifier: "ShowChecklist", sender: checklist)
}
}
当视图控制器可视化的时候,UIKit会自动调用这个方法。
首先,视图控制器使自己成为导航控制器的委托。
每个视图控制器都有一个内建的导航控制器属性,你可以使用navigationController?.delegate读取它,因为它是个可选型,所以你要使用一个问号。
你也可以使用感叹号来代替问号。区别在于如果这个视图控制器从不在外部展示一个导航控制器的话,那么使用感叹号会使app挂掉。而使用问号则不会,使用问号时仅仅会使得这一行被忽略。
然后它检查UserDefaults,看看是否需要执行转场。
如果“ChecklistIndex”的值为-1,那就是说在app中断前,用户是停留在主界面上的,这时你不需要做任何事情。
然而如果“ChecklistIndex”的值不是-1的话,就是说用户在app中断前是停留在某条待办事项上的,你需要转场到相应的地方。和以前一样,你把Checklist相关的对象放到sender中:performSegue(withIdentifier,sender)。
!=,这个符号的意思是“不等于”。和==操作符正好相反。就是算术中的≠。有些语言使用的是<>,但是Swift中不是。
⚠️:这里发生的事情并不是太一目了然。
viewDidAppear()并不是当app启动时才被调用,每次导航控制器将主界面滑动回视图中时也会被调用。
检查checklist界面是否被恢复,应该仅在app启动时发生,所以为什么你将这段逻辑放入viewDidAppear()中呢?如果它被调用的那么频繁的话。
理由如下:
AllListsViewController的界面可视化前你并不想调用navigationController(willShow...)委托方法,因为这样做会使得你在保存旧的界面前,“ChecklistIndex”就会被重写为-1。
通过等待AllListsViewController可视化之后再将其注册为导航控制器的委托,可以避免这个问题的发生。viewDidAppear()正适合这个时机。
然而,前面提到了,viewDidAppear()当用户点击back按钮回到All Lists界面时它也会被调用。它不会造成负面影响,比如重复触发转场。
当用户点击back按钮时导航控制器调用navigationController(willShow...)方法,这件事发生在viewDidAppear()被调用之前。这样委托方法总是能及时的将ChecklistIndex重置为-1,而viewDidAppear()绝不会重复触发转场。
结果就是,每次app启动时viewDidAppear()内的逻辑才会被执行,还有一些方法可以达到同样目的,但是这个最简单。
你能理解这整个过程了吗?不要急躁,保持平常心。如果你想确实的观察一下发生了什么,可以在这些方法中添加一些print()方法来观察一下,所谓百闻不如一见。
核实一下所有UserDefaults语句中使用的键值都是一样的,应该是“ChecklistIndex”。如果其中一个错了的话,UserDefault会产生读写错误。
运行app,先进入到待办事项界面,然后通过Shift+Command+H,回到主界面,然后点击Stop中断app。
小贴士:如果你不先回到iOS主界面,而是通过Xcode直接杀死app,那么你做的改动不会被保存,这一点和真实的设备不一样。
⚠️:如果你的待办事项为空,那么此时app会挂掉,这是我们下一节课要解决的问题。你可以先把viewDidAppear()注释掉,先增加几个待办事项进去,然后再把注释去掉重新运行,或者什么都不要做,等我们下节课解决完问题后再说。