swiftdata-coredata-persistence

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SwiftData & CoreData Persistence

SwiftData & CoreData 持久化方案

Overview

概述

SwiftData and CoreData are NOT interchangeable. Each has specific strengths, migration traps, and performance patterns. Wrong framework choice or migration strategy causes production crashes.
Core principle: Choose framework based on app constraints, version models from day one, never store BLOBs in the database.
SwiftData与CoreData不能互换。两者各有特定优势、迁移陷阱和性能模式。错误的框架选择或迁移策略会导致生产环境崩溃。
核心原则:根据应用约束选择框架,从项目初期就对模型进行版本控制,绝不在数据库中存储BLOB数据。

Framework Selection

框架选择

dot
digraph framework_choice {
    "New iOS app?" [shape=diamond];
    "iOS 17+ only?" [shape=diamond];
    "Simple data model?" [shape=diamond];
    "iCloud sync?" [shape=diamond];
    "Existing CoreData?" [shape=diamond];
    "Use SwiftData" [shape=box, style=filled, fillcolor=lightgreen];
    "Use CoreData" [shape=box, style=filled, fillcolor=lightblue];
    "Stay with CoreData" [shape=box, style=filled, fillcolor=lightblue];

    "New iOS app?" -> "iOS 17+ only?" [label="yes"];
    "New iOS app?" -> "Existing CoreData?" [label="no"];
    "Existing CoreData?" -> "Stay with CoreData" [label="yes"];
    "Existing CoreData?" -> "iOS 17+ only?" [label="no"];
    "iOS 17+ only?" -> "Simple data model?" [label="yes"];
    "iOS 17+ only?" -> "Use CoreData" [label="no"];
    "Simple data model?" -> "iCloud sync?" [label="yes"];
    "Simple data model?" -> "Use CoreData" [label="no"];
    "iCloud sync?" -> "Use SwiftData" [label="yes"];
    "iCloud sync?" -> "Use SwiftData" [label="no"];
}
dot
digraph framework_choice {
    "New iOS app?" [shape=diamond];
    "iOS 17+ only?" [shape=diamond];
    "Simple data model?" [shape=diamond];
    "iCloud sync?" [shape=diamond];
    "Existing CoreData?" [shape=diamond];
    "Use SwiftData" [shape=box, style=filled, fillcolor=lightgreen];
    "Use CoreData" [shape=box, style=filled, fillcolor=lightblue];
    "Stay with CoreData" [shape=box, style=filled, fillcolor=lightblue];

    "New iOS app?" -> "iOS 17+ only?" [label="yes"];
    "New iOS app?" -> "Existing CoreData?" [label="no"];
    "Existing CoreData?" -> "Stay with CoreData" [label="yes"];
    "Existing CoreData?" -> "iOS 17+ only?" [label="no"];
    "iOS 17+ only?" -> "Simple data model?" [label="yes"];
    "iOS 17+ only?" -> "Use CoreData" [label="no"];
    "Simple data model?" -> "iCloud sync?" [label="yes"];
    "Simple data model?" -> "Use CoreData" [label="no"];
    "iCloud sync?" -> "Use SwiftData" [label="yes"];
    "iCloud sync?" -> "Use SwiftData" [label="no"];
}

Choose SwiftData When

选择SwiftData的场景

  • New app, iOS 17+ deployment target
  • Simple relationships (mostly 1:1 or 1:N, few complex queries)
  • SwiftUI-first architecture
  • iCloud sync needed with minimal configuration
  • 全新iOS应用,部署目标为iOS 17及以上
  • 数据模型简单(多为1:1或1:N关系,复杂查询较少)
  • SwiftUI优先的架构
  • 需要iCloud同步且希望配置尽可能简洁

Choose CoreData When

选择CoreData的场景

  • iOS 16 or earlier support required
  • Complex queries with NSCompoundPredicate, subqueries
  • UIKit-heavy app with NSFetchedResultsController
  • Existing CoreData codebase (migration complex, stay with CoreData)
  • Performance critical (as of 2025, CoreData still faster)
  • Abstract entities or inheritance hierarchies
  • 需要支持iOS 16或更早版本
  • 涉及复杂查询,需使用NSCompoundPredicate、子查询
  • UIKit为主的应用,需使用NSFetchedResultsController
  • 已有CoreData代码库(迁移复杂度高,建议继续使用CoreData)
  • 性能要求极高(截至2025年,CoreData仍更快)
  • 需要抽象实体或继承层级

Migration Death Traps

迁移致命陷阱

Trap 1: Unversioned → Versioned with iCloud

陷阱1:未版本化 → 带iCloud的版本化

CRITICAL: If you shipped unversioned SwiftData with iCloud sync, users will crash on first migration.
Error: "Cannot use staged migration with an unknown model version"
Why: CloudKit requires versioned schemas for sync consistency.
Solution:
swift
// ❌ WRONG: Adding versioning after shipping unversioned + iCloud
// This will crash for all existing users

// ✅ CORRECT: Two-phase migration
// Version 1.1: Add versioning WITHOUT schema changes
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] { [User.self] }

    @Model
    final class User {
        var name: String
        var email: String
        // EXACT same schema as shipped v1.0
    }
}

// Version 1.2: Now safe to add new properties
enum SchemaV2: VersionedSchema {
    static var models: [any PersistentModel.Type] { [User.self] }

    @Model
    final class User {
        var name: String
        var email: String
        var createdAt: Date? // New property
    }
}
Rule: Start with versioned schemas from day one, or ship versioning wrapper first.
严重警告:如果你的应用已发布未版本化的SwiftData并启用iCloud同步,用户在首次迁移时会遭遇崩溃。
错误信息:"Cannot use staged migration with an unknown model version"
原因:CloudKit需要版本化的 schema 来保证同步一致性。
解决方案
swift
// ❌ 错误做法:发布未版本化+iCloud同步后再添加版本控制
// 这会导致所有现有用户崩溃

// ✅ 正确做法:分两阶段迁移
// 版本1.1:添加版本控制但不修改schema
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] { [User.self] }

    @Model
    final class User {
        var name: String
        var email: String
        // 与已发布的v1.0完全一致的schema
    }
}

// 版本1.2:此时安全添加新属性
enum SchemaV2: VersionedSchema {
    static var models: [any PersistentModel.Type] { [User.self] }

    @Model
    final class User {
        var name: String
        var email: String
        var createdAt: Date? // 新属性
    }
}
规则:从项目初期就使用版本化schema,或先发布版本化包装器。

Trap 2: Unique Constraint with Duplicates

陷阱2:存在重复数据时添加唯一约束

Scenario: Adding
@Attribute(.unique)
when data has duplicates.
Result: Lightweight migration fails, app becomes non-functional.
Solution: Complex migration with deduplication BEFORE applying constraint.
swift
struct DeduplicateThenUnique: MigrationStage {
    func migrate(context: ModelContext) throws {
        let items = try context.fetch(FetchDescriptor<OldSchema.Product>())
        let grouped = Dictionary(grouping: items, by: { $0.sku })

        // Keep first, delete duplicates
        for (_, duplicates) in grouped where duplicates.count > 1 {
            for item in duplicates.dropFirst() {
                context.delete(item)
            }
        }
        try context.save()
    }
}
Rule: Clean data BEFORE applying constraints. Test with production data backup.
场景:当数据中存在重复项时,添加
@Attribute(.unique)
结果:轻量迁移失败,应用无法正常使用。
解决方案:在应用约束前,先执行去重的复杂迁移。
swift
struct DeduplicateThenUnique: MigrationStage {
    func migrate(context: ModelContext) throws {
        let items = try context.fetch(FetchDescriptor<OldSchema.Product>())
        let grouped = Dictionary(grouping: items, by: { $0.sku })

        // 保留第一个,删除重复项
        for (_, duplicates) in grouped where duplicates.count > 1 {
            for item in duplicates.dropFirst() {
                context.delete(item)
            }
        }
        try context.save()
    }
}
规则:在应用约束前清理数据。务必使用生产数据备份进行测试。

Trap 3: iCloud Sync Blocks Migrations

陷阱3:iCloud同步阻止迁移

CRITICAL: Once iCloud sync enabled, SwiftData blocks migrations that break lightweight compatibility.
What this means:
  • Cannot rename properties (breaks sync)
  • Cannot change relationship delete rules (breaks sync)
  • Cannot remove required properties (breaks sync)
Workaround:
swift
// Instead of renaming (blocked):
// @Model class User {
//     var userName: String // Renamed from 'name'
// }

// Add new, deprecate old (allowed):
@Model class User {
    var name: String // Keep for backward compatibility
    var userName: String? // New preferred field

    var displayName: String {
        userName ?? name // Compute from both
    }
}
Rule: With iCloud, only additive changes allowed. Design schema carefully upfront.
严重警告:一旦启用iCloud同步,SwiftData会阻止破坏轻量兼容性的迁移。
具体影响
  • 无法重命名属性(会破坏同步)
  • 无法修改关系删除规则(会破坏同步)
  • 无法移除必填属性(会破坏同步)
替代方案
swift
// 不要直接重命名(会被阻止):
// @Model class User {
//     var userName: String // 从'name'重命名而来
// }

// 添加新属性,弃用旧属性(允许操作):
@Model class User {
    var name: String // 保留以兼容旧版本
    var userName: String? // 新的首选字段

    var displayName: String {
        userName ?? name // 从两个字段计算得出
    }
}
规则:启用iCloud后,仅允许添加式修改。需提前精心设计schema。

Trap 4: Main Thread Migration Blocking

陷阱4:主线程迁移导致阻塞

Symptom: White screen for 3-5 seconds on app launch after update.
Cause: Large dataset migration runs on main thread.
Solution: Background initialization.
swift
// ✅ Initialize container on background thread
Task.detached {
    let container = try await ModelContainer(
        for: User.self,
        migrationPlan: MigrationPlan.self
    )
    await MainActor.run {
        self.container = container
    }
}
Rule: Always test migrations with realistic data size (1000+ records).
症状:应用更新后启动时出现3-5秒的白屏。
原因:大型数据集的迁移在主线程运行。
解决方案:后台初始化。
swift
// ✅ 在后台线程初始化容器
Task.detached {
    let container = try await ModelContainer(
        for: User.self,
        migrationPlan: MigrationPlan.self
    )
    await MainActor.run {
        self.container = container
    }
}
规则:务必使用真实数据规模(1000+条记录)测试迁移。

Performance Patterns

性能优化方案

Pattern 1: Batch Fetching

方案1:批量查询

Problem: Loading 1000+ objects at once consumes memory, blocks UI.
Solution: fetchBatchSize for lazy loading.
swift
// ❌ WRONG: Loads all 1000 posts into memory
let request: NSFetchRequest<Post> = Post.fetchRequest()
let posts = try context.fetch(request)

// ✅ CORRECT: Loads 20 at a time as needed
let request: NSFetchRequest<Post> = Post.fetchRequest()
request.fetchBatchSize = 20
let posts = try context.fetch(request)
Rule: Always set fetchBatchSize for feeds, lists, paginated views.
问题:一次性加载1000+个对象会占用大量内存,阻塞UI。
解决方案:使用fetchBatchSize实现懒加载。
swift
// ❌ 错误做法:一次性加载全部1000篇帖子到内存
let request: NSFetchRequest<Post> = Post.fetchRequest()
let posts = try context.fetch(request)

// ✅ 正确做法:按需每次加载20条
let request: NSFetchRequest<Post> = Post.fetchRequest()
request.fetchBatchSize = 20
let posts = try context.fetch(request)
规则:对于信息流、列表、分页视图,务必设置fetchBatchSize。

Pattern 2: BLOB Storage

方案2:BLOB存储

Problem: Storing images/videos in CoreData/SwiftData kills performance.
Rule:
  • < 100KB: Store inline (safe)
  • 100KB - 1MB: Separate entity with relationship
  • > 1MB: File system + store path in database
swift
// ❌ WRONG: 2MB images in Post entity
@Model
class Post {
    var title: String
    @Attribute(.externalStorage) var image: Data // Still loads eagerly!
}

// ✅ CORRECT: File system storage
@Model
class Post {
    var title: String
    var imagePath: String? // "images/post-123.jpg"
}

// Load image on-demand
func loadImage(for post: Post) -> UIImage? {
    guard let path = post.imagePath else { return nil }
    let url = FileManager.documentsDirectory.appendingPathComponent(path)
    return UIImage(contentsOfFile: url.path)
}
Why: Database fetches load full objects. BLOBs inflate memory usage even when not displayed.
问题:在CoreData/SwiftData中存储图片/视频会严重影响性能。
规则
  • 小于100KB:可内联存储(安全)
  • 100KB - 1MB:使用独立实体加关联关系
  • 大于1MB:存储到文件系统,仅在数据库中保存路径
swift
// ❌ 错误做法:在Post实体中存储2MB图片
@Model
class Post {
    var title: String
    @Attribute(.externalStorage) var image: Data // 仍会在查询时加载到内存!
}

// ✅ 正确做法:文件系统存储
@Model
class Post {
    var title: String
    var imagePath: String? // "images/post-123.jpg"
}

// 按需加载图片
func loadImage(for post: Post) -> UIImage? {
    guard let path = post.imagePath else { return nil }
    let url = FileManager.documentsDirectory.appendingPathComponent(path)
    return UIImage(contentsOfFile: url.path)
}
原因:数据库查询会加载完整对象。即使不显示,BLOB也会占用大量内存。

Pattern 3: Faulting for Relationships

方案3:关系延迟加载

Problem: Fetching posts eagerly loads all related users and images.
Solution: Return faults, load on access.
swift
// ✅ Relationships default to faults (good)
let request: NSFetchRequest<Post> = Post.fetchRequest()
request.relationshipKeyPathsForPrefetching = [] // Don't prefetch
let posts = try context.fetch(request)

// Access triggers individual fault:
let authorName = posts[0].author.name // Only now loads User

// ✅ Prefetch when you know you'll need it:
request.relationshipKeyPathsForPrefetching = ["author"]
Rule: Only prefetch relationships you'll access for ALL fetched objects.
问题:查询帖子时会自动加载所有关联的用户和图片。
解决方案:返回故障对象,访问时再加载。
swift
// ✅ 关系默认是故障对象(合理设置)
let request: NSFetchRequest<Post> = Post.fetchRequest()
request.relationshipKeyPathsForPrefetching = [] // 不要预加载
let posts = try context.fetch(request)

// 访问时才会触发单独加载:
let authorName = posts[0].author.name // 此时才会加载User

// ✅ 确定需要访问所有对象的关系时再预加载:
request.relationshipKeyPathsForPrefetching = ["author"]
规则:仅预加载你会访问到的所有查询对象的关系。

Pattern 4: NSFetchedResultsController (CoreData)

方案4:NSFetchedResultsController(CoreData)

For: Table/collection views with live updates.
Why: Lazy loads, automatic UI updates, sectioning built-in.
swift
let request: NSFetchRequest<Post> = Post.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
request.fetchBatchSize = 20

let frc = NSFetchedResultsController(
    fetchRequest: request,
    managedObjectContext: context,
    sectionNameKeyPath: nil,
    cacheName: "PostsCache"
)

frc.delegate = self
try frc.performFetch()

// UITableView uses frc.fetchedObjects
Rule: Use FRC for all CoreData-backed table/collection views.
适用场景:需要实时更新的表格/集合视图。
优势:懒加载、自动更新UI、内置分组功能。
swift
let request: NSFetchRequest<Post> = Post.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
request.fetchBatchSize = 20

let frc = NSFetchedResultsController(
    fetchRequest: request,
    managedObjectContext: context,
    sectionNameKeyPath: nil,
    cacheName: "PostsCache"
)

frc.delegate = self
try frc.performFetch()

// UITableView 使用 frc.fetchedObjects
规则:所有基于CoreData的表格/集合视图都应使用FRC。

SwiftData Limitations (as of iOS 18)

SwiftData的局限性(截至iOS 18)

FeatureCoreDataSwiftDataWorkaround
NSCompoundPredicateMultiple queries
NSFetchedResultsController@Query in SwiftUI
Abstract entitiesUse protocols
Child contextsUse ModelContext
Subclassing modelsDon't subclass
PerformanceFasterSlowerWait for updates
功能CoreDataSwiftData替代方案
NSCompoundPredicate多轮查询
NSFetchedResultsControllerSwiftUI中使用@Query
抽象实体使用协议
子上下文使用ModelContext
模型子类化不要子类化
性能更快更慢等待官方更新

Common Mistakes

常见错误

MistakeRealityFix
"Migrations are automatic"Only lightweight migrations. Unique constraints, renames require complex migrations.Test with production data
"I'll version later"Unversioned + iCloud = crash on first migrationVersion from day one
"SwiftData = CoreData simplified"SwiftData has different rules (no subclassing, different delete rules)Learn SwiftData-specific constraints
"externalStorage attribute fixes BLOBs"Still loaded into memory on fetchStore in file system
"I can rename properties freely"With iCloud, renames blockedAdd new property, keep old
"Migration won't block UI"Large datasets block main threadBackground initialization
错误认知实际情况修复方案
"迁移是自动的"只有轻量迁移是自动的。唯一约束、重命名等需要复杂迁移。使用生产数据测试
"以后再做版本化"未版本化+iCloud同步=首次迁移必崩溃从项目初期就做版本化
"SwiftData是CoreData的简化版"SwiftData有不同的规则(不能子类化、删除规则不同等)学习SwiftData的特定约束
"externalStorage属性能解决BLOB问题"查询时仍会加载到内存存储到文件系统
"我可以随意重命名属性"启用iCloud后,重命名会被阻止添加新属性,保留旧属性
"迁移不会阻塞UI"大型数据集会阻塞主线程后台初始化迁移

Migration Checklist

迁移检查清单

Before shipping schema change:
  • Defined versioned schemas (V1, V2)
  • Written migration stage if complex (deduplication, renames)
  • Tested with production data backup (1000+ records)
  • Tested migration on background thread (no white screen)
  • Verified iCloud sync works post-migration
  • Checked new properties have defaults or are optional
  • Confirmed unique constraints applied to clean data only
发布schema变更前
  • 已定义版本化schema(V1、V2)
  • 如需复杂操作(去重、重命名),已编写迁移阶段代码
  • 使用生产数据备份测试(1000+条记录)
  • 测试了后台线程迁移(无白屏)
  • 验证迁移后iCloud同步正常
  • 确认新属性有默认值或为可选类型
  • 确保仅在数据干净时应用唯一约束

Quick Reference

快速参考

SwiftData:
swift
// Basic model
@Model
class User {
    @Attribute(.unique) var email: String
    var name: String
    @Relationship(deleteRule: .cascade) var posts: [Post]
}

// Versioned schemas
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] { [User.self] }
    @Model final class User { /* ... */ }
}

// Query (SwiftUI)
@Query(sort: \.createdAt, order: .reverse) var users: [User]
CoreData:
swift
// Fetch with batching
let request: NSFetchRequest<User> = User.fetchRequest()
request.fetchBatchSize = 20
request.predicate = NSPredicate(format: "name CONTAINS[cd] %@", searchText)

// Background context
let bgContext = container.newBackgroundContext()
bgContext.perform {
    // Heavy work here
    try? bgContext.save()
}
SwiftData:
swift
// 基础模型
@Model
class User {
    @Attribute(.unique) var email: String
    var name: String
    @Relationship(deleteRule: .cascade) var posts: [Post]
}

// 版本化schema
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] { [User.self] }
    @Model final class User { /* ... */ }
}

// 查询(SwiftUI)
@Query(sort: \.createdAt, order: .reverse) var users: [User]
CoreData:
swift
// 带批量加载的查询
let request: NSFetchRequest<User> = User.fetchRequest()
request.fetchBatchSize = 20
request.predicate = NSPredicate(format: "name CONTAINS[cd] %@", searchText)

// 后台上下文
let bgContext = container.newBackgroundContext()
bgContext.perform {
    // 在此处理繁重任务
    try? bgContext.save()
}

Red Flags - STOP and Reconsider

危险信号 - 立即停止并重新考虑

  • Shipped unversioned schema with iCloud → Ship versioning wrapper first
  • Adding unique constraint to dirty data → Run deduplication migration
  • Storing multi-MB files in database → Move to file system
  • White screen on launch after update → Background migration
  • Fetch taking >500ms → Add fetchBatchSize, check for BLOBs
  • "I'll handle migration when users complain" → Test NOW with production data
  • 已发布未版本化schema且启用iCloud → 先发布版本化包装器
  • 向脏数据添加唯一约束 → 先执行去重迁移
  • 在数据库中存储多MB级文件 → 转移到文件系统
  • 更新后启动时出现白屏 → 改为后台迁移
  • 查询耗时>500ms → 添加fetchBatchSize,检查是否有BLOB
  • "等用户反馈再处理迁移问题" → 立即使用生产数据测试

Real-World Impact

实际业务影响

Before: App crashes for 100% of users with iCloud enabled after first migration (unversioned→versioned trap).
After: Ship v1.1 with versioning wrapper (no schema change), then v1.2 with actual changes. Zero crashes.

Before: Feed loads in 4 seconds with 1000 posts + 2MB images each.
After: Images moved to file system, fetchBatchSize = 20. Feed loads in 300ms, lazy-loads images.
优化前:应用在首次迁移(未版本化→版本化陷阱)时,所有启用iCloud的用户100%崩溃。
优化后:先发布v1.1版本(仅添加版本化包装器,无schema变更),再发布v1.2版本(实际变更)。零崩溃。

优化前:信息流加载1000篇帖子(每篇含2MB图片)耗时4秒。
优化后:图片转移到文件系统,fetchBatchSize设为20。信息流加载耗时300ms,图片按需懒加载。