core-data について
SwiftData をまだ採用していないアプリを保守する開発者向けの Core Data スキルです。永続化、並行処理、一括操作、マイグレーションに加え、複数ターゲット間の履歴追跡とテストを扱います。
主な機能
- 01Core Data スタックと並行処理
- 02NSFetchedResultsController によるデータ表示
- 03一括挿入、更新、削除
- 04永続履歴の追跡
- 05段階的なモデルマイグレーション
- 06インメモリストアでのテスト
活用シーン
- 01Core 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、永続履歴の追跡、段階的なマイグレーション、テストについて説明します。
目次
- スタックの設定
- 並行処理とスレッド
- NSFetchedResultsController
- バッチ操作
- 永続履歴の追跡
- 段階的なマイグレーション
- 複合属性
- SwiftData との境界
- テスト
- よくある間違い
- レビュー用チェックリスト
- 参考資料
スタックの設定
NSPersistentContainer は Core Data スタックをカプセル化します。
ドキュメント:NSPersistentContainer
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 はメインキュー上にあり、バックグラウンドコンテキストはプライベートキュー上で動作します。
ドキュメント:NSManagedObjectContext
ルール:
- コンテキスト自身のキュー以外からアクセスする場合は、必ず
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 Concurrency との統合
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 を効率的に更新します。変更追跡機能が組み込まれており、キャッシュも任意で使用できます。
ドキュメント:NSFetchedResultsController
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)
}
}
重要なポイント:
- フェッチリクエストには、少なくとも 1 つのソート記述子を必ず設定してください。
- フェッチリクエストの述語またはソート記述子を変更する前に
deleteCache(withName:)を呼び出すか、cacheNameをnilに設定してください。 - 差分可能なスナップショットのデリゲートメソッド(
didChangeContentWith:)は iOS 13 以降で使用でき、以前の変更ごとのコールバックよりも推奨されます。 - コンテキストで
reset()を呼び出した後は、再度performFetch()を呼び出してください。
バッチ操作
バッチ操作は SQL レベルで実行され、管理オブジェクトコンテキストを経由しません。高速ですが、コンテキスト通知は自動的に発生しません。
NSBatchInsertRequest(iOS 13 以降)
ドキュメント:NSBatchInsertRequest
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 以降)
ドキュメント:NSBatchDeleteRequest
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 より前のシステムに対応するアプリでは、軽量マイグレーションまたはマッピングモデルの方法が必要です。
ドキュメント:NSStagedMigrationManager
順序付けされたステージ、モデル参照、カスタムハンドラー、永続ストアオプションを構築する際は、staged-migration.md を読み込んでください。
複合属性
iOS 17 以降では複合属性がサポートされています。複合属性とは、エンティティ上のサブ属性のグループで、単一の論理単位として機能します。モデルエディターで Composite 型の属性を追加し、その下にサブ属性をネストして定義します。
ドキュメント:NSCompositeAttributeDescription
SwiftData との共存シナリオでは、複合属性は Swift の Codable 構造体に対応します。
SwiftData との境界
Core Data と SwiftData の共存、またはマイグレーションの実装には、swiftdata スキルを使用してください。引き継ぐ前に、以下の Core Data との境界を維持してください。
- Core Data データを共有またはマイグレーションする場合、SwiftData は既存の永続ストア 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")
}
}
ヒント:
- 「duplicate entity」警告を避けるため、テスト間で
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:) の confinement 型を使用する | .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インスタンスをスレッド間で受け渡しません