保存待办事项
在本小节,你将会写代码来保存用户的新增、编辑、或者删除的内容到Checklists.plist文件。并且你保存到这些内容会在app重新启动后被读取回来。
那么什么是.plist文件呢?
你已经在Bull's Eye这个课程中见过了Info.plist文件。所有的app都有这个文件,包括我们现在的这个app,你可以在工程导航器中看到这个文件。Info.plist包含app的各种属性,可以给iOS系统更多的关于这个app的信息,比如app的展现名称。
“plist”是Property List的简写,是一种XML文件,用于格式化数据结构,通常以许多设置及设置的值的形式存在。Property List文件在iOS中使用非常频繁。它适用于多种数据类型的存储,并且是最简单易用的一个。我们都爱它!
为了存储checklist items你必须要使用NSCoder系统,NSCoder系统可以使对象存储它们的数据到一个格式化的文件中。
你无需关心太多格式化的事。所有你要关心的事就是数据会存储在app的Documents文件夹下的某种文件中,其余的技术细节都可以交给NSCoder去处理。
你已经在多种场景中使用过NSCoder了,其实这就是故事模版的工作方式。当你在故事模版中添加一个新的view controller时,Xcode使用NSCoder系统将这个对象写入文件,这一步叫做(encoding,编码),然后当你的应用启动时,它又使用NSCoder来从故事模版中读取这个对象,这一步叫做(decoding,解码)。
将对象写入到文件然后在读取出来的这个过程叫做序列化(srialization)。这是软件工程中的一个大课题。
其实这个过程有点像我们使用电冰箱的过程,你先把新鲜蔬菜(对象)放到冰箱的某一个抽屉里里冻起来(相当于iPhone闪存中的某个文件),然后等你要吃的时候再拿出来解冻。(我只想说这个比喻有点好不恰当,不过算了,忠实于原著)
打开ChecklistViewController.swift添加下面这个方法进去:
func saveChecklistItems() {
let data = NSMutableData()
let archiver = NSKeyedArchiver(forWritingWith: data)
archiver.encode(items, forkey: "ChecklistItems")
archiver.finishEncoding()
data.write(to: dataFilePath(), atomically: true)
}
这个方法首先拿到items数组的数据,然后通过两个步骤将items数组转换为二进制数据块,并且将这个二进制数据块写到文件中。
1、NSKeyedArchiver,NSCoder创建plist文件的形式,将数组编码(encode)并且将其中的所有ChecklistItem转换为二进制格式,这样就可以将这些数据写入文件了。
2、数据被放置在一个NSMutableData对象中,然后将自己写入dataFilePath()路径指定的文件中。
目前你不理解NSKeyedArchiver内部的工作原理并不重要。你只需要记住这一行为可以使你将你的对象放入一个文件,并且之后可以读取出来。
你需要在任何items有变动的地方调用saveChecklistItems()这个方法。
练习:你都应该在哪些地方调用这个方法呢?
答案:看看items数组在哪些地方有修改的操作。它们都发生在ItemDetailViewControllerDelegate委托方法中。
还是在ChecklistViewController.swift文件中,在以下方法内部添加saveChecklistItems():
func itemDetailViewController(_ controller: ItemDetailViewController,
...
saveChecklistItems()
}
func itemDetailViewController(_ controller: ItemDetailViewController,
...
saveChecklistItems()
}
不要忘了扫动删除行的地方:
override func tableView(_ tableView: UITableView,
commit editingStyle: UITableViewCellEditingStyle,
...
saveChecklistItems()
}
还有开关对勾符号的地方:
override func tableView(_ tableView: UITableView,
...
saveChecklistItems()
}
仅仅是对items数组调用NSKeyedArchiver是不够的。如果你现在运行app,做些什么,比如说通过点击将某一行的对勾符号去掉,app会立马死给你看。不信可以试试:
*** Terminating app due to uncaught exception
'NSInvalidArgumentException', reason: '-[Checklists.ChecklistItem
encodeWithCoder:]: unrecognized selector sent to instance 0x7f8d6af3aac0
上面的报错中提到了selector(选择器)这样一个东西,selector是Object-C中的术语,用于方法的名称,所以这个报错的意思是app试图调用一个叫做encodeWithCoder()的方法,但是没有找到它。(Swift的术语中不存在selector,但是因为iOS的框架是由Object-C写成的,所以比较底层的报错中你会看到Object-C的术语)
报错的同时Xcode的窗口会切换到调试器,并且指出是哪里引发了问题,你得到的提示大约会是下面这个样子:
调试区域将报错的源头指向AppDelegate.swift源文件,这是一个误导。这时我们需要用一种叫做断点的工具来调试:
切换到断点导航器(见下图),并且点击底部的➕号按钮:
再运行一次app试试。
注意:如果你的app运行时立马崩溃了,那么就是UIKit底层的异常被触发了。这不是由于你的代码造成的。异常断点的作用就是捕捉任何异常,即使并不是非常致命的异常或者是由于系统导致的异常。在这种情况下,你可以点击几次following按钮(见下图)来跟踪一下:
这个按钮的作用是继续运行程序,你可以在Xcode底部找到它。
点击app上的某行上的对勾符号。这一次Xocde就会定位到正确的错误源头了,它是在saveChecklistItems()中:
罪犯现形:
archiver.encode(items, forKey: "ChecklistItems")
显然app是在试图对items数组编码时崩溃掉的,或者是数组内部的数据出了问题。
这个“unrecognized selector”报错信息意味着你忘记了执行某个方法。很显然这个被遗忘的方法和对ChecklistItem进行encode(with)有关。
真相是这样的:你指使NSKeyedArchiver对items数组进行编码,所以在编码时,程序不仅要对数组进行编码,而且要对数组内的每一个ChecklistItem对象进行编码,而NSKeyedArchiver目前对ChecklistItem一无所知。所以你必须帮它一把。
在ChecklistItem.swift中添加一个NSCoding,就在下面这一行添加:
class ChecklistItem: NSObject,NSCoding {
添加完以后,轮到编译器死给你看了,它说ChecklistItem没有遵守NSCoding协议。
这里的名字有点绕:如果你想要在你的对象上使用NSCoder系统的话,你就必须执行NSCoding协议。
之前我们介绍过,协议就是一组方法名称的列表。遵守一个协议就意味着你需要执行协议中的方法。
NSCoding协议中的方法是:
func encode(with aCoder: NSCoder)
init?(coder aDecoder: NSCoder)
不算太坏,只有两个方法而已。第一个是用于存储或者说用于编码的方法。第二个是一种特殊的init方法。回忆一下init方法用于创建一个新的对象。这里的init方法用于创建从plist文件中读取出来的对象。
在ChecklistItem.swift中添加以下方法:
func encode(with aCoder: NSCoder) {
aCoder.encode(text, forKey: "Text")
aCoder.encode(checked, forKey: "Checked")
}
这就是被遗忘的那个方法。
当NSKeyedArchiver试图对ChecklistItem对象编码时,它会先给Checklist item发送一条encode(with)消息。
这个方法翻译成白话文就是:一个ChecklistItem需要保存一个名叫Text的对象,这个对象包含实例变量text的值,以及一个名为Checked的对象,这个对象包含变量checked的值。
添加这个方法足以让coder系统工作了,至少可以用于保存对象了。
在你能正常运行app前,你还需要添加点东西。Swift需要你执行协议中的所有方法,而NSCoding协议中有两个方法,一个用于保存,另一个用于读取。
在ChecklistItem.swift中添加第二个方法:
required init?(coder aDecoder: NSCoder) {
super.init()
}
现在我们还没有实际的用到它,仅仅是为了编译器不报错,简单的添加了这个方法。
⚠️:你还记得init?(coder)吗,我们之前使用过的,用于初始化ChecklistViewController。因为故事模版也需要使用NSCoding来读取对象到app中。
init是Swift中比较特殊的一个方法,因为你添加了init?(coder)所以你必须同时添加没有参数的init()方法。如果不这样做,还是无法通过编译。我们后面会学习更多关于这个的知识。
在ChecklistItem.swift中添加:
override init() {
super.init()
}
这个方法没有任何作用,仅仅保证顺利通过编译。
运行app,然后点击某行上的对勾符号试试,看能不能正常工作。
回到Finder窗口中,找到app的Document目录:
现在你可以看到Checklists.plist文件了,它包含table view中的待办项目的数据。
你可以打开这个文件看看,但是可能里面的内容会让人头晕眼花,虽然它是XML格式,但不是用来给人看的那种,只有NSKeyedArchiver系统能看懂。
也许你看到的不是XML格式的东西,而是二进制的格式,这是因为有些文本编辑器不支持自动转换格式,你可以从Mac store下载一个TextWrangler试试,非常不错。
你也可以利用Finder的快速预览功能来查看这个文件,选中这个文件然后点击空格键就可以了。
你也可以右键选择打开方式为Xcode。
还是有点看不太懂,不过多了解一下总是好的。
你可以展开几行看看,里面即包括NSKeyedArchiver也有ChecklistItem,至于它们是如何自洽的组合在一起的,我并不关心。
NS对象
以NS为前缀开头的对象都是由Foundation框架提供的。NS是NextStep的缩写,从1990年开始NS对象就是Mac OS和iOS的基础部分。
如果你困惑于像NSKeyedArchiver和NSCoding这类对象到底是如何工作的,你可以把鼠标移到它们上面然后使用Option键查看它们的介绍。
我总是提醒自己如何利用框架中提供的对象以及方法。
对框架中可用的对象有个大体的把握是很好的,但是没有人可以把一个框架内的方方面面都背下来。所以养成查看文档的习惯是非常有必要的,如果你遇到了一个新的对象的话。这样你会非常快的熟悉iOS框架。
读取文件
存储功能已经做好了,但是只是存储是没有用的,我们还需要对Checklists.plist做读取操作。这非常简单,和保存的过程一样,只是动作是相反的。
我们之前在ChecklistItem.swift文件中添加了一个空的init?(coder)方法,现在是时候添加点东西进去了。
将init?(coder)修改为下面这个样子:
required init?(coder aDecoder: NSCoder) {
text = aDecoder.decodeObject(forKey: "Text") as! String
checked = aDecoder.decodeBool(forKey: "Checked")
super.init()
}
在init?(coder)你做了于encode(with)相反的操作。你从NSCoder的解码对象中取得了对象并且将它们放回到原来的变量中。
你之前在存储在Text中的内容现在重新回到了text变量中。checked也是同样道理。需要值得注意的是对text操作时用的是decodeObject,对checked操作时用的是decodeBool。不同的数据类型要用不同的解码器。
初始化
在Swift中名为init的方法是一种特殊的方法。它们仅在你创建新的对象时使用,为我们创建好这些新的对象随时可以使用。
把这件事想象为买一件新衣服。买来以后这件衣服就是你的私人财产了(相当于为对象分配好了一块内存),但是这件衣服仍然在手提袋里,你要把它穿到身上,才算使用了这件衣服。
但你用下面的语句创建一个新的对象时:
let item = ChecklistItem()
swift首先会为这个新的对象分配足够的存储空间然后调用ChecklistItme的init()方法,没有参数的这一个。
通过NSKeyedUnarchiver对象完成Checklists.plist文件的读取时,会先创建ChecklistItem对象,然后进行解压:
let item = ChecklistItem(coder: someDecoderObject)
这个操作同样也为新的ChecklistItem分配了内存,但是它调用的是init?(coder)而不是init()。
一个对象有多个init方法是非常常见的,具体使用那一个要看具体情况。
当用户点击➕号按钮创建ChecklistItem对象时,你使用init(),当读取存储中的ChecklistItem对象时,你使用init?(coder)。
无论你是调用init()还是init?(coder),这些init方法的实现都是遵循一些列步骤的,哪怕是以后你自己写的init方法,也要遵循这些步骤。
这是最基础的init方法调用:
init() {
// Put values into your instance variables and constants.
super.init()
// Other initialization code, such as calling methods, goes here.
}
注意一下init方法和其他方法不同,没有func关键字。
有时你会看到override init 或者 required init?。当你为其他对象的子类的对象添加init方法时,必须这样做。更多的我们后面讲。
当init可能会返回失败或者返回nil时,就需要加上一个问号。你可以理解为当为一个对象解码时,plist文件中的数据丢失了,不够全,导致解码失败。
在init方法的内部,你首先要确保所有的实例变量或者常量都有值。回忆一下,swift要求所有的变量或者常量都必须有值,除了可选型。
当你声明一个实例变量时你可以给它一个初始值,比如:
var checked = false
有时候会只写变量名称和类型,但是没有初始值,比如:
var checked: Bool
这种情况下,在init方法内部一定要先给它赋值,比如:
init() {
checked = false
super.init()
}
如果你不这样做的话,swift会认为这里有致命错误,唯一的特例就是可选型,它们不一定必须有值。
当你确定所有的变量或者常量都有值了以后,你调用super.init()来初始化这个对象的父类。如果你以前没有接触过面向对象编程的话,你可能不知道什么是父类,没关系,忽视它吧,我们会在下个课程中详细讨论这个话题。
你只需要记住有时对象需要向叫做super的东西发送消息,如果你忘记了,app就会死给你看。
调用super.init()后,你可以进行额外的初始化配置,比如调用这个对象自己的方法。但是在你调用super.init()前不能这样做,因为此时swift不能确保你的对象的变量都有值。
你不需要总是写init方法,如果你的init方法本身不需要任何东西的话,没有任何的变量需要处理,那么你可以忘掉init,编译器会自动为你提供一个。
当你最开始创建ChecklistItem时,并没有用到init()方法,但是后面我们用到了init?(coder),所以此时init()就不能省略了,你必须写出来。
swift的初始化规则有些复杂,但是幸运的是,当你忘记它时,编译器会提醒你的。
ChecklistItem的实现就全部完成了。它可以把plist文件中已经序列化的数据读取会app中,但是你还要写一些代码才能正确的显示读取回来的结果。这次是要改动ChecklistViewController。
table view对象,和其他对象一样,有超过一个的init方法,它们是:
init?(coder)用于从故事模版中自动加载视图控制器。
init(nibName,bundle)用于你手工从视图控制器中加载nib(nib文件和故事模版一样,只是仅包含一个视图控制器)
init(style)用于你想要创建没有故事模版或者nib时的table view controller。
这些视图控制器是来自故事模版的,所以你要将plist文件中读取到的东西放进init?(coder)里。这和你在实现ChecklistItem时用到的差不多。
UITableViewController通过故事模版从同样的NSCoder系统中由你自己创建的plist文件中被读取出来并且解码。
打开ChecklistViewController.swift,将init?(coder)修改为:
required init?(coder aDecoder: NSCoder) {
items = [ChecklistItem]()
super.init(coder: aDecoder)
loadChecklistItems()
}
这次在这个init方法中我们做了三件事:
1、你确保实例变量items一定是有值的。
2、然后你调用了父级的init()。这一次调用的是super.init(coder),确保这个视图控制器的剩余部分可以正常的从故事模版中解码。
3、最后,你可以调用一些其他方法。这里你调用了一个新的方法,这个新的方法的作用就是从plist文件中读取数据。
⚠️:你注意到了吗?这个init?(coder)有不同的外部和内部名称。coder是方法名称的一部分,但是在方法的内部还有一个参数叫做aDecoder。
当你调用super.init时,你使用名称coder来引用super.init的参数,而aDecoder中的对象就是这个参数的值。
接下来我们来添加loadChecklistItems()方法:
func loadChecklistItems() {
//1
let path = dataFilePath()
//2
if let data = try?Data(contentsOf: path) {
//3
let unarchiver = NSKeyedUnarchiver(forReadingWith: data)
items = unarchiver.decodeObject(forKey: "ChecklistItems") as! [ChecklistItem]
unarchiver.finishDecoding()
}
}
一步步来解释:
1、首先你将dataFilePath()的结果放倒了一个名叫path的临时常量里。
2、试着从Checklists.plist中读取内容到一个新的数据对象中。关键字Try?的意思是就是字面意思,试试,如果有数据对象就读出来,如果是nil就不读了。这就是为什么我们用if let解包的原因,因为它可能为nil。
为什么可能会失败呢?假如根本没有Checklists.plist文件的话,显然也不会有什么ChecklistItem对象。这一情况会在app初次运行时发生。在这种情况下,你需要跳过这个方法的其余部分。
3、当app找到Checklists.plist文件,你要从文件中把整个数组读取出来。这一行为和saveChecklistItems()截然相反。
你创建了一个NSKeyedUnarchiver对象,就是unarchiver,并且要求它将数据解码到items数组中。这一步将文件中曾经未解码的ChecklistItem对象的拷贝填充到数组中。
⚠️:检查一下,loadChecklistItem()和saveChecklistItems()两个方法中都是使用“ChecklistItems”这个关键字来进行解码和编码的。如果你输入错了,那么app就会死给你看。
通常Xcode的智商都足以为你指出错误,但是没有聪明到能指出这种错误。
运行app,并且做些操作。然后点击Stop按钮中断app,再重新运行一次,你会发现你第一次到操作结果都保存了下来。
在此点击Stop按钮中断app,然后打开Finder,找到Documents文件夹并且删除掉Checklists.plist文件,再运行一次app,你会发现所有的东西都消失了。
然后再添加一条待办记录,你会发现Checklists.plist又重新生成了。
棒极了,你写了一个app不仅可以添加、编辑数据,并且还可以持久化的保存数据,这些都是app开发的基本功。以及使用委托传送数据都是iOS开发的基础技能。
使用FileMerge来比较文件
你可以对你写的app代码和我的进行对比,通过使用叫做FileMerge的工具。它位于Xcode的菜单Xcode->Open Developer Tool->FileMerge中:
然后分别选择两个文件的位置:
等一段时间后,FileMerge会告诉你两个文件的差异在哪里:
双击列表中的文件名,就可以查看两个文件的详细内容了:
FileMerge是非常有用的一个工具,用于定位两个文件之间的差异,我经常使用它。
如果你在写代码的过程中,没有达到理想的效果,就可以通过这种方法和我的对比一下。
到了可以休息一会的时间了,停下来泡泡脚,然后畅想一下未来你自己的app位居榜首的情景。
如果你有不理解的地方,那么回头重新阅读一遍是不二的选择。不要冲的太快,我并没有什么奖牌可以给你。相比之下你还不如花时间慢慢的把这些东西消化为自己的,这才是你的奖牌。
和往常一样,自己动手去改改代码,添加一些你想要的新功能,我们鼓励你把app搞到不能运行为止,这就是学习的方法。
确保你消化了到目前为止的所有内容,接下来我们会为这个app添加一些新的功能,也会重复之前的内容,相当于复习了。
但是眼下,你还是先停下来,休息一下,调整调整自己。