如何在 Core Data 中使用 Derived 和 Transient 属性

前言

使用过 Core Data 的开发者,一定会在编辑 Data Model 时看到过右侧的属性面板中的 Derived 和 Transient 两个属性。关于这两个属性的文档不多,大多的开发者并不清楚该如何使用或在何时使用该属性。文本将结合我的使用体验,对 Derived 和 Transient 两个属性的功能、用法、注意事项等内容作以介绍。

健康笔记 - 全家人的健康助手

健康笔记适用于任何有健康管理需求的人士。提供了强大的自定义数据类型功能,可以记录生活中绝大多数的健康项目数据。你可以为每个家庭成员创建各自的记录笔记,或者针对某个特定项目、特定时期创建对应的笔记。

推荐

Derived

什么是 Derived 属性

从 iOS 13 开始,苹果在 Core Data 中添加了 Derived(派生属性),它的名称已经表明了该属性的含义—— 该属性的值从一个或多个其他的属性的值派生而来。

通俗地说,就是在创建或修改托管对象实例时,Core Data 将自动为派生属性生成值。值将根据预设的派生表达式(Derived Expression)通过其他的属性值计算而来。

Derived 属性的功能

下面通过一个具体的例子方便大家理解派生属性的功能。

项目中有两个 Entity,TodoList 和 Item,Todolist 同 Item 是一对多关系(To-many),Item 同 TodoList 之间是一对一关系(To-one)

image-20211025175712406

在之前如果我们要查看每个 TodoList 下有多少个 Item,可以使用如下代码:

let count = todolist.items.count

使用 Derived 派生属性后,我们将可以通过如下代码获取Item的个数。

let count = todolist.count

如何设置 Derived 属性

通常我们需要在 Core Data 的数据模型编辑器(Data Model Editor)中设置派生属性,如下图,我们为上面的例子中的 TodoList 创建派生属性 count

image-20211025183247335
  • 为 TodoList 创建名为 count 的属性
  • 选择 Derived
  • 将 Derivation 设置为 items.@count (计算 items 关系对应的数据个数)

开发者可以根据需要设定派生属性的类型和派生表达式,目前支持的派生表达式有以下几种形式:

  • 仅仅复制内容

通常使用在 to-one 的关系中,比如上面的例子中,我们可以使用派生表达式todolist.name,为 Item 设置一个 todolistName 的 Derived 属性,用于保存该 Item 对应的 TodoList 的名称。复制的属性类型没有特别的限制。

  • 将某个属性(类型为字符串)经过变换保存

仅支持类型为 String 的属性,可以使用在同一个 Entity 中的不同属性或者 to-one 的 Entity 属性中。支持 uppercase:lowercase: 以及 canonical: 三种方法。通过保存字符串的变体,提供搜索效率。 例如,保存 TodoList 的 name 的小写版本的派生表达式为lowercase:(todolist.name)

  • 计算对多关系的 count 和 sum

计算对多关系(to-many)对象的个数或计算指定属性的求和值。使用@sum 时,要求对应的属性必须为可计算值类型。例如计算一个实体名称为 Student,属性名为 age 的总和值的表达式为 student.age.@sum

  • 当前时间

保存SQLite更新托管对象对应的数据记录的操作日期。通常用于类似 lastModifiedDate 之类的时间戳。派生表达式为now()

通常我们将 Derived 同 Optional 配合使用,如果不选取 Optional 则需要做一点特殊处理才能让程序正常执行。在下文的注意事项中有具体说明。

如果手动编写 NSManagedObject 代码,Derived 属性的写法同其他属性也完全一致(仍需在 Data Model Editor 中设置),例如上文中的 count 可以使用如下代码定义:

@NSManaged var count: Int

Derived 数据的更新机制

派生数据的值是谁计算的

派生数据的值是由 SQLite 直接计算并更新的。

Derived 值的计算是 Core Data 中为数不多的几个直接使用 SQLite 内置机制来完成的操作,并非由 Swift(或 Objective - C) 代码计算而来。

例如,now() 的表达式,Core Data 在创建数据表时将产生类似如下的 SQL代码:

CREATE TRIGGER IF NOT EXISTS Z_DA_ZITEM_Item_update_UPDATE AFTER UPDATE OF Z_OPT ON ZITEM FOR EACH ROW BEGIN UPDATE ZITEM SET ZUPDATE = NSCoreDataNow() WHERE Z_PK = NEW.Z_PK; SELECT NSCoreDataDATriggerUpdatedAffectedObjectValue('ZITEM', Z_ENT, Z_PK, 'update', ZUPDATE) FROM ZITEM WHERE Z_PK = NEW.Z_PK; END'

@count 对应的代码:

UPDATE ZITEM SET ZCOUNT = (SELECT IFNULL(COUNT(ZITEM), 0) FROM ZATTACHEMENT WHERE ZITEM = ZITEM.Z_PK);

因此在相同功能的情况下,使用SQL的效率是高于 Swift(或 Objective - C)的。

Core Data 中,通常需要从持久化存储获取结果后,返回到上下文,再经过计算然后持久化。中间有多次的IO过程,影响了效率。

派生数据什么时候更新

因为是由 SQLite 直接处理的,所以只有在数据持久化时 SQLite 才会更新对应的派生数据。只在上下文中处理不持久化的话是不会获得正确的派生值的。持久化的行为可以是通过使用代码viewcontext.save(),或者通过网络同步等方式激发。

Derived 的优缺点

优点

  • 效率高

由于其特有的更新机制,所以对于值的处理效率更高,且不会有多余的处理动作(只在持久化时才进行更新)。

  • 逻辑简洁清晰

使用得当的情况下,配置所需代码更少,表达更清晰。例如now()

缺点

  • 支持的表达式有限

SQLite 能够支持的表达式非常有限,无法满足更复杂的业务需要。

  • 对于不了解 Derived 的开发者来说,代码更难阅读

Derived 的配置是在 Data Model Editor 中进行的,仅阅读代码将无法获悉该数据的来源和处理方式。

Derived 的替代方案

计算属性

对于使用频率不高的属性值,为托管对象创建计算属性或许是更好的选择,例如上文中计算 TodoList 的 Item 数量。

extension TodoList {
    var count:Int { items.count }
}

willSave

使用 NSManagedObject 的 willSave 方法,在数据持久化前,为指定属性设置值。例如:

extension Item {
    override func willSave() {
      super.willSave()
      setPrimitiveValue(Date(), forKey: #keyPath(Item.lastModifiedDate))
  }
}

Derived 同上述两种方式均有各自的优缺点,请根据具体的使用场景来选择合适的方案。

Derived 的注意事项

在配置 Derived 属性时,如果不选择 Optional,直接执行代码的话,在添加数据时会得到类似如下的错误:

Fatal error: Unresolved error Error Domain=NSCocoaErrorDomain Code=1570 "count is a required value."

这是因为,由于该属性并非可选值,所以 Core Data 要求我们为派生属性默认值,但是由于派生属性是只读的,因此我们无法在代码中直接为托管对象实例的派生属性赋值。

解决的方法是,通过在 awakeFromInsert 中为派生属性设置初始化值,即可通过 Core Data 的属性有效性检查。

extension TodoList {
    override func awakeFromInset(){
        super.awakeFromInsert()
        setPrimitiveValue(0, forKey: #keyPath(Todolist.count)) 
    }
}

设置的 value 可以是任意值(需要符合类型要求),因为在持久化时,SQLite 将生成新的值覆盖掉我们的初始化值。

Transient

什么是 Transient 属性

Transient(瞬态属性)是一种不可持久化的属性。作为托管对象定义的一部分,Core Data 会跟踪 Transient 属性的变化,并设置对应的托管对象和托管对象上下文状态,但属性中的内容将不会被保存到持久化存储器中,也不会在持久化存储中创建对应的字段。

除了不能持久化外,瞬态属性同其他的 Core Data 属性没有什么区别,支持全部的可用数据类型,以及 Optional,Default Value 等选项。

如何设置 Transient 属性

相较于 Derived,瞬态属性设置非常简单,只需要勾选 Transient 即可。

image-20211025201846952

为什么使用 Transient

既然 Transient 不可持久化,为什么我们要使用 Data Model Editor 来创建它呢?

我们可以通过代码直接为托管对象创建存储属性,例如:

@objc(Test)
public class Item: NSManagedObject {
  var temp:Int = 0
}

extension Item
    @NSManaged public var createDate: Date?
    @NSManaged public var title: String?

}

let item = Item(context:viewContext)
item.temp = 100

上面的代码,无论我们如何修改 item 的 temp 属性,都不会让 Core Data 感知到。

托管对象的托管属性(使用@NSManaged 标示)是由 Core Data 来托管的,Core Data 将持续跟踪托管对象中的托管属性来设置相应的状态。使用 Transient 属性,Core Data 在该属性内容发生变化时将托管对象实例的 hasChanges 以及托管对象上下文的 hasChanges 设置为 true,这样无论是@FetchRequest 还是 NSFetchedResultsController 都将会自动反应数据的变化。

因此,当我们不需要持久化但又要能够 dirty 状态时,Transient 就成了唯一的选择。

Transient 值的初始化

由于 Transient 属性是不可持久化的,因此每当含有 Transient 属性的托管对象实例出现(Fetch、Create、Undo 等情况)在上下文中时,其瞬态属性都将恢复到初始值。

尽管在 Data Model Editor 中,我们可以为 Transient 设置默认值,但很多场景下,我们需要根据情况或其他数据计算并创建 Transient 的初始值。我们可以选择在如下的时机来设置:

  • awakeFromFetch

为惰值状态(Fault)的实例填充数据时

  • awakeFromInsert

创建托管对象实例时

  • awake(fromSnapshotEvents:NSSnapshotEventType)

从快照中载入实例时

在这些方法中设置 Transient 或其他属性时,应使用原始访问器方法来设置数据,避免触发 KVO 观察器通知。例如:

setPrimitiveValue("hello",forKey:#keyPath(Movie.title))

Transient 属性使用举例

绝大多数的 Core Data 书籍中,即使提到了 Transient 属性也通常是一带而过。作者通常会表示自己没有遇到合适的 Transient 使用案例。

我也是在不久前,才遇到第一个符合 Transient 特点的应用场景。

在开发 【健康笔记 3.0】 的过程中,我有一处地方需要对一个包含很多关系和记录的托管对象实例进行 Deep Copy(复制其下的全部关系数据),复制后的实例将在复制完成后替换掉原来的实例(为了解决网络数据共享中遇到的特殊需求)。因为使用了@FetchRequest,因此在复制过程中的 1-2 秒钟,UI 列表中会出现两个同样的数据记录,会给使用者带来困惑。

如果使用持久化方案,我可以为该数据创建一个用来表示显示与否的属性,例如 visible。通过在复制操作前后设置该属性并配置 Predicate 来解决列表重复问题。

但由于该场景的使用次数非常少(很多用户可能完全不会使用到),因此创建一个可持久性字段将非常浪费。

因此,我为该托管对象创建了一个名为 visible 的 Transient 属性,既避免了重复显示,同时又不会浪费存储空间。

其他关于 Transient 的注意事项

  • NSManagedObjectContext 的 refreshAllObjects 将重置 Transient 内容
  • 如果仅需要查看托管对象可持久性属性是否有改变可以使用 hasPersistentChangedValues
  • 不要在 NSPredicate 中使用 transient 属性作为限制条件
    @FetchRequest(entity: Test.entity(),
                  sortDescriptors: [NSSortDescriptor(keyPath: \Test.title, ascending: true)],
                  predicate: NSPredicate(format: "visible = true"))
    var tests: FetchedResults<Test>

上面代码的使用方式是错误的,如果想仅显示 visible == true 的数据,可以使用如下方式:

    @FetchRequest(entity: Test.entity(),
                  sortDescriptors: [NSSortDescriptor(keyPath: \Test.title, ascending: true)])

    ForEach(tests) { test in
         if test.visible {
             row(test: test)
         }
    }

总结

Core Data 作为一个历史悠久的框架,包含了不少非常有用但不被人熟知的功能。即使只是泛泛了解一下这些功能,不仅可以开阔思路,说不定在某个场合它就会成为解决问题的利器。

想阅读更多关于 Core Data 的文章,请查看我的 Core Data 专栏

希望本文能够对你有所帮助。同时也欢迎你通过 TwitterDiscord 频道或下方的留言板与我进行交流。

本博客文章采用CC 4.0 协议,转载需注明出处和作者。