Core Data 的一个卓越特点是让开发者能够以更加接近面向对象编程的方式声明数据模型,同时无需关心底层的存储实现细节。在这个框架中,模型继承是一个尤为重要的机制。本文将深入探讨模型继承的核心概念,包括父实体(Parent Entity)、子实体(Sub Entity)和抽象实体(Abstract Entity);我们将分析它们的优缺点,并探讨在不直接使用这些功能时如何实现类似的效果。
Parent Entity 与 Sub Entity
在使用 Xcode 模型编辑器构建模型时,开发者会注意到 Entity 有一个 Parent Entity
选项。虽然大多数情况下我们都保持 No Parent Entity
状态,但通过下拉菜单,可以选择其他实体作为父实体。
将 Publication
实体设置为 Book
实体的父实体后,我们就完成了两者的继承关系声明:Publication
成为 Book
的 Parent Entity,而 Book
则是 Publication
的 Sub Entity。
查看 Xcode 自动生成的模型代码,可以看到:
@objc(Book)
public class Book: Publication {}
在这段声明中,Book
作为 Publication
的子类存在。与标准的 Swift 类继承类似,Book
自动继承了 Publication
实体中已声明的属性。
模型继承的作用
设想这样一个场景:我们需要存储各种类型的出版物(如书籍(Book)、学术论文(AcademicPaper)、网络文章 (WebArticle)、博客文章(BlogPost)等),这些出版物具有许多共同特征,并且我们经常需要进行跨类型的查询。
如果将不同类型的出版物定义为独立的实体,且没有一个共同的父实体,那么实现诸如查找特定标签的出版物、统计作者的出版物数量或根据关键字搜索出版物等需求将变得异常复杂。然而,采用模型继承后,这些需求的实现将变得非常简便。
上面的图像是通过 CoreData Model Editor 生成的。由于 Xcode 已经取消了基于关系图的建模方式,CoreData Model Editor 成为一个非常不错的补充工具。
首先,我们声明一个 Publication
实体,设置不同类型出版物共有的属性和关系。在这个模型中,Publication
包含 publishDate
和 title
两个属性,并与 Tag
实体建立了多对多的关系。
接下来,我们声明 Book
和 AcademicPaper
实体,分别添加各自独特的属性,并将 Publication
设为它们的父实体。尽管在 Book
中没有直接定义 publishDate
和 title
属性,但这些属性会被自动继承。更重要的是,与 Tag
的多对多关系也会被继承。
模型继承与基于协议的最大不同点在于:模型继承不仅包含属性声明,还涵盖了 Core Data 模型特有的其他信息,如逆关系定义、删除规则、验证规则、索引、预定义的 Fetch Request 以及实体间的约束等。
完成上述模型设置后,我们可以在项目中分别使用 Publication
、Book
、AcademicPaper
作为独立的模型类型。然而,由于它们之间存在继承关系,当需要进行跨出版物类型的检索时,可以直接针对 Publication
进行操作。
在以下代码示例中,我们分别创建了 Publication
、Book
、AcademicPaper
的实例,所有数据都会统一出现在针对 Publication
的检索结果中。在用于显示数据的 PublicationView
中,则根据具体类型展示各自独有的属性。
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
// 检索所有的 Publication
@FetchRequest(entity: Publication.entity(), sortDescriptors: [.init(key: "publishDate", ascending: false)])
var Publications: FetchedResults<Publication>
var body: some View {
List {
Button("New publication") {
let publication = Publication(context: viewContext)
publication.title = "\(Int.random(in: 0 ... 100))"
publication.publishDate = .now
try? viewContext.save()
}
Button("New book") {
let book = Book(context: viewContext)
book.title = "\(Int.random(in: 0 ... 100))"
book.publishDate = .now
book.isbn = "\(Int.random(in: 3000...4000))-\(Int.random(in: 6000...8000))"
try? viewContext.save()
}
Button("New Paper") {
let paper = AcademicPaper(context: viewContext)
paper.title = "\(Int.random(in: 0 ... 100))"
paper.publishDate = .now
paper.paperType = Int16.random(in: 0 ..< 100)
try? viewContext.save()
}
ForEach(Publications) { publication in
PublicationView(publication: publication)
}
}
}
}
struct PublicationView: View {
@ObservedObject var publication: Publication
var body: some View {
// 根据类型展示不同内容
switch publication {
case is Book:
if let book = publication as? Book, let isbn = book.isbn {
Text("Base:\(isbn)").foregroundStyle(.red)
}
case is AcademicPaper:
if let paper = publication as? AcademicPaper {
Text("Publication:\(paper.paperType)").foregroundStyle(.blue)
}
default:
Text(publication.publishDate!, format: .dateTime).foregroundStyle(.green)
}
}
}
由此可见,模型继承至少具有以下优势:
- 简化模型声明:子实体自动继承父实体的属性、关系和配置,减少了重复定义的工作。
- 提供统一的数据抽象:通过共享父实体,支持跨不同子实体的聚合查询和整体性检索。
- 增强关系建模的灵活性:允许在不同子类型实体之间建立更具语义化和结构化的关联。
模型继承的实现原理
那么,Core Data 是如何在 SQLite 中实现模型继承的呢?其实并不复杂。如果我们查看项目对应的 SQLite 数据库,会发现并没有单独的表与 Book
和 AcademicPaper
对应。所有与 Publication
相关的数据都保存在同一个表中。该表包含了 Publication
以及其所有子实体和孙实体的属性。
在进行数据检索时,Core Data 首先会查询 Z_PRIMARYKEY
表中与实体相关的声明(对应的 Z_ENT
值),然后根据检索的实体类型设置适当的检索条件。
例如,如果我们仅检索 Book
类型的数据,Core Data 会在相应的 SQL 语句中添加一个 Z_ENT = 3
的条件。而如果检索 Publication
,则无需添加此限定条件。Core Data 通过这种巧妙的方式,实现了底层存储与模型描述之间的抽象分离,并轻松支持了多层继承结构。
这种实现方式带来了以下几个优势:
- 简化查询逻辑:通过在单一表中存储所有继承实体的数据,减少了跨表查询的复杂性。
- 高效的数据管理:统一的存储结构使得数据的插入、更新和删除操作更加高效。
- 灵活的继承支持:支持多层继承,使得模型设计更加灵活和可扩展。
然而,这种实现方式也有其局限性,例如在处理大量不同子实体的数据时,可能会导致表结构复杂化和性能瓶颈。因此,在设计 Core Data 模型时,需要权衡继承带来的便利性与潜在的性能影响,选择最适合项目需求的方案。
Abstract Entity
在模型编辑器中,实体还有另一个选项:Abstract Entity(抽象实体)。
苹果官方文档中对其的描述如下:
Specify that an entity is abstract if you will not create any instances of that entity. You typically make an entity abstract if you have a number of entities that all represent specializations of (inherit from) a common entity that should not itself be instantiated. For example, in the Employee entity you could define Person as an abstract entity and specify that only concrete subentities (Employee and Customer) can be instantiated. By marking an entity as abstract in the Entity pane of the Data Model inspector, you are informing Core Data that it will never be instantiated directly.
指定一个实体为抽象实体,意味着你不会创建该实体的任何实例。当你有多个实体都是某个公共实体的特殊化(继承自)表现,而该公共实体本身不应被实例化时,通常会将其设为抽象实体。例如,在 Employee 实体中,你可以将 Person 定义为抽象实体,并指定只有具体的子实体(如 Employee 和 Customer)可以被实例化。通过在数据模型检查器的实体面板中将实体标记为抽象,你是在告知 Core Data 该实体永远不会被直接实例化。
根据官方文档,标注为抽象的实体更像是一种特殊的基类。它可以被继承,但不能被实例化。按照理想情况,在前文的例子中,如果将 Publication
标注为 Abstract Entity
,则直接创建其实例是不被允许的。然而,至少在 Core Data 的 Swift 包装实现中,这一规则并未被严格遵守。在实践中,大多数情况下,即使将一个实体设置为抽象实体,依然可以创建其实例。
不过,我确实遇到过创建 Abstract Entity 实例导致应用崩溃的情况。因此,如果你将某个实体标注为
Abstract
,最好按照文档要求,不编写实例化该实体的代码。
当一个抽象实体包含子实体且本身没有父实体时,数据库中的表名仍会使用该实体的名称命名。这一点与未标注为抽象实体的情况相同。
模型继承的局限
尽管模型继承带来了诸多优点,但其底层存储机制也带来了一定的局限性,主要体现在数据的冗余和潜在的性能下降。具体来说,数据浪费的程度取决于每个子实体所独有的属性数量。因此,许多开发者不推荐在数据量较大的情况下使用模型继承,认为这会导致存储空间的浪费并影响应用性能。
任何工具都有其适用的场景,模型继承亦是如此。在不了解模型继承底层存储机制的情况下,一些开发者可能会尝试将不同实体中的通用属性(例如创建时间、UUID)抽象出来,作为父实体,尤其是将其声明为抽象实体。这样一来,所有子实体的数据都会存储在同一张表中。如果这些子实体本身并没有太多其他共性属性,这种设计必然会导致显著的性能损失。
因此,大多数情况下,问题的根源在于不恰当地使用了模型继承。
正如模型继承的优势所示,在以下场景中使用模型继承能够获得良好的效果:
- 父实体包含大量通用属性和关系:当父实体中有许多共享的属性和关系配置时,模型继承可以有效减少重复定义。
- 子实体之间具有高度的共同性:拥有共同父实体的子实体在属性和行为上有较大的相似性,使得继承关系更加合理。
- 需要对不同实体数据进行聚合查询:通过共享父实体,可以方便地对不同子实体的数据进行统一的查询和处理。
以苹果的通讯录应用为例,可以看到苹果在其数据模型中深入使用了模型继承。在下图中,展示了以一个抽象实体 ABCDRecord
为根,构建了复杂的继承关系。
考虑到通讯录中有限的数据量以及上述条件,这种模型继承的设计为代码编写带来了显著的优势,如简化数据管理和增强查询效率。
上面的图片展示了通过 Core Data Lab 查看数据库文件的结果。与直接使用 SQLite 客户端相比,Core Data Lab 能够呈现更多 Core Data 独有的模型细节,提供更加直观的数据库视图。
条条大路通罗马
尽管模型继承具有诸多优势,但如果今天问我是否应该使用这一功能,我会建议慎重考虑。
主要原因在于,模型继承功能无法被 SwiftData 所支持。一旦采用了模型继承,数据库将无法迁移到 SwiftData 框架中。
然而,如果你能够接受与模型继承相似的存储效率牺牲,我们依然可以手动实现类似的效果。
与模型继承的实现方式相似,我们可以将具有共通性的不同类型存储在同一个表中(即声明为一个实体),并通过特定的属性或关系来加以区分。
需要注意的是,在这种情况下,开发者最好手动编写模型代码,以实现类似模型继承中子实体不包含其他子实体属性的效果。当然,这也意味着模型声明的代码将更加复杂。
例如,在我的项目中,我构建了如下代码,其中 ItemData
实体对应七种不同的类型。为了更加安全和便捷地创建和展示不同类型的数据,我将模型中特化属性的呈现方式改为了枚举:
// 定义 ItemData 实体中不同数据类型的特化内容,通过枚举区分各类属性
public enum ItemDataContent: Equatable, Sendable {
case singleValue(eventDate: Date, value: Double)
case singleOption(eventDate: Date, optionID: UUID)
case valueWithOption(eventDate: Date, value: Double, optionID: UUID)
case dualValues(eventDate: Date, pairValue: ValidatedPairDouble)
case valueWithInterval(pairDate: ValidatedPairDate, value: Double)
case optionWithInterval(pairDate: ValidatedPairDate, optionID: UUID)
case interval(pairDate: ValidatedPairDate)
}
通过这种方式,我们只需在公开的属性中呈现这个枚举,并根据枚举构建初始化方法:
@objc(ItemData)
public final class ItemData: NSManagedObject {}
// MARK: - 公开属性
extension ItemData: ItemDataBaseProtocol {
@NSManaged public var createTimestamp: Date
@NSManaged public var uid: UUID
@NSManaged public var item: Item?
@NSManaged public var memo: Memo?
@NSManaged public var deletionLog: DeletionLog?
// 不同类型的特化属性
public var dataContent: ItemDataContent? {
get { dataContentGetter(type: type) }
set { dateContentSetter(content: newValue) }
}
}
extension ItemData {
public convenience init(
createTimestamp: Date,
uid: UUID,
dataContent: ItemDataContent
) {
self.init(entity: Self.entity(), insertInto: nil)
self.createTimestamp = createTimestamp
self.uid = uid
self.dataContent = dataContent
}
}
当然,为了实现这个简洁易用的公开 API,我们还需要进行大量的内部转换(完整代码较长,此处不再展示)。其中部分转换如果采用模型继承,Core Data 会自动完成。
总之,目前我更推荐开发者手动实现类似模型继承的效果。一方面,这样可以更好地兼容 SwiftData;另一方面,也能使模型声明更符合 Swift 的编程风格,增强代码的可控性。
总结
无论是否选择使用模型继承,开发者都应充分了解其优缺点。在合适的场景下,模型继承能够显著简化数据模型的设计、提高代码的可维护性,并支持复杂的查询需求。