iOS - CoreData 访问数据(3)

2018-12-20

一个基本的CoreData栈由四个部分

  • 托管对象 ( NSManagedObject)
  • 托管对象上下文 ( NSManagedObjectContext)
  • 持久化存储协调器 (NSPersistentStoreCoordinator)
  • 持久化存储 ( NSPersistentStore)

1. 获取请求

获取数据有两个 API 接口,两者的区别如下

// 上下文将匹配来自持久存储的结果与上下文中的当前更改(因此,即使插入的对象尚未持久化,仍返回)
open func fetch(_ request: NSFetchRequest<NSFetchRequestResult>) throws -> [Any] 

// 用该方法查询请求,不会影响上下文的内容
open func execute(_ request: NSPersistentStoreRequest) throws -> NSPersistentStoreResult

执行一个 execute 方法的步骤:

  1. 上下文通过 executeRequest(_:withContext) 方法将获取的请求加上上下文(也就是是自己)当做参数传入持久化存储协调器。

  2. 持久化存储调用每一个持久化存储上的 executeRequest(_:withContext) 方法将请求转发给持久化存储们(有多个存储的话)。

  3. 持久化存储把获取请求转换成一个 SQL 语句,并把这个 SQL语句发给 SQLite。

  4. SQLite 执行查询语句,将匹配查询条件的所有行(row)返回给存储。这些行同时包含了对象的 ID(ObjectID)和属性的数据(因为获取请求的 includesPropertyValues 选项的默认值是true)。对象 ID 是持久化存储 ID 、表 ID 、表中行主键的组合,做为一个唯一标识符。 返回的属性数据存储在持久化存储的行缓存里,一起存储的还有对象 ID 和缓存条目最后更新的时间戳。只要上下文存在某个特定对象 ID 的托管对象,含有这个对象 ID 的行缓存条目就会一直存在,无论这个对象是不是惰值。

  5. 持久化存储把从 SQLite 接受到的对象 ID 通过调用上下文的 objectWithID(_: ) 实例化为托管对象,并把对象返回给协调器。获取请求默认返回托管对象(还有其他三个类型,count,dictionary,NSManageObjectID)。这些对象默认是 惰值(也就是一些没有填充实际数据的轻量级对象)。当使用对象属性时,会去行缓存加载数据。上下文如果已经有相同对象 ID 的对象,那么会直接只用对象,因为对象 ID 是唯一的,上下文对相同对象只托管一个。

  6. 持久化协调器将从持久化存储拿到的托管对象数组返回给上下文。

  7. 因为获取请求的 includesPendingChanges 属性默认值是 true ,在返回获取请求的结果之前,上下文会将那些正在等待进行的更改考虑进来,并相应地更新原来的结果(等待进行的更改指那些在上下文里做过但还没保存的更新、插入、删除)。

  8. 最后返回一个托管对象数组给调用者。

所有的操作都是同步发生——托管对象上下文和持久化存储协调器会被阻塞,直到获取请求被完成。

2. 对象惰值

个人感觉 惰值 在 Core Data 中是一个很重要的概念,和性能关系很大

可以通过设置 returnObjectsAsFaults 属性来控制获取请求是返回惰值,还是完全返回实体化对象,默认是true。如果事先知道请求的数据都是要使用的,可以将 returnObjectsAsFaults 设置为false。在这种情况下,可以省掉一堆为了填充数值而产生的往返于持久化存储层的开销。

惰值实体化的步骤:

  1. Core Data 的属性存取方法(accessor)内部会调用 willAccessValueForKey(_: ) 检查对象是否为惰值。Core Data 会在运行时为标记为 @NSManaged 的属性实现属性存取方法(accessor),所以可以在读取写入属性值的时候注入自己的行为,比如填充一个惰值。

  2. 因为这个对象是一个惰值,它会让它所属的上下文填充这个惰值。上下文会向它的持久化存储协调器请求数据。

  3. 持久化存储协调器通过 newValuesForObjectWithID(_:withContext:) 方法向持久化存储请求与 对象ID 相关联的数据。

  4. 持久化存储在行缓存里查找 对象ID 的数据。如果缓存数据没有失效,那么命中缓存,数据返回,工作结束。缓存数据的失效由上下文的 stalenessInterval 属性来决定,默认为0,表示永不失效。

  5. 如果没有命中缓存,持久化存储会重新生成 SQL 语句请求数据。

  6. 协调器把数据返回上下文填充惰值。

刷新对象: 可以将已经实体化的托管对象转成一个惰值。可以为对象调用上下文的 refreshObject(_:mergeChanges: ) 方法。这个方法的参数 mergeChanges 只在对象有未保存的更改是才起作用。当为true 时,会更新更改,并不会把对象变成惰值。为 false 时,会强制将对象转成惰值,未保存更改将丢失。refreshAllObjects( ) 可以将上下文中所有不包含待保存改变的对象惰值化。

3. 获取请求的结果类型

除了默认的得到一个托管对象的数组,还有其他三个选项:只获取对象的 ID、用字典的方式获取特定的属性、只获取匹配行的数量。只需修改获取请求的 resultType 的枚举类型就行。

  • 只获取对象的 ID( .ManagedObjectIDResultType):获取请求将返回 NSManagedObjectID 实例的数组。但是注意,这样子获取请求仍会从数据库中加载匹配行的所有数据,并更新行缓存。要想防止这种行为,可以将获取请求的 includesPropertyValues 的值设为 false。

    只获取对象 ID 有时很有用,比如我们可以用很小的开销获取一个符合我们期望谓词和排序描述符的对象 ID 列表,我们可以遍历这个列表,通过将所需对象的 ID 传递到一个 self IN %@ 的谓词里增量的获取数据。这实际就是 Core Data 在获取请求上实现批次获取(batch size)的做法。此外获取随机数据也可以先获取对象 ID 列表,再其中随机选择 ID 获取具体数据。

  • 只获取匹配行的数量( .CountResultType):概念上等同于 countForFetchRequest(_: ) 而不是 executeFetchResult(_: ) ,单纯为了获取结果的数量。
  • 用字典的方式获取特定的属性( .DictionaryResultType):该类型返回的是包含原始数据的字典的数组,可以通过设置 propertiesToFetch 来指定只取回实体的某些属性。除此以外还可以通过和 NSExpression 一起做一些有趣的事,比如求平均值。
let request = NSFetchRequest(entityName: employee") 
request.resultType = .DictionaryResultType 

let salaryExp = NSExpressionDescription() 
salaryExp.expressionResultType = .DoubleAttributeType 
salaryExp.expression = NSExpression(for Function: "average:, arguments: [NSExpression(forKeyPath: "salary")]) 
salaryExp.name = "avgSalary" 

// propertiesToGroupBy 属性指定在 SELECT 语句执行之前应该被用来分组的属性的名称。这个获取请求的结果将是一个字典数组,每个字典有两个键 “type” 和 “avgSalary”。 
request.propertiesToGroupBy = ["type"] 
request.propertiesToFetch = ["type", salaryExp] 

try! context.executeFetchRequest(request)

4. 批量获取

NSFetchRequest 的 fetchBatchSize 属性能避免一次性加载所有行的数据。如果你不是百分百确定你确实立刻需要使用所有的结果,那么就应该使用这个属性。

request.returnsObjectsAsFaults = false 
request.fetchBatchSize = 20 

当我们执行以上请求时,会发生以下步骤:

  1. 持久化存储将请求结果的 所有对象 ID 加载到内存中,不包含关联所有数据,转交给协调器。
  2. 持久化存储协调器创建一个由这些对象 ID 组成的数组,并将这个数组返回给上下文。

一旦访问数组里的元素,Core Data 就会在幕后处理这些按页加载的任务。过程如下:

  1. 这个分批数组注意到它缺少你正在尝试访问的元素数据,它会要求上下文加载你请求的索引附近数量为 fetchBatchSize 的一批对象。
  2. 请求加载这批数据,存储在行缓存里,将惰值填充了返回给上下文。
  3. 就获得了该批次的对象。

当我们遍历数组时,Core Data 会额外加载几批所需数据外的对象,并以最近使用作为原则保持少量批次,而较早的会被释放。

5. 异步请求

以下是创建一个异步请求 NSAsynchronousFetchRequest 异步获取数据

func asyncFind(by request: NSFetchRequest, completionBlock: @escaping (String, [Entity]) -> Void) { 
	let asyncRequest = NSAsynchronousFetchRequest(fetchRequest: request) {  result in 
		if let result = result.finalResult { 
			completionBlock(keywords, result as! [Entity]) 
		}
	}
	try! managedObjectContext.execute(asyncRequest) 
}

6. 其他取回托管对象的方法

每个托管对象上下文在 registeredObjects 属性里持有一个在当前上下文里注册过的所有对象列表。

  • objectRegisteredForID方法:获取特定ID的对象,上下文不存在这个特定ID的对象,返回nil。该方法不会执行任何 I/O 操作,仅仅在上下文的 registeredObjects 属性中搜索。
  • objectWithID方法:如果对象已经用对应的对象 ID 注册过,那么这个方法返回对象。没注册过,那么上下文创建一个含有指定对象 ID 的托管对象惰值。
  • existingObjectWithID方法:如果对象已经用对应的对象 ID 注册过,那么这个方法返回对象。没注册过,尝试从持久化存储里获取指定对象。如果持久话存储也没有,抛出一个错误。

7. 内存考量

在默认情况下,上下文只保留那些含有未保存更改的托管对象的强引用。意味着如果代码里没有强引用,那个这个托管对象会从上下文的 registeredObjects 里一处并释放掉。同样的,一旦没有托管对象引用持久化存储行缓存里的数据,那么数据会在行缓存里移除。

关系循环引用: 一旦使用关系,托管对象就会持有其他对象的引用。要想释放掉其的内存,就要打破循环引用。我们必须刷新至少一个对象,调用上下文的 refreshObject 方法,这个对象仍将有效,但是它的数据会从上下文里消失,这不仅会影响到对象的属性,也会影响到它的关系,从而打破循环引用。

打破循序应用的时机可以是从 navigationController 弹出一个 viewController,或者在应用程序进入后台时候刷新所有对象。