iOS Apprentice中文版-从0开始学iOS开发-第二十三课

改进数据模型

上面的代码已经可以工作了,但是你仍旧可以做的更好一些。你为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()注释掉,先增加几个待办事项进去,然后再把注释去掉重新运行,或者什么都不要做,等我们下节课解决完问题后再说。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容