将待办事项放入分类中
之前你完成的工作非常不错,但是待办事项分类中其实不存在任何待办事项条目。
目前为止,待办事项和待办事项分类是相互独立的。
让我们来把数据模型改成下面这个样子:
lists数组还是不变,它包含所有的Checklist对象,但是这些Checklist每一个都会包含自己数组,其中存放着ChecklistItem对象。
打开Checklist.swift,修改一下:
class Checklist: NSObject {
var name = ""
var items = [ChecklistItem]() //添加这一行
...
仅仅是通过添加了一行,我们就几乎万事俱备了。
如果你是处女座,也可以把这一行写成下面这个样子:
var items: [ChecklistItem] = [ChecklistItem]()
我个人不喜欢后者,这种行为被称为“DRY”-Don’t Repeat Yourself(不要干重复的事)。感谢swift的类型推断功能,给我省去了不少事。
有时你也会看到写成这个样子:
var items: [ChecklistItem] = []
[]这对方括号表示,给指定的类型分配一个空的数组。
无论你采用哪种写法,都会使Checklist对象包含一个存储着ChecklistItem对象的数组。最初,这个数组是空的。
之前你修改过AllListsViewController.swift中的prepare(for:sender:),使得用户点击某一行时可以转场到ChecklistViewController,并且将被点击行的Checklist对象传递过去。
目前ChecklistViewController仍然从它自己私有的items数组中得到ChecklistItem对象。你需要修改一下,让它从Checklist对象中的items数组中读取ChecklistItem对象。
打开ChecklistViewController.swift,删掉items实例变量。
然后在修改以下几处地方。把任何存在items的地方修改为checklist.items。
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return checklist.items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ChecklistItem", for: indexPath)
let item = checklist.items[indexPath.row]
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let cell = tableView.cellForRow(at: indexPath) {
let item = checklist.items[indexPath.row]
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
checklist.items.remove(at: indexPath.row)
func itemDetailViewController(_ controller: ItemDetailViewController, didFinishAdding item: ChecklistItem) {
let newRowIndex = checklist.items.count
checklist.items.append(item)
func itemDetailViewController(_ controller: ItemDetailViewController, didFinishEditing item: ChecklistItem) {
if let index = checklist.items.index(of: item) {
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
...
controller.itemToEdit = checklist.items[indexPath.row]
...
然后把下面的几个方法删除掉(小帖士,你可以把它们先保存到别的文件里,之后我们会把它们都粘贴回来,做些小的修改),要删除的是:
• func documentsDirectory()
• func dataFilePath()
• func saveChecklistItems()
• func loadChecklistItems()
你之前添加这些方法的作用是将checklist items保存到一个文件中,并且可以在需要的时候读取出来。这些功能已经不再是这个视图控制器的责任了。对app而言,把它们放在Checklist对象中会更好。
读取和存储数据模型对象放在数据模型中会比放在其他地方要好的多。
在你做这些之前,我们先来测试一下刚才的改动是不是成功了。Xcode的编译器应该还有几处报错,因为你调用了刚才已经被删除掉的方法,你需要把这些代码也删除掉先。
把调用saveChecklistItems()的行删掉。
同时也把init?(coder)删掉。
然后使用command+B重新编译一遍程序。
伪造数据测试
让我们在Checklist对象中造一些数据,来测试一下,这个新的设计是否生效。
在AllListsViewController的init?(coder)方法中你已经造了一些Checklist对象到lists数组中。现在我们来在这个方法中添加一些新的东西。
在AllListsViewController的init?(coder)方法底部添加以下代码:
for list in lists {
let item = ChecklistItem()
item.text = "Item for \(list.name)"
list.items.append(item)
}
这里出现了你以前没有见过的东西,for in语句,和if一样,这是一种特殊的语法结构。
程序语言结构
为了带大家复习一下,我们来贯穿的看看你已经见到过的编程语言的工具。大多数现代编程语言都会提供以下这些最基本的功能:
可以通过将值存储入变量的方式,把这个值保存下来。有些变量非常简单,比如Int和Bool。而有些比较复杂,它们可以存储对象,比如ChecklistItem,UIButton,还有些甚至可以存储对象的集合,比如数组。
可以从变量中读取值,并且进行基本的算术运算,加减乘除等,甚至更复杂的运算,比如比较运算等。
可以做出决策,比如你已经见过的if语句,以及switch语句,switch可以将多个if...else if进行简化。
可以把功能打包,比如函数或者方法。你可以调用这些函数和方法,并且接收到一个返回值,并且用这个值进行后面的运算。
可以将语句多次甚至无数次循环执行。这就是for in语句的作用。还有一些语句也可以完成这个功能,比如while和repeat。重复劳动是电脑的一大特色。
其他的一切都是基于这些基础功能之上的,你已经见识过其中的大部分了,但是循环还是头一次见。
如果你对上面的概念都已经了然于心了,那么你离一个真正的开发者也就不远了。如果没有的话,请复习之前的课程,然后再继续往下。
让我们来详细的分析一下for循环:
for list in lists {
...
}
这个语句的意思是:对每一个在lists数组中的Checklist对象,执行花括号内的操作。
执行循环的第一次时,list临时变量会得到一个Birthdays的checklist对象的引用,因为这是你创建并且添加到数组中的第一个Checklist对象。
在循环的内部,你做了这些事情:
let item = ChecklistItem()
item.text = "Item for \(list.name)"
list.items.append(item)
这应该比较熟悉。首先你创建了一个新的ChecklistItem对象。然后你设置它的文本属性为“Item for Birthdays”,(...)的作用就是插入变量list.name的值到字符串中,而第一个list.name就是Birthdays。
最后,你将这个新的ChecklistItem添加到Birthdays checklist对象的items数组中。
这是第一次循环结束时发生的事情,之后for in语句会发现lists数组中还有三个Checklist对象,然后对这三个对象做一遍同样的事情。
执行完毕后,lists数组中已经没有其他对象了,此时循环结束。
使用循环会使你节省大量时间,如果不使用循环达到刚才的目的的话,你需要这样写代码:
var item = ChecklistItem()
item.text = "Item for Birthdays"
lists[0].items.append(item)
item = ChecklistItem()
item.text = "Item for Groceries"
lists[1].items.append(item)
item = ChecklistItem()
item.text = "Item for Cool Apps"
lists[2].items.append(item)
item = ChecklistItem()
item.text = "Item for To Do"
lists[3].items.append(item)
高下立判,想象一下,如果你有100个对象要处理的话,你真的要把这些小段的语句拷贝100次,然后一个个去改吗?
而且大多数时候,你根本无法预知你要处理几个对象,所以根本无法挨个写出它们。通过使用循环就不存在这个问题了,有几个都无所谓,让循环自己去处理。
也许你已经猜到了,循环和数组经常结伴出现。
运行app。现在你可以看到每个分类下都有属于自己的待办事项条目了。
好好的体验一下各种功能,删除、新增都做几个,看看是不是达到了想要的效果。
打开AllListsViewController.swift,添加以下代码(记得之前我说过要你把删掉的几个方法先保存到别处吗?如果你保存了,现在就可以粘贴回来了。)
func documeentsDirectory() -> URL {
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
return paths[0]
}
func dataFilePath() -> URL {
return documeentsDirectory().appendingPathComponent("Checklists.plist")
}
//用于保存的方法现在叫做saveChecklists()
func saveChecklists() {
let data = NSMutableData()
let archiver = NSKeyedArchiver(forWritingWith: data)
//这里和之前不同
archiver.encode(lists, forKey: "Checklists")
archiver.finishEncoding()
data.write(to: dataFilePath(), atomically: true)
}
//用于读取的方法现在叫做loadChecklists()
func loadChecklists() {
let path = dataFilePath()
if let data = try?Data(contentsOf: path) {
let unarchiver = NSKeyedUnarchiver(forReadingWith: data)
//这里和之前不同
lists = unarchiver.decodeObject(forKey: "Checklists") as! [Checklist]
unarchiver.finishDecoding()
}
这些和你之前在ChecklistViewController中做的几乎完全相同,除了现在保存的是lists数组而不是items数组。注意一下,存储的键值现在是“Checklists”,之前是“ChecklistItem”。同时保存和读取的方法名称也做了相应的改变。
同时改变一下init?(coder)方法:
required init?(coder aDecoder: NSCoder) {
lists = [Checklist]()
super.init(coder: aDecoder)
loadChecklists()
}
我们删除掉了测试用的伪造数据,并且调用了loadChecklists()方法。
你同时还要让Checklist对象符合NSCoding协议。
打开Checklist.swift,添加NSCoding协议
class Checklist: NSObject,NSCoding {
回忆一下,NSCoding协议还需要两个方法,分别是init?(coder)和encode(with)。
把它们也添加到Checklist.swift中。
required init?(coder aDecoder: NSCoder) {
name = aDecoder.decodeObject(forKey: "Name") as! String
items = aDecoder.decodeObject(forKey: "Items") as! [ChecklistItem]
super.init()
}
func encode(with aCoder: NSCoder) {
aCoder.encode(name, forKey: "Name")
aCoder.encode(items, forKey: "Items")
}
这样就实现了对name和items的存储和读取。
重要:在你运行app之前,从模拟器的Documents文件夹中删除掉Checklists.plist。如果不这样做的话,app会挂掉,因为文件的结构已经和现在的数据不符合了。
app莫名其妙的挂掉
当我第一次写这个课程,我在修改数据模型后再次运行app前,忘记了删除Checklists.plist。但是app好像也能正常运行,知道我试着新增一个新的待办分类时,app给出了一个奇怪的报错,并且再也没有响应了。
一开始我穷尽精力检查了几遍代码,没有发现问题,后来我想到可能是这个文件引起的BUG,然后我删掉了这个文件,再次运行app,一切运行完美。但是我还是不放心,为了确认这个因素,我又将之前保存好的旧版本运行了一次,果然,在新增待办分类时app挂掉了。此时就可以确定了,新旧版本的差异就在于Checklists.plist文件的结构。
原因大概是这样,代码以某种方式保留着旧的文件,即使此时数据模型已经完全不一致了,所以在对该文件写入内容时,就报错了,后面的一切操作也都无法继续。
你经常会遇到这种莫名其妙的BUG,从你正在做的部分当中,根本无法定位原因,此时正确的做法就是回到之前做过的事情中去找原因,类似这种bug在你真正找出原因前,是无法解决的。
在我们第四个课程中,有一节是专门将如何调试排出故障的,因为你写的代码肯定会有bug存在,所以掌握这个技能是必须的。快速定位并且根除bug,是成熟的开发者的必备技能。
删除掉旧的Checklists.plist后,运行app,试试各种功能是否正常运行。
然后退出app(使用Stop按钮中断app),再次运行,你会发现列表全部空空如也。你添加的所有东西都没了。
你可以添加任何你想添加的事项,但是什么都保存不下来。
这是怎么回事呢?
不同的存储
之前,每当用户新增、删除条目,或者点击对勾符号都会调用存储,将Checklists.plist更新一遍,这就是之前ChecklistViewController具备的功能。
然而,你将这些存储逻辑移放到了AllListsViewController中,这样,你如何能保证待办事项条目被保存呢?AllListsViewController甚至都不知道对勾符号的存在。
你可以给ChecklistViewController一个引用到AllListsViewController,当用户做出改变时,调用saveChecklists()方法,但是这样会形成一个所谓的父子从属关系,这是你极力需要避免的情况(循环引用,记得吗?)
父亲和它们的孩子
在软件开发中父亲和孩子是非常常见的术语。
父亲就是在某些层级上比较高位的对象;孩子就是在这个层级中觉低位的对象。
在我们的这个情况中,“层级”就是app中不同界面的间的导航流转。
All Lists界面是Checklist界面的父级,因为All Lists先“出生”。每次转场时它创造了ChecklistViewController这个新的孩子。
同样的,All Lists也是List Detail界面的父级。而Item Detail界面是Checklist view controller的子级。
总体而言,父级对子级了如指掌是没问题的,而子级对父级则不是这样,就像真实生活中一样,父母总是有些可怕的秘密不像让孩子知道。
结果就是,你不想让父级对象依赖它们的子级对象,所以让ChecklistViewController通知AllListsViewController做事,是不对的。
你也许会想:噢,我可以使用一个委托。确实可以,而且如果你真的有这个意识的话,我会感到非常自豪,证明我没有白教,但是我们不会这样做,取而代之的是,我们要回头来重新考虑一下我们的存储策略。
我们真的有必要实时的进行数据保存吗?当app运行期间,数据模型在内存中不断的被刷新。
你需要从文件中读取信息的唯一时刻就是当app启动的时候,而之后就不需要再次读取了。从app运行后起,你所做的一切操作,都会都会被内存记下来。
但是当改变发生的时候,之前存储的文件就过时了。这就是为什么我们之前把这些改变存储了下来,保持文件和内存的实时同步。
但是实际上,我们读取文件仅发生在app启动时,并不会在app运行期间实时的读取文件,所以我们只要在app中断时保存一次就可以了,其余的时间,让内存自动保存数据没有任何问题。
换而言之,你仅需确保,在app中断前,对数据进行一次保存。
这样做并不是仅仅为了提高效率,而是我们的app实现的是一个非常简单的功能,并不需要实时存储(像word这类软件确实需要实时存储)。
有以下三种情况,会导致app中断:
1、早期的iOS系统并不支持多任务,当用户运行app期间,接进来了一个电话,就会杀掉正在运行的app的进程。从iOS 4开始就不会这样了,接进电话时,会把正在运行的app切换到后台。但是即使是比较新的iOS版本,也会在某些情况下杀掉正在运行的app进程,比如说打开的应用太多,内存耗尽了。
2、当app被切换到后台以后。iOS系统在大多数时间内都会保证app的存活,它们的数据被冻结起来,只是不在进行运算。当你从新把app从后台切换回来以后,app继续正常运行。
而有时,iOS需要更大的内存给其他的app,多数时候是游戏类app,为了保证内存够用,iOS会杀掉后台app,并且把内存清理出来,而这一切,都不会提前通知这个app。
3、app异常崩溃。发现app崩溃很容易,但是处理起来很难,而且可能会在处理的过程中,使事情更加复杂。(避免app崩溃最好的办法就是代码不要写错)
幸运的是,在诸如中断和切换至后台这些动作发生前,iOS系统都会礼貌性的通知app一下。
你可以在听到这些通知的这一时刻,对数据进行保存。这样就可以保证每次app中断前,你都可以及时保存数据。
处理这些通知的地方是在application delegate中,你之前可能没关注过它,但是每个app都有一个application delegate。从名字就可以看出来,这是一个用于app全部通知的委托。
也是你接受到app中断和app被切换至后台通知的地方。
事实上,你打开AppDelegate.swift,你就会看到这个方法:
func applicationDidEnterBackground(_ application: UIApplication)
和
func applicationWillTerminate(_ application: UIApplication)
还有一些其他方法,但是现在你需要的就是这两个(Xcode已经为这些方法写好了清晰的注释,所以你可以轻易的知晓它们的作用)。
现在的问题是,你如何从这些委托方法中调用AllListsViewController的saveChecklists()。app delegate并不知道AllListsViewController的存在。
你需要介绍它们俩相互认识一下。
打开AppDelegate.swift,添加一个方法进去:
func saveData() {
let navigationController = window!.rootViewController as! UINavigationController
let controller = navigationController.viewControllers[0] as! AllListsViewController
controller.saveChecklists()
}
saveData() 方法从window属性中找到包含故事模版的UIWindow对象。
UIWindow是你app中所有视图中的最高层级的视图。在你的app中仅存在一个UIWindow对象,与桌面软件不同,桌面软件通常有多个window。
练习:你可以解释 为什么window后面要带一个感叹号吗?
可选型解包
在AppDelegate.swift顶部,你可以看到window被声明为一个可选型:
var window: UIWindow?
给可选型解包,你一般会使用if let语句:
if let w = window {
// if window is not nil, w is the real UIWindow object
let navigationController = w.rootViewController
}
也可以简写为可选型链接的形式:
let navigationController = window?.rootViewController
如果window为nil,则app都不会去看剩余的代码,并且navigationController也会为空。
因为app使用了一个故事模版(大多数都会),所以你可以保证window绝对不会为nil,即使它是一个可选型。UIKit可以保证在app启动时,在window变量内部有一个有效的指向UIWindow对象的引用。
那么为什么它会是一个可选型呢?当app启动,并且加载故事模版时,会有一个短暂的瞬间window属性为nil。如果一个变量可以为空,不管这个时间有多短暂,Swift都会要求将它申明为可选型。
如果你可以确保一个可选型不会为nil,那么当你使用它的时候,你就可以用强制解包的方式,在可选型变量的后面加一个感叹号就是强制解包的意思:
let navigationController = window!.rootViewController
你在saveData()方法中用的就是强制解包。强制解包是处理可选型的最简单的方式,但是它会带来很大的潜在危险:如果你的判断有误,当强制解包的时候可选型为nil,那么app就会挂掉。所以在使用强制解包的时候一定要三思。
其实在Item Detail和List Detail中你从UITextField中读取文本的时候 ,你就已经使用过强制解包了。UITextField的text属性就是一个可选型,但是它永远不会为nil,所以你可以用textField.text!来进行强制解包,这个感叹号就是强制解包的意思,它可以把可选型转换为常规的变量。
通常你不需要对UIWindow做任何操作,但是在这种情况下,你不得不向它请求访问它的rootViewController。root或者initial视图控制器,是故事模版中最开始的界面,navigation controller就在它的左边。
你可以在界面建造器中看到这一点,因为navigation controller左边有一个大箭头指着它,就是下面这个:
在navigation controller的属性检查器中,有一个Is Initial View的选框是选中状态,这个选框和大箭头是一个意思。在纲要面板中它被称作故事模版的起点。
一旦你有了这个navigation controller,你就可以找到AllListsViewController。毕竟这些视图控制器都是被嵌入到navigation controller内部的。
不幸的是,UINavgationController没有自己的“rootViewController”属性,所以你需要自己从视图控制器的数组中找到它:
let controller = navigationController.viewControllers[0] as! AllListsViewController
和平常一样,这里要使用类型扮演,因为视图控制器数组对其他你的视图控制器类型一无所知。一旦你有了一个指向AllListsViewController的引用,你就可以调用它的saveChecklists()方法了。
从window到navigation controller,直到找到你想要的视图控制器花了不少功夫,但这就是iOS开发者的必修课。
⚠️:顺便说一下,UINavigationController有一个topViewController属性,但是在这里你不能使用它:top view controller是正在显示中的界面,如果用户正在对待办事项条目进行操作的话,这个top view controller就是ChecklistViewController,而它无法处理saveChecklists()方法,app就会挂掉。
在applicationDidEnterBackground()和applicationWillTerminate() 方法中调用saveData()方法:
func applicationDidEnterBackground(_ application: UIApplication) {
saveData()
}
func applicationWillTerminate(_ application: UIApplication) {
saveData()
}
运行app,做些操作,包括更改一下对勾符号的状态。
可以使用Shift+command+H键或者模拟器菜单Hardware->Home来使模拟器中的app切换到后台,就像在iPhone上按下Home键一样。
顺便看看Document文件中,新的Checklist.plist文件已经生成了。
按下Stop按钮可以中断app,然后再次运行app,看看保存结果是否还存在。
⚠️:Xcode的Stop按钮
非常重要:当你点击Xcode的Stop按钮后,application delegate不会从applicationWillTerminate()中接收到消息。这和真实的手机使用情况不同。
因此为了测试这个存储效果,你需要先将app切换到后台,如果在你点击Stop按钮前,没有使用Shift+command+H把app切换到后台,那么数据不会被保存下来。