关于 core-data
面向尚未迁移到 SwiftData 的应用开发者的 Core Data 实践技能,涵盖持久化、并发、批处理和迁移。内容还包括跨目标历史记录、界面数据驱动及测试要点。
主要功能
- 01Core Data 栈与并发管理
- 02NSFetchedResultsController 数据驱动
- 03批量插入、更新和删除
- 04持久化历史跟踪
- 05分阶段模型迁移
- 06内存存储测试
适用场景
- 01为应用搭建 Core Data 持久化
- 02在后台上下文导入或更新数据
- 03同步扩展与应用中的存储变更
- 04规划模型迁移并测试持久化逻辑
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill core-data安装后在 Claude Code 中输入/core-data确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
evals/evals.jsonreferences/persistent-history.mdstaged-migration.md详细介绍
Core Data
为尚未采用 SwiftData 的应用构建和维护 Core Data 持久化功能。涵盖堆栈设置、并发、批量操作、NSFetchedResultsController、持久化历史跟踪、分阶段迁移和测试。
目录
堆栈设置
NSPersistentContainer 封装了 Core Data 堆栈。
import CoreData
final class CoreDataStack: @unchecked Sendable {
static let shared = CoreDataStack()
let container: NSPersistentContainer
private init() {
container = NSPersistentContainer(name: "MyAppModel")
container.loadPersistentStores { _, error in
if let error { fatalError("Core Data store failed: \(error)") }
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
var viewContext: NSManagedObjectContext { container.viewContext }
func newBackgroundContext() -> NSManagedObjectContext {
container.newBackgroundContext()
}
}
如需与 CloudKit 同步,请改用 NSPersistentCloudKitContainer。
并发与线程
Core Data 上下文绑定到队列。viewContext 位于主队列;后台上下文则在私有队列上运行。
规则:
- 在上下文所属队列之外访问上下文时,始终使用
perform(_:)或performAndWait(_:)。 - 切勿跨上下文或线程边界传递
NSManagedObject实例。应改为传递NSManagedObjectID,然后重新获取对象。 - 在
viewContext上将automaticallyMergesChangesFromParent = true。
// Writing on a background context
func updateTrip(id: NSManagedObjectID, newName: String) async throws {
let context = CoreDataStack.shared.newBackgroundContext()
try await context.perform {
guard let trip = try context.existingObject(with: id) as? CDTrip else {
throw PersistenceError.notFound
}
trip.name = newName
try context.save()
}
}
Swift 并发集成
NSManagedObjectContext.perform(_:) 提供 async throws 重载(iOS 15+)。避免将 NSManagedObject 子类标记为 Sendable。
func importItems(_ records: [ItemRecord]) async throws {
let context = CoreDataStack.shared.newBackgroundContext()
try await context.perform {
for record in records {
let item = CDItem(context: context)
item.id = record.id
item.title = record.title
}
try context.save()
}
// After save completes, viewContext auto-merges if configured
}
不要在托管对象上使用 @unchecked Sendable。 如需跨边界通信,请传递 objectID(它符合 Sendable),然后重新获取对象:
let objectID = trip.objectID // Sendable
Task.detached {
let bgContext = CoreDataStack.shared.newBackgroundContext()
try await bgContext.perform {
let trip = try bgContext.existingObject(with: objectID) as! CDTrip
trip.isFavorite = true
try bgContext.save()
}
}
NSFetchedResultsController
通过 Core Data 获取请求高效驱动 UITableView / UICollectionView,并内置变更跟踪和可选缓存。
import CoreData
import UIKit
class TripsViewController: UITableViewController, NSFetchedResultsControllerDelegate {
private lazy var fetchedResultsController: NSFetchedResultsController<CDTrip> = {
let request: NSFetchRequest<CDTrip> = CDTrip.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \CDTrip.startDate, ascending: false)
]
request.fetchBatchSize = 20
let controller = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: CoreDataStack.shared.viewContext,
sectionNameKeyPath: nil,
cacheName: "TripsCache"
)
controller.delegate = self
return controller
}()
override func viewDidLoad() {
super.viewDidLoad()
try? fetchedResultsController.performFetch()
}
// MARK: - UITableViewDataSource
override func numberOfSections(in tableView: UITableView) -> Int {
fetchedResultsController.sections?.count ?? 0
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
fetchedResultsController.sections?[section].numberOfObjects ?? 0
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "TripCell", for: indexPath)
let trip = fetchedResultsController.object(at: indexPath)
cell.textLabel?.text = trip.name
return cell
}
// MARK: - NSFetchedResultsControllerDelegate (diffable)
func controller(
_ controller: NSFetchedResultsController<any NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference
) {
let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
dataSource.apply(snapshot, animatingDifferences: true)
}
}
要点:
- 获取请求必须至少包含一个排序描述符。
- 更改获取请求谓词或排序描述符之前,调用
deleteCache(withName:),或将cacheName设为nil。 - 可差异化快照的代理方法(
didChangeContentWith:)适用于 iOS 13+,且优先于较早的逐项变更回调。 - 上下文调用
reset()后,再次调用performFetch()。
批量操作
批量操作在 SQL 层执行,绕过托管对象上下文。它们速度很快,但不会自动触发上下文通知。
NSBatchInsertRequest(iOS 13+)
func batchImport(_ records: [[String: Any]]) async throws {
let context = CoreDataStack.shared.newBackgroundContext()
try await context.perform {
let request = NSBatchInsertRequest(
entity: CDTrip.entity(),
objects: records
)
request.resultType = .objectIDs
let result = try context.execute(request) as? NSBatchInsertResult
if let ids = result?.result as? [NSManagedObjectID] {
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: [NSInsertedObjectsKey: ids],
into: [CoreDataStack.shared.viewContext]
)
}
}
}
NSBatchDeleteRequest(iOS 9+)
func deleteOldTrips(before cutoff: Date) async throws {
let context = CoreDataStack.shared.newBackgroundContext()
try await context.perform {
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = CDTrip.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "endDate < %@", cutoff as NSDate)
let request = NSBatchDeleteRequest(fetchRequest: fetchRequest)
request.resultType = .resultTypeObjectIDs
let result = try context.execute(request) as? NSBatchDeleteResult
if let ids = result?.result as? [NSManagedObjectID] {
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: [NSDeletedObjectsKey: ids],
into: [CoreDataStack.shared.viewContext]
)
}
}
}
NSBatchUpdateRequest(iOS 8+)
func markAllTripsAsNotFavorite() async throws {
let context = CoreDataStack.shared.newBackgroundContext()
try await context.perform {
let request = NSBatchUpdateRequest(entity: CDTrip.entity())
request.propertiesToUpdate = ["isFavorite": false]
request.resultType = .updatedObjectIDsResultType
let result = try context.execute(request) as? NSBatchUpdateResult
if let ids = result?.result as? [NSManagedObjectID] {
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: [NSUpdatedObjectsKey: ids],
into: [CoreDataStack.shared.viewContext]
)
}
}
}
批量操作后,始终将变更合并回相关上下文。批量删除不会执行 Deny 删除规则。
对于破坏性或可重试的批量操作,请采用验证闭环:预先检查谓词和预期数量,使用对象 ID 结果类型执行操作,将 ID 合并到活动上下文中,重新获取对象,并断言后置条件。失败时,恢复到干净的测试夹具,或先证明操作具有幂等性再重试;切勿盲目重新运行只完成了一部分的批量操作。
持久化历史记录跟踪
跟踪应用、扩展、窗口小组件等目标以及进程之间的存储级更改。核心工作流如下:
文档:NSPersistentHistoryChangeRequest
- 在加载存储之前启用持久化历史记录和远程更改通知。
- 观察更改,并在目标的持久化令牌之后获取事务。
- 将事务通知合并到实时上下文中,然后持久化新令牌。
- 仅清除所有相关使用者都已处理的历史记录。
实现存储选项、观察器、令牌持久化、合并循环或清除策略时,加载 persistent-history.md。
分阶段迁移
NSStagedMigrationManager(iOS 17+)通过有序的轻量级或自定义阶段来执行架构迁移。阶段输入使用已编译的模型版本校验和,而非模型名称。支持 iOS 17 以下系统的应用需要采用轻量级迁移或映射模型路径。
构建有序阶段、模型引用、自定义处理程序和持久化存储选项时,加载 staged-migration.md。
复合属性
iOS 17+ 支持复合属性:实体上的子属性组,可作为单个逻辑单元。在模型编辑器中添加一个 Composite 类型的属性,并在其下嵌套子属性,即可定义复合属性。
文档:NSCompositeAttributeDescription
在与 SwiftData 共存的场景中,复合属性会映射到 Swift 中的 Codable 结构体。
SwiftData 边界
对于 Core Data 与 SwiftData 共存或迁移的实现,请使用 swiftdata 技能。交接前,请保留以下 Core Data 边界:
- 如果 SwiftData 需要共享或迁移 Core Data 数据,则必须指向现有的持久化存储 URL。
- 共享的持久化数据必须确保 Core Data 模型与 SwiftData
@Model类中的实体名称、属性名称、类型和架构兼容。 - 使用 SwiftData
@Attribute(originalName:)映射已重命名的持久化属性。
测试
用于测试的内存存储
import CoreData
import Testing
struct CoreDataTests {
func makeTestContainer() throws -> NSPersistentContainer {
let container = NSPersistentContainer(name: "MyAppModel")
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
var loadError: Error?
container.loadPersistentStores { _, error in loadError = error }
if let loadError { throw loadError }
return container
}
@Test func createAndFetchTrip() throws {
let container = try makeTestContainer()
let context = container.viewContext
let trip = CDTrip(context: context)
trip.name = "Test Trip"
trip.startDate = .now
try context.save()
let request: NSFetchRequest<CDTrip> = CDTrip.fetchRequest()
let trips = try context.fetch(request)
#expect(trips.count == 1)
#expect(trips.first?.name == "Test Trip")
}
}
提示:
- 在测试之间共享
NSManagedObjectModel实例,避免出现“重复实体”警告。 - 使用单个共享模型,并只加载一次:
private let sharedModel: NSManagedObjectModel = {
let url = Bundle.main.url(forResource: "MyAppModel", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
}()
func makeTestContainer() throws -> NSPersistentContainer {
let container = NSPersistentContainer(name: "MyAppModel",
managedObjectModel: sharedModel)
// ... configure in-memory store
}
常见错误
| 错误 | 修正方法 |
|---|---|
在线程间传递 NSManagedObject | 传递 objectID,并在目标上下文中重新获取 |
| 忘记合并批量操作结果 | 调用 mergeChanges(fromRemoteContextSave:into:) |
未检查 hasChanges 就调用 save() | 先使用 context.hasChanges 进行保护 |
使用已弃用的 init(concurrencyType:) 并发类型 | 使用 .privateQueueConcurrencyType 或 .mainQueueConcurrencyType |
未为 viewContext 设置 mergePolicy | 设置 NSMergeByPropertyObjectTrumpMergePolicy,避免冲突导致崩溃 |
在实时 NSFetchedResultsController 上修改获取请求,却未删除缓存 | 先调用 deleteCache(withName:),或使用 cacheName: nil |
| 批量删除忽略 Deny 删除规则 | 批量删除会绕过删除规则;请手动验证 |
将 NSManagedObject 标记为 @unchecked Sendable | 不要这样做。请改为传递 objectID |
| 共存期间让 SwiftData 指向一个全新的存储 | 当 SwiftData 需要共享或迁移 Core Data 数据时,请使用现有存储 URL 和兼容的架构 |
审查清单
-
NSPersistentContainer只初始化一次并共享 - 仅在主队列上使用
viewContext;写入时使用后台上下文 - 所有离队列的上下文访问都由
perform(_:)或performAndWait(_:)包裹 - 在
viewContext上设置automaticallyMergesChangesFromParent - 在
viewContext上设置mergePolicy,以避免冲突导致崩溃 - 将批量操作的结果合并到相关上下文中
-
NSFetchedResultsController的获取请求具有排序描述符 - 多目标应用启用持久化历史跟踪
- Core Data 与 SwiftData 的交接保留存储 URL、架构兼容性、实体和属性名称,以及重命名映射
- 测试使用共享
NSManagedObjectModel的内存存储 -
NSManagedObject实例不会跨线程边界传递