Core DataからSwiftDataへの移行:ステップバイステップガイド2026

iOSアプリをCore DataからSwiftDataへ移行するための完全ガイドです。実践的な例、共存戦略、ベストプラクティスを網羅しています。

iOS開発者向けCore DataからSwiftDataへの移行ガイド

SwiftDataはAppleプラットフォームにおけるデータ永続化の未来を象徴しています。WWDC 2023で発表されたこのフレームワークは、ネイティブなSwift構文とSwiftUIとのシームレスな統合を提供します。既存のCore DataアプリケーションにとってSwiftDataへの移行は、より現代的で保守しやすいコードへ向かう戦略的な一歩となります。

この記事の対象範囲

本ガイドではCore DataからSwiftDataへの移行プロセス全体を詳述します。互換性の評価、モデル変換、データ移行戦略、そして段階的な移行のための共存パターンを取り上げます。

移行の実現可能性の評価

移行を始める前に、潜在的な障害を特定するための入念な評価が重要となります。Core DataとSwiftDataは同じSQLite永続化エンジンを共有するため、データは完全に互換性があります。

MigrationAssessment.swiftswift
// Migration assessment checklist

/*
 FEATURES SUPPORTED BY SWIFTDATA:
 ✅ Simple models with basic properties
 ✅ One-to-one and one-to-many relationships
 ✅ Optional properties and default values
 ✅ Transformable attributes (via Codable)
 ✅ CloudKit synchronization (basic)
 ✅ Automatic lightweight migrations
 ✅ Class inheritance (iOS 26+)

 FEATURES REQUIRING ATTENTION:
 ⚠️ NSFetchedResultsController → @Query + manual observation
 ⚠️ NSCompoundPredicate → #Predicate with combined logic
 ⚠️ Dynamic predicates → Workarounds required

 UNSUPPORTED FEATURES:
 ❌ Advanced CloudKit Sharing
 ❌ Derived attributes
 ❌ Fetched properties
*/

// Example of typical Core Data model to migrate
import CoreData

// Existing Core Data entity
class CDTask: NSManagedObject {
    @NSManaged var id: UUID
    @NSManaged var title: String
    @NSManaged var isCompleted: Bool
    @NSManaged var createdAt: Date
    @NSManaged var priority: Int16
    @NSManaged var category: CDCategory?
}

class CDCategory: NSManagedObject {
    @NSManaged var id: UUID
    @NSManaged var name: String
    @NSManaged var color: String
    @NSManaged var tasks: NSSet?
}

データレベルの互換性は、移行後もユーザーが既存の情報を保持することを意味します。プロセスを正しく実行すれば、データの損失は発生しません。

Core DataモデルのSwiftDataへの変換

最初の具体的なステップは、Core DataのエンティティをSwiftDataのクラスに変換することです。Xcodeは自動ツールを提供していますが、手動プロセスを理解することは依然として重要です。

TaskModel.swiftswift
import SwiftData

// SwiftData equivalent of CDTask
@Model
final class Task {
    // Properties with default values
    var id: UUID = UUID()
    var title: String = ""
    var isCompleted: Bool = false
    var createdAt: Date = Date()
    var priority: Int = 0

    // Optional relationship to Category
    var category: Category?

    // Explicit initializer recommended
    init(
        id: UUID = UUID(),
        title: String,
        isCompleted: Bool = false,
        createdAt: Date = Date(),
        priority: Int = 0,
        category: Category? = nil
    ) {
        self.id = id
        self.title = title
        self.isCompleted = isCompleted
        self.createdAt = createdAt
        self.priority = priority
        self.category = category
    }
}

Core Dataとの主な違いには、NSManagedObjectの代わりに@Modelマクロを使用することや、Objective-Cの型ではなくSwiftネイティブの型を採用することが含まれます。

CategoryModel.swiftswift
import SwiftData

@Model
final class Category {
    var id: UUID = UUID()
    var name: String = ""
    var color: String = "blue"

    // Inverse relationship with delete rule
    @Relationship(deleteRule: .cascade, inverse: \Task.category)
    var tasks: [Task] = []

    init(id: UUID = UUID(), name: String, color: String = "blue") {
        self.id = id
        self.name = name
        self.color = color
    }
}
型のマッピング

Core Dataの型は直接変換されます。Int16Intに、NSSet[Model]に、そしてDateDateのままです。Transformable属性にはCodableの採用が必要です。

ModelContainerの構成

SwiftDataのModelContainerはCore DataのNSPersistentContainerを置き換えます。構成によってデータがどこにどのように保存されるかが決定されます。

ModelContainerSetup.swiftswift
import SwiftData
import SwiftUI

@main
struct TaskManagerApp: App {
    // SwiftData container configuration
    var sharedModelContainer: ModelContainer = {
        // Schema including all models
        let schema = Schema([
            Task.self,
            Category.self
        ])

        // Configuration with storage options
        let modelConfiguration = ModelConfiguration(
            schema: schema,
            isStoredInMemoryOnly: false,
            // Use the same store as Core Data
            url: URL.applicationSupportDirectory
                .appending(path: "TaskManager.sqlite")
        )

        do {
            return try ModelContainer(
                for: schema,
                configurations: [modelConfiguration]
            )
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

重要な点はストアのURLにあります。Core Dataと同じSQLiteファイルを使用することで、SwiftDataが既存のデータを読み取れるようになります。

Core DataとSwiftDataの共存戦略

複雑なアプリケーションの場合、両フレームワークの共存による段階的な移行が最も安全なアプローチとなります。両方のスタックが同じSQLiteファイルにアクセスできます。

CoexistenceSetup.swiftswift
import CoreData
import SwiftData

// Configuration for coexistence
class PersistenceController {
    static let shared = PersistenceController()

    // Shared store between Core Data and SwiftData
    private let storeURL: URL = {
        let appSupport = FileManager.default
            .urls(for: .applicationSupportDirectory, in: .userDomainMask)
            .first!
        return appSupport.appending(path: "TaskManager.sqlite")
    }()

    // MARK: - Core Data Stack (existing)

    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "TaskManager")

        // Configure to use shared store
        let description = NSPersistentStoreDescription(url: storeURL)
        description.setOption(
            true as NSNumber,
            forKey: NSPersistentHistoryTrackingKey
        )
        container.persistentStoreDescriptions = [description]

        container.loadPersistentStores { _, error in
            if let error = error as NSError? {
                fatalError("Core Data error: \(error)")
            }
        }

        return container
    }()

    // MARK: - SwiftData Stack (new)

    lazy var swiftDataContainer: ModelContainer = {
        let schema = Schema([Task.self, Category.self])

        let config = ModelConfiguration(
            schema: schema,
            url: storeURL,
            // Disable automatic migrations in coexistence
            allowsSave: true
        )

        do {
            return try ModelContainer(for: schema, configurations: [config])
        } catch {
            fatalError("SwiftData error: \(error)")
        }
    }()
}
変更の同期

共存モードでは、一方のフレームワークによる変更がもう一方にすぐには反映されません。明示的な再読み込みやアプリの再起動が必要となる場合があります。

クエリの移行:NSFetchRequestから@Queryへ

最も大きな違いはデータの取得方法に関わります。SwiftUIでは@FetchRequestの代わりにプロパティラッパー@Queryを使用します。

QueryMigration.swiftswift
import SwiftUI
import SwiftData

// ❌ Old pattern with Core Data
struct OldTaskListView: View {
    @FetchRequest(
        sortDescriptors: [
            NSSortDescriptor(keyPath: \CDTask.createdAt, ascending: false)
        ],
        predicate: NSPredicate(format: "isCompleted == NO")
    )
    private var tasks: FetchedResults<CDTask>

    var body: some View {
        List(tasks) { task in
            Text(task.title)
        }
    }
}

// ✅ New pattern with SwiftData
struct NewTaskListView: View {
    // @Query with built-in sorting and filtering
    @Query(
        filter: #Predicate<Task> { !$0.isCompleted },
        sort: \Task.createdAt,
        order: .reverse
    )
    private var tasks: [Task]

    var body: some View {
        List(tasks) { task in
            TaskRowView(task: task)
        }
    }
}

struct TaskRowView: View {
    let task: Task

    var body: some View {
        HStack {
            // Priority indicator
            Circle()
                .fill(priorityColor)
                .frame(width: 8, height: 8)

            VStack(alignment: .leading) {
                Text(task.title)
                    .font(.headline)

                if let category = task.category {
                    Text(category.name)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }

            Spacer()

            // Date badge
            Text(task.createdAt, style: .date)
                .font(.caption2)
                .foregroundStyle(.tertiary)
        }
    }

    private var priorityColor: Color {
        switch task.priority {
        case 3: return .red
        case 2: return .orange
        case 1: return .yellow
        default: return .gray
        }
    }
}

iOSの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

動的Predicateの取り扱い

SwiftDataにおける重要な課題のひとつは動的Predicateです。Predicateを動的に変更できるCore Dataとは異なり、@Queryでは代替的なアプローチが必要となります。

DynamicPredicates.swiftswift
import SwiftUI
import SwiftData

// Solution 1: Use @Query with custom init
struct FilteredTasksView: View {
    @Query private var tasks: [Task]

    // Create view with specific filter
    init(showCompleted: Bool, categoryId: UUID?) {
        // Build predicate based on parameters
        var predicates: [Predicate<Task>] = []

        if !showCompleted {
            predicates.append(#Predicate { !$0.isCompleted })
        }

        if let categoryId {
            predicates.append(#Predicate { task in
                task.category?.id == categoryId
            })
        }

        // Combine predicates
        let combinedPredicate: Predicate<Task>?
        if predicates.isEmpty {
            combinedPredicate = nil
        } else if predicates.count == 1 {
            combinedPredicate = predicates[0]
        } else {
            // Manually combine for AND logic
            combinedPredicate = #Predicate<Task> { task in
                !task.isCompleted && task.category?.id == categoryId
            }
        }

        _tasks = Query(
            filter: combinedPredicate,
            sort: \Task.createdAt,
            order: .reverse
        )
    }

    var body: some View {
        List(tasks) { task in
            TaskRowView(task: task)
        }
    }
}

// Solution 2: View-side filtering with all results
struct SmartTaskListView: View {
    // Fetch all tasks
    @Query(sort: \Task.createdAt, order: .reverse)
    private var allTasks: [Task]

    // Filter state
    @State private var searchText = ""
    @State private var showCompleted = false
    @State private var selectedCategory: Category?

    // Computed filtering
    private var filteredTasks: [Task] {
        allTasks.filter { task in
            // Text filter
            let matchesSearch = searchText.isEmpty ||
                task.title.localizedCaseInsensitiveContains(searchText)

            // Status filter
            let matchesStatus = showCompleted || !task.isCompleted

            // Category filter
            let matchesCategory = selectedCategory == nil ||
                task.category?.id == selectedCategory?.id

            return matchesSearch && matchesStatus && matchesCategory
        }
    }

    var body: some View {
        NavigationStack {
            List(filteredTasks) { task in
                TaskRowView(task: task)
            }
            .searchable(text: $searchText)
            .toolbar {
                FilterMenu(
                    showCompleted: $showCompleted,
                    selectedCategory: $selectedCategory
                )
            }
        }
    }
}

バージョン付きスキーマの移行

データモデルが進化する場合、SwiftDataでは複雑な移行を管理するためにVersionedSchemaを使用します。

VersionedSchemas.swiftswift
import SwiftData

// Version 1: Initial schema
enum TaskSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Task.self, Category.self]
    }

    @Model
    final class Task {
        var id: UUID = UUID()
        var title: String = ""
        var isCompleted: Bool = false
        var createdAt: Date = Date()
        var category: Category?

        init(title: String) {
            self.title = title
        }
    }

    @Model
    final class Category {
        var id: UUID = UUID()
        var name: String = ""

        @Relationship(deleteRule: .cascade, inverse: \Task.category)
        var tasks: [Task] = []

        init(name: String) {
            self.name = name
        }
    }
}

// Version 2: Added priority and notes fields
enum TaskSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Task.self, Category.self]
    }

    @Model
    final class Task {
        var id: UUID = UUID()
        var title: String = ""
        var isCompleted: Bool = false
        var createdAt: Date = Date()
        // New properties with default values
        var priority: Int = 0
        var notes: String = ""
        var category: Category?

        init(title: String, priority: Int = 0) {
            self.title = title
            self.priority = priority
        }
    }

    @Model
    final class Category {
        var id: UUID = UUID()
        var name: String = ""
        // New property
        var color: String = "blue"

        @Relationship(deleteRule: .cascade, inverse: \Task.category)
        var tasks: [Task] = []

        init(name: String, color: String = "blue") {
            self.name = name
            self.color = color
        }
    }
}

移行プランはバージョンの順序と必要に応じてカスタム移行を定義します。

MigrationPlan.swiftswift
import SwiftData

enum TaskMigrationPlan: SchemaMigrationPlan {
    // Chronological order of schemas
    static var schemas: [any VersionedSchema.Type] {
        [TaskSchemaV1.self, TaskSchemaV2.self]
    }

    // Migration stages
    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    // V1 → V2 migration: lightweight (properties with defaults)
    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: TaskSchemaV1.self,
        toVersion: TaskSchemaV2.self
    )
}

// Container configuration with migration
@main
struct TaskManagerApp: App {
    var sharedModelContainer: ModelContainer = {
        do {
            return try ModelContainer(
                for: TaskSchemaV2.Task.self, TaskSchemaV2.Category.self,
                migrationPlan: TaskMigrationPlan.self
            )
        } catch {
            fatalError("Migration failed: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}
軽量移行とカスタム移行

SwiftDataは軽量移行を自動的に処理します(デフォルト値付きのプロパティ追加、リネーム、削除)。データ変換を伴う複雑な移行にはMigrationStage.customを使用します。

NSFetchedResultsControllerの置き換え

セクション付きのリストや変更の細かい監視には、データ抽出と組み合わせた@QueryNSFetchedResultsControllerを置き換えます。

SectionedResults.swiftswift
import SwiftUI
import SwiftData

struct SectionedTaskListView: View {
    @Query(sort: \Task.createdAt, order: .reverse)
    private var tasks: [Task]

    // Grouping by category
    private var tasksByCategory: [(Category?, [Task])] {
        Dictionary(grouping: tasks) { $0.category }
            .map { ($0.key, $0.value) }
            .sorted { first, second in
                // Tasks without category last
                guard let firstName = first.0?.name else { return false }
                guard let secondName = second.0?.name else { return true }
                return firstName < secondName
            }
    }

    var body: some View {
        List {
            ForEach(tasksByCategory, id: \.0?.id) { category, categoryTasks in
                Section(header: SectionHeader(category: category)) {
                    ForEach(categoryTasks) { task in
                        TaskRowView(task: task)
                    }
                }
            }
        }
    }
}

struct SectionHeader: View {
    let category: Category?

    var body: some View {
        HStack {
            if let category {
                Circle()
                    .fill(Color(category.color))
                    .frame(width: 12, height: 12)
                Text(category.name)
            } else {
                Text("Uncategorized")
                    .foregroundStyle(.secondary)
            }
        }
    }
}

// Alternative: Grouping by date
struct DateGroupedTasksView: View {
    @Query(sort: \Task.createdAt, order: .reverse)
    private var tasks: [Task]

    private var tasksByDate: [(Date, [Task])] {
        let calendar = Calendar.current

        let grouped = Dictionary(grouping: tasks) { task in
            calendar.startOfDay(for: task.createdAt)
        }

        return grouped
            .map { ($0.key, $0.value) }
            .sorted { $0.0 > $1.0 }
    }

    var body: some View {
        List {
            ForEach(tasksByDate, id: \.0) { date, dateTasks in
                Section(header: Text(date, style: .date)) {
                    ForEach(dateTasks) { task in
                        TaskRowView(task: task)
                    }
                }
            }
        }
    }
}

ModelContextを用いたCRUD操作

ModelContextはすべての作成、読み取り、更新、削除操作においてNSManagedObjectContextを置き換えます。

CRUDOperations.swiftswift
import SwiftUI
import SwiftData

struct TaskManagementView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var tasks: [Task]
    @Query private var categories: [Category]

    @State private var newTaskTitle = ""
    @State private var selectedCategory: Category?

    var body: some View {
        NavigationStack {
            VStack {
                // Add form
                AddTaskForm(
                    title: $newTaskTitle,
                    category: $selectedCategory,
                    categories: categories,
                    onAdd: addTask
                )

                // Task list
                List {
                    ForEach(tasks) { task in
                        TaskRowView(task: task)
                            .swipeActions(edge: .trailing) {
                                Button(role: .destructive) {
                                    deleteTask(task)
                                } label: {
                                    Label("Delete", systemImage: "trash")
                                }
                            }
                            .swipeActions(edge: .leading) {
                                Button {
                                    toggleCompletion(task)
                                } label: {
                                    Label(
                                        task.isCompleted ? "Todo" : "Done",
                                        systemImage: task.isCompleted ? "circle" : "checkmark"
                                    )
                                }
                                .tint(task.isCompleted ? .orange : .green)
                            }
                    }
                }
            }
            .navigationTitle("Tasks")
        }
    }

    // CREATE
    private func addTask() {
        guard !newTaskTitle.isEmpty else { return }

        let task = Task(
            title: newTaskTitle,
            category: selectedCategory
        )

        // Insert into context
        modelContext.insert(task)

        // Explicit save (optional - autosave enabled by default)
        do {
            try modelContext.save()
        } catch {
            print("Save error: \(error)")
        }

        // Reset form
        newTaskTitle = ""
        selectedCategory = nil
    }

    // UPDATE
    private func toggleCompletion(_ task: Task) {
        // Direct modification - SwiftData tracks automatically
        task.isCompleted.toggle()

        // Automatic save handles persistence
    }

    // DELETE
    private func deleteTask(_ task: Task) {
        modelContext.delete(task)
    }
}

struct AddTaskForm: View {
    @Binding var title: String
    @Binding var category: Category?
    let categories: [Category]
    let onAdd: () -> Void

    var body: some View {
        VStack(spacing: 12) {
            TextField("New task...", text: $title)
                .textFieldStyle(.roundedBorder)

            HStack {
                Picker("Category", selection: $category) {
                    Text("None").tag(nil as Category?)
                    ForEach(categories) { cat in
                        Text(cat.name).tag(cat as Category?)
                    }
                }
                .pickerStyle(.menu)

                Button("Add", action: onAdd)
                    .buttonStyle(.borderedProminent)
                    .disabled(title.isEmpty)
            }
        }
        .padding()
    }
}

SwiftDataによるユニットテスト

堅牢なテスト戦略は移行の検証を容易にします。SwiftDataではテスト用にインメモリのコンテナを作成できます。

SwiftDataTests.swiftswift
import XCTest
import SwiftData
@testable import TaskManager

final class TaskModelTests: XCTestCase {
    var container: ModelContainer!
    var context: ModelContext!

    override func setUpWithError() throws {
        // In-memory container for tests
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        container = try ModelContainer(
            for: Task.self, Category.self,
            configurations: config
        )
        context = ModelContext(container)
    }

    override func tearDownWithError() throws {
        container = nil
        context = nil
    }

    func testCreateTask() throws {
        // Given
        let task = Task(title: "Test Task")

        // When
        context.insert(task)
        try context.save()

        // Then
        let descriptor = FetchDescriptor<Task>()
        let tasks = try context.fetch(descriptor)

        XCTAssertEqual(tasks.count, 1)
        XCTAssertEqual(tasks.first?.title, "Test Task")
    }

    func testTaskCategoryRelationship() throws {
        // Given
        let category = Category(name: "Work", color: "blue")
        let task = Task(title: "Meeting", category: category)

        // When
        context.insert(category)
        context.insert(task)
        try context.save()

        // Then
        XCTAssertEqual(task.category?.name, "Work")
        XCTAssertTrue(category.tasks.contains(task))
    }

    func testDeleteCategoryCascade() throws {
        // Given
        let category = Category(name: "Personal")
        let task1 = Task(title: "Task 1", category: category)
        let task2 = Task(title: "Task 2", category: category)

        context.insert(category)
        context.insert(task1)
        context.insert(task2)
        try context.save()

        // When
        context.delete(category)
        try context.save()

        // Then - cascade delete should remove tasks
        let descriptor = FetchDescriptor<Task>()
        let remainingTasks = try context.fetch(descriptor)

        XCTAssertEqual(remainingTasks.count, 0)
    }

    func testFilteredFetch() throws {
        // Given
        let task1 = Task(title: "Completed", isCompleted: true)
        let task2 = Task(title: "Pending", isCompleted: false)
        let task3 = Task(title: "Also Pending", isCompleted: false)

        [task1, task2, task3].forEach { context.insert($0) }
        try context.save()

        // When
        var descriptor = FetchDescriptor<Task>(
            predicate: #Predicate { !$0.isCompleted }
        )
        let pendingTasks = try context.fetch(descriptor)

        // Then
        XCTAssertEqual(pendingTasks.count, 2)
    }
}

移行の完全チェックリスト

以下は移行を成功させるための手順の概要です。

MigrationChecklist.swiftswift
/*
 PHASE 1: PREPARATION
 □ Audit Core Data features in use
 □ Identify features not supported by SwiftData
 □ Create dedicated migration branch
 □ Back up test data

 PHASE 2: MODEL CONVERSION
 □ Convert NSManagedObject entities to @Model
 □ Adapt relationships with @Relationship
 □ Configure appropriate delete rules
 □ Add required default values

 PHASE 3: CONFIGURATION
 □ Create ModelContainer with existing store URL
 □ Configure versioned schema if needed
 □ Define migration plan
 □ Test in coexistence mode if applicable

 PHASE 4: CODE MIGRATION
 □ Replace @FetchRequest with @Query
 □ Adapt predicates to #Predicate
 □ Migrate NSFetchedResultsController to manual grouping
 □ Convert CRUD operations to ModelContext

 PHASE 5: VALIDATION
 □ Run all unit tests
 □ Test migration with real data
 □ Verify performance with Instruments
 □ Validate CloudKit sync (if applicable)

 PHASE 6: DEPLOYMENT
 □ Document breaking changes
 □ Prepare rollback plan
 □ Deploy to TestFlight
 □ Monitor post-deployment crashes
*/

まとめ

Core DataからSwiftDataへの移行は、現代的なiOSアプリケーションにとって自然な進化を意味します。SQLiteストアレベルの互換性によりユーザーデータの保存が保証され、ネイティブなSwift構文によりコードが大幅に簡潔になります。

重要なポイント

  • ✅ SwiftDataとCore Dataは同じSQLiteエンジンを共有する
  • ✅ 共存により段階的な移行が可能となる
  • @Queryはよりシンプルな構文で@FetchRequestを置き換える
  • ✅ 動的Predicateには代替パターンが必要となる
  • VersionedSchemaはスキーマの進化を管理する
  • ✅ インメモリテストが検証を容易にする
  • ✅ iOS 26はクラス継承のサポートをもたらす
  • ✅ Core Dataの特定のニーズがない限り、新規プロジェクトはSwiftDataで開始することが望ましい

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#swiftdata
#core-data
#ios
#migration
#swift

共有

関連記事