axiom-swiftdata-migration

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SwiftData Custom Schema Migrations

SwiftData 自定义架构迁移

Overview

概述

SwiftData schema migrations move your data safely when models change. Core principle SwiftData's
willMigrate
sees only OLD models,
didMigrate
sees only NEW models—you can never access both simultaneously. This limitation shapes all migration strategies.
Requires iOS 17+, Swift 5.9+ Target iOS 26+ (features like
propertiesToFetch
)
当模型发生变更时,SwiftData架构迁移可安全迁移你的数据。核心原则:SwiftData的
willMigrate
仅能访问旧模型,
didMigrate
仅能访问新模型——你永远无法同时访问两者。这一限制决定了所有迁移策略的设计。
要求:iOS 17+、Swift 5.9+ 目标版本:iOS 26+(支持
propertiesToFetch
等特性)

When Custom Migrations Are Required

何时需要自定义迁移

Lightweight Migrations (Automatic)

轻量迁移(自动)

SwiftData can migrate automatically for:
  • ✅ Adding new optional properties
  • ✅ Adding new required properties with default values
  • ✅ Removing properties
  • ✅ Renaming properties (with
    @Attribute(originalName:)
    )
  • ✅ Changing relationship delete rules
  • ✅ Adding new models
SwiftData可自动处理以下场景的迁移:
  • ✅ 添加新的可选属性
  • ✅ 添加带默认值的新必填属性
  • ✅ 删除属性
  • ✅ 重命名属性(使用
    @Attribute(originalName:)
  • ✅ 修改关系删除规则
  • ✅ 添加新模型

Custom Migrations (This Skill)

自定义迁移(本技能覆盖)

You need custom migrations for:
  • ❌ Changing property types (
    String
    AttributedString
    ,
    Int
    String
    )
  • ❌ Making optional properties required (must populate existing nulls)
  • ❌ Complex relationship restructuring
  • ❌ Data transformations (splitting/merging fields)
  • ❌ Deduplication when adding unique constraints
以下场景需要使用自定义迁移:
  • ❌ 变更属性类型(
    String
    AttributedString
    Int
    String
    等)
  • ❌ 将可选属性改为必填属性(必须填充现有空值)
  • ❌ 复杂关系重构
  • ❌ 数据转换(拆分/合并字段)
  • ❌ 添加唯一约束时去重

Example Prompts

示例问题

These are real questions developers ask that this skill is designed to answer:
以下是开发者常问的、本技能可解答的实际问题:

1. "I need to change a property from String to AttributedString. How do I migrate existing data with relationships intact?"

1. "我需要将属性从String类型改为AttributedString类型,如何在迁移时保留现有数据和关系?"

→ The skill shows the two-stage migration pattern that works around the willMigrate/didMigrate limitation
→ 本技能会展示绕过willMigrate/didMigrate限制的两阶段迁移模式

2. "My model has a one-to-many relationship with cascade delete. How do I preserve this during a type change migration?"

2. "我的模型有一对多关系且级联删除规则,如何在类型变更迁移中保留该关系?"

→ The skill explains relationship prefetching and maintaining inverse relationships across schema versions
→ 本技能会讲解关系预取以及跨架构版本维护反向关系的方法

3. "I have a many-to-many relationship between Tags and Notes. The migration is failing with 'Expected only Arrays for Relationships'. What's wrong?"

3. "我的Tags和Notes之间有多对多关系,迁移时出现'Expected only Arrays for Relationships'错误,问题出在哪?"

→ The skill covers explicit inverse relationship requirements and iOS 17.0 alphabetical naming bug
→ 本技能会覆盖显式反向关系的要求以及iOS 17.0的字母命名bug

4. "I need to rename a model but keep all its relationships intact."

4. "我需要重命名模型,但要保留所有关系,该怎么做?"

→ The skill shows
@Attribute(originalName:)
patterns for lightweight migration
→ 本技能会展示使用
@Attribute(originalName:)
进行轻量迁移的模式

5. "My migration works in the simulator but crashes on a real device with existing data."

5. "我的迁移在模拟器中正常工作,但在真实设备上使用现有数据时崩溃,这是为什么?"

→ The skill emphasizes real-device testing and explains why simulator success doesn't guarantee production safety
→ 本技能会强调真实设备测试的重要性,并解释为什么模拟器成功不代表生产环境安全

6. "Why do I have to copy ALL my models into each VersionedSchema, even ones that haven't changed?"

6. "为什么我必须把所有模型都复制到每个VersionedSchema中,即使它们没有变化?"

→ The skill explains SwiftData's design: each VersionedSchema is a complete snapshot, not a diff
→ 本技能会解释SwiftData的设计:每个VersionedSchema都是完整的快照,而非差异包

7. "I'm getting 'The model used to open the store is incompatible with the one used to create the store' error."

7. "我遇到了'The model used to open the store is incompatible with the one used to create the store'错误,该怎么解决?"

→ The skill provides debugging steps for schema version mismatches
→ 本技能会提供架构版本不匹配的调试步骤

8. "How do I test my SwiftData migration before releasing to production?"

8. "如何在发布到生产环境前测试我的SwiftData迁移?"

→ The skill covers migration testing workflow, real device testing requirements, and validation strategies

→ 本技能会覆盖迁移测试流程、真实设备测试要求以及验证策略

The willMigrate/didMigrate Limitation

willMigrate/didMigrate的限制

CRITICAL This is the architectural constraint that shapes all SwiftData migration patterns.
关键要点:这是决定所有SwiftData迁移模式的架构约束。

What You Can Access

可访问范围

swift
static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: { context in
        // ✅ CAN access: SchemaV1 models (old)
        let v1Notes = try context.fetch(FetchDescriptor<SchemaV1.Note>())

        // ❌ CANNOT access: SchemaV2 models
        // SchemaV2.Note doesn't exist yet
    },
    didMigrate: { context in
        // ✅ CAN access: SchemaV2 models (new)
        let v2Notes = try context.fetch(FetchDescriptor<SchemaV2.Note>())

        // ❌ CANNOT access: SchemaV1 models
        // SchemaV1.Note is gone
    }
)
swift
static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: { context in
        // ✅ 可访问:SchemaV1模型(旧模型)
        let v1Notes = try context.fetch(FetchDescriptor<SchemaV1.Note>())

        // ❌ 不可访问:SchemaV2模型
        // SchemaV2.Note尚未存在
    },
    didMigrate: { context in
        // ✅ 可访问:SchemaV2模型(新模型)
        let v2Notes = try context.fetch(FetchDescriptor<SchemaV2.Note>())

        // ❌ 不可访问:SchemaV1模型
        // SchemaV1.Note已不存在
    }
)

Why This Matters

这一限制的影响

You cannot directly transform data from old type to new type in a single migration stage. Example:
swift
// ❌ IMPOSSIBLE - you can't do this in one stage
willMigrate: { context in
    let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
    for oldNote in oldNotes {
        let newNote = SchemaV2.Note()  // ❌ Doesn't exist yet!
        newNote.content = oldNote.contentAsAttributedString()
    }
}
Solution Use two-stage migration pattern (covered below).

你无法在单个迁移阶段直接将数据从旧类型转换为新类型。示例:
swift
// ❌ 不可能——无法在单个阶段完成此操作
willMigrate: { context in
    let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
    for oldNote in oldNotes {
        let newNote = SchemaV2.Note()  // ❌ 尚未存在!
        newNote.content = oldNote.contentAsAttributedString()
    }
}
解决方案:使用两阶段迁移模式(下文会介绍)。

Core Patterns

核心模式

Pattern 1: Basic VersionedSchema Setup

模式1:基础VersionedSchema配置

Every distinct schema version must be defined as a
VersionedSchema
.
swift
import SwiftData

enum NotesSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]  // ALL models, even if unchanged
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String
        var content: String  // Original type
        var createdAt: Date

        @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
        var folder: Folder?

        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []

        init(id: String, title: String, content: String, createdAt: Date) {
            self.id = id
            self.title = title
            self.content = content
            self.createdAt = createdAt
        }
    }

    @Model
    final class Folder {
        @Attribute(.unique) var id: String
        var name: String

        @Relationship(deleteRule: .cascade)
        var notes: [Note] = []

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

    @Model
    final class Tag {
        @Attribute(.unique) var id: String
        var name: String

        @Relationship(deleteRule: .nullify)
        var notes: [Note] = []

        init(id: String, name: String) {
            self.id = id
            self.name = name
        }
    }
}
每个不同的架构版本都必须定义为
VersionedSchema
swift
import SwiftData

enum NotesSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]  // 所有模型,即使未变更
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String
        var content: String  // 原始类型
        var createdAt: Date

        @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
        var folder: Folder?

        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []

        init(id: String, title: String, content: String, createdAt: Date) {
            self.id = id
            self.title = title
            self.content = content
            self.createdAt = createdAt
        }
    }

    @Model
    final class Folder {
        @Attribute(.unique) var id: String
        var name: String

        @Relationship(deleteRule: .cascade)
        var notes: [Note] = []

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

    @Model
    final class Tag {
        @Attribute(.unique) var id: String
        var name: String

        @Relationship(deleteRule: .nullify)
        var notes: [Note] = []

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

Key patterns

关键模式

  • Complete snapshot All models included, even unchanged ones
  • Semantic versioning Use Schema.Version(major, minor, patch)
  • Explicit init SwiftData doesn't synthesize initializers
  • Inverse relationships Specify on both sides for bidirectional

  • 完整快照:包含所有模型,即使未变更
  • 语义版本控制:使用Schema.Version(major, minor, patch)
  • 显式初始化器:SwiftData不会自动合成初始化器
  • 反向关系:双向关系需在两侧都指定

Pattern 2: Two-Stage Migration for Type Changes

模式2:类型变更的两阶段迁移

Use when Changing property type (String → AttributedString, Int → String, etc.)
适用场景:变更属性类型(String → AttributedString、Int → String等)

Problem

问题

We want to change
Note.content
from
String
to
AttributedString
, but we can't access both old and new types simultaneously.
我们希望将
Note.content
String
类型改为
AttributedString
类型,但无法同时访问旧模型和新模型。

Solution

解决方案

Use an intermediate schema version (V1.1) that has BOTH properties.
swift
// Stage 1: V1 → V1.1 (Add new property alongside old)
enum NotesSchemaV1_1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 1, 0)

    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String

        // OLD property (to be deprecated)
        @Attribute(originalName: "content")
        var contentOld: String = ""

        // NEW property (target type)
        var contentNew: AttributedString?

        var createdAt: Date

        @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
        var folder: Folder?

        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []

        init(id: String, title: String, contentOld: String, createdAt: Date) {
            self.id = id
            self.title = title
            self.contentOld = contentOld
            self.createdAt = createdAt
        }
    }

    // Folder and Tag unchanged (copy from V1)
    @Model final class Folder { /* same as V1 */ }
    @Model final class Tag { /* same as V1 */ }
}

// Stage 2: V1.1 → V2 (Transform data, remove old property)
enum NotesSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String

        // Renamed from contentNew
        @Attribute(originalName: "contentNew")
        var content: AttributedString?

        var createdAt: Date

        @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
        var folder: Folder?

        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []

        init(id: String, title: String, content: AttributedString?, createdAt: Date) {
            self.id = id
            self.title = title
            self.content = content
            self.createdAt = createdAt
        }
    }

    @Model final class Folder { /* same as V1 */ }
    @Model final class Tag { /* same as V1 */ }
}
使用中间架构版本(V1.1),同时保留新旧属性。
swift
// 阶段1:V1 → V1.1(在旧属性旁添加新属性)
enum NotesSchemaV1_1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 1, 0)

    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String

        // 旧属性(即将废弃)
        @Attribute(originalName: "content")
        var contentOld: String = ""

        // 新属性(目标类型)
        var contentNew: AttributedString?

        var createdAt: Date

        @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
        var folder: Folder?

        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []

        init(id: String, title: String, contentOld: String, createdAt: Date) {
            self.id = id
            self.title = title
            self.contentOld = contentOld
            self.createdAt = createdAt
        }
    }

    // Folder和Tag未变更(从V1复制)
    @Model final class Folder { /* 与V1相同 */ }
    @Model final class Tag { /* 与V1相同 */ }
}

// 阶段2:V1.1 → V2(转换数据,移除旧属性)
enum NotesSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)

    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String

        // 从contentNew重命名
        @Attribute(originalName: "contentNew")
        var content: AttributedString?

        var createdAt: Date

        @Relationship(deleteRule: .nullify, inverse: \Folder.notes)
        var folder: Folder?

        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []

        init(id: String, title: String, content: AttributedString?, createdAt: Date) {
            self.id = id
            self.title = title
            self.content = content
            self.createdAt = createdAt
        }
    }

    @Model final class Folder { /* 与V1相同 */ }
    @Model final class Tag { /* 与V1相同 */ }
}

Migration Plan

迁移计划

swift
enum NotesMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [NotesSchemaV1.self, NotesSchemaV1_1.self, NotesSchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV1_1, migrateV1_1toV2]
    }

    // Stage 1: Lightweight migration (adds contentNew)
    static let migrateV1toV1_1 = MigrationStage.lightweight(
        fromVersion: NotesSchemaV1.self,
        toVersion: NotesSchemaV1_1.self
    )

    // Stage 2: Custom migration (transform String → AttributedString)
    static let migrateV1_1toV2 = MigrationStage.custom(
        fromVersion: NotesSchemaV1_1.self,
        toVersion: NotesSchemaV2.self,
        willMigrate: { context in
            // Transform data while we still have access to V1.1 models
            var fetchDesc = FetchDescriptor<NotesSchemaV1_1.Note>()

            // Prefetch relationships to preserve them
            fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]

            let notes = try context.fetch(fetchDesc)

            for note in notes {
                // Convert String → AttributedString
                note.contentNew = try? AttributedString(markdown: note.contentOld)
            }

            try context.save()
        },
        didMigrate: nil
    )
}
swift
enum NotesMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [NotesSchemaV1.self, NotesSchemaV1_1.self, NotesSchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV1_1, migrateV1_1toV2]
    }

    // 阶段1:轻量迁移(添加contentNew)
    static let migrateV1toV1_1 = MigrationStage.lightweight(
        fromVersion: NotesSchemaV1.self,
        toVersion: NotesSchemaV1_1.self
    )

    // 阶段2:自定义迁移(转换String → AttributedString)
    static let migrateV1_1toV2 = MigrationStage.custom(
        fromVersion: NotesSchemaV1_1.self,
        toVersion: NotesSchemaV2.self,
        willMigrate: { context in
            // 在仍能访问V1.1模型时转换数据
            var fetchDesc = FetchDescriptor<NotesSchemaV1_1.Note>()

            // 预取关系以保留它们
            fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]

            let notes = try context.fetch(fetchDesc)

            for note in notes {
                // 转换String → AttributedString
                note.contentNew = try? AttributedString(markdown: note.contentOld)
            }

            try context.save()
        },
        didMigrate: nil
    )
}

Apply Migration Plan

应用迁移计划

swift
@main
struct NotesApp: App {
    let container: ModelContainer = {
        do {
            let schema = Schema(versionedSchema: NotesSchemaV2.self)
            return try ModelContainer(
                for: schema,
                migrationPlan: NotesMigrationPlan.self
            )
        } catch {
            fatalError("Failed to create container: \(error)")
        }
    }()

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

swift
@main
struct NotesApp: App {
    let container: ModelContainer = {
        do {
            let schema = Schema(versionedSchema: NotesSchemaV2.self)
            return try ModelContainer(
                for: schema,
                migrationPlan: NotesMigrationPlan.self
            )
        } catch {
            fatalError("Failed to create container: \(error)")
        }
    }()

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

Pattern 3: Many-to-Many Relationship Migration

模式3:多对多关系迁移

Use when You have many-to-many relationships (Tags ↔ Notes)
适用场景:存在多对多关系(标签 ↔ 笔记)

Critical Requirements

关键要求

  1. Explicit inverse relationships SwiftData won't infer many-to-many
  2. Arrays on both sides Not optional, must be arrays
  3. iOS 17.0 bug workaround Alphabetical naming issue
swift
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Note.self, Tag.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String

        // Many-to-many: MUST specify inverse
        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []  // ✅ Array with default value

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

    @Model
    final class Tag {
        @Attribute(.unique) var id: String
        var name: String

        // Many-to-many: MUST specify inverse
        @Relationship(deleteRule: .nullify, inverse: \Note.tags)
        var notes: [Note] = []  // ✅ Array with default value

        init(id: String, name: String) {
            self.id = id
            self.name = name
        }
    }
}
  1. 显式反向关系:SwiftData不会自动推断多对多关系
  2. 两侧均为数组:不可选,必须是数组类型
  3. iOS 17.0 bug修复:字母命名问题的解决方法
swift
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Note.self, Tag.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String

        // 多对多:必须指定反向关系
        @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
        var tags: [Tag] = []  // ✅ 带默认值的数组

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

    @Model
    final class Tag {
        @Attribute(.unique) var id: String
        var name: String

        // 多对多:必须指定反向关系
        @Relationship(deleteRule: .nullify, inverse: \Note.tags)
        var notes: [Note] = []  // ✅ 带默认值的数组

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

iOS 17.0 Alphabetical Bug Workaround

iOS 17.0字母命名Bug修复

In iOS 17.0, many-to-many relationships could fail if model names were in alphabetical order (e.g., Actor ↔ Movie works, but Movie ↔ Person fails).
Workaround Provide default values for relationship arrays:
swift
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var actors: [Actor] = []  // ✅ Default value prevents bug
Fixed in iOS 17.1+
在iOS 17.0中,如果模型名称按字母顺序排列,多对多关系可能会失败(例如Actor ↔ Movie正常,但Movie ↔ Person失败)。
修复方法:为关系数组提供默认值:
swift
@Relationship(deleteRule: .nullify, inverse: \Movie.actors)
var actors: [Actor] = []  // ✅ 默认值可避免bug
修复版本:iOS 17.1+

Adding Junction Table Metadata

添加关联表元数据

If you need additional fields on the relationship (e.g., "when was this tag added?"), use an explicit junction model:
swift
@Model
final class NoteTag {
    @Attribute(.unique) var id: String
    var addedAt: Date  // Metadata on relationship

    @Relationship(deleteRule: .cascade)
    var note: Note?

    @Relationship(deleteRule: .cascade)
    var tag: Tag?

    init(id: String, note: Note, tag: Tag, addedAt: Date) {
        self.id = id
        self.note = note
        self.tag = tag
        self.addedAt = addedAt
    }
}

@Model
final class Note {
    @Attribute(.unique) var id: String
    var title: String

    @Relationship(deleteRule: .cascade)
    var noteTags: [NoteTag] = []  // One-to-many to junction

    var tags: [Tag] {
        noteTags.compactMap { $0.tag }
    }
}

@Model
final class Tag {
    @Attribute(.unique) var id: String
    var name: String

    @Relationship(deleteRule: .cascade)
    var noteTags: [NoteTag] = []  // One-to-many to junction

    var notes: [Note] {
        noteTags.compactMap { $0.note }
    }
}

如果需要在关系上添加额外字段(例如"标签添加时间"),请使用显式关联模型:
swift
@Model
final class NoteTag {
    @Attribute(.unique) var id: String
    var addedAt: Date  // 关系元数据

    @Relationship(deleteRule: .cascade)
    var note: Note?

    @Relationship(deleteRule: .cascade)
    var tag: Tag?

    init(id: String, note: Note, tag: Tag, addedAt: Date) {
        self.id = id
        self.note = note
        self.tag = tag
        self.addedAt = addedAt
    }
}

@Model
final class Note {
    @Attribute(.unique) var id: String
    var title: String

    @Relationship(deleteRule: .cascade)
    var noteTags: [NoteTag] = []  // 一对多关联到关联表

    var tags: [Tag] {
        noteTags.compactMap { $0.tag }
    }
}

@Model
final class Tag {
    @Attribute(.unique) var id: String
    var name: String

    @Relationship(deleteRule: .cascade)
    var noteTags: [NoteTag] = []  // 一对多关联到关联表

    var notes: [Note] {
        noteTags.compactMap { $0.note }
    }
}

Pattern 4: Relationship Prefetching During Migration

模式4:迁移期间的关系预取

Use when Migrating models with relationships to avoid N+1 queries
swift
static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: { context in
        var fetchDesc = FetchDescriptor<SchemaV1.Note>()

        // Prefetch relationships (iOS 26+)
        fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]

        // Only fetch properties you need (iOS 26+)
        fetchDesc.propertiesToFetch = [\.title, \.content]

        let notes = try context.fetch(fetchDesc)

        // Relationships are already loaded - no N+1
        for note in notes {
            let folderName = note.folder?.name  // ✅ Already in memory
            let tagCount = note.tags.count  // ✅ Already in memory
        }

        try context.save()
    },
    didMigrate: nil
)
适用场景:迁移带关系的模型以避免N+1查询
swift
static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: { context in
        var fetchDesc = FetchDescriptor<SchemaV1.Note>()

        // 预取关系(iOS 26+)
        fetchDesc.relationshipKeyPathsForPrefetching = [\.folder, \.tags]

        // 仅获取需要的属性(iOS 26+)
        fetchDesc.propertiesToFetch = [\.title, \.content]

        let notes = try context.fetch(fetchDesc)

        // 关系已加载——无N+1查询
        for note in notes {
            let folderName = note.folder?.name  // ✅ 已在内存中
            let tagCount = note.tags.count  // ✅ 已在内存中
        }

        try context.save()
    },
    didMigrate: nil
)

Performance Impact

性能影响

Without prefetching:
- 1 query to fetch notes
- N queries to fetch each note's folder
- N queries to fetch each note's tags
= 1 + N + N queries

With prefetching:
- 1 query to fetch notes
- 1 query to fetch all folders
- 1 query to fetch all tags
= 3 queries total

无预取时:
- 1次查询获取笔记
- N次查询获取每个笔记的文件夹
- N次查询获取每个笔记的标签
= 1 + N + N次查询

有预取时:
- 1次查询获取笔记
- 1次查询获取所有文件夹
- 1次查询获取所有标签
= 共3次查询

Pattern 5: Renaming Properties

模式5:重命名属性

Use when You want to rename a property without data loss
swift
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Note.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String  // Original name
    }
}

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Note.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String

        // Renamed from "title" to "heading"
        @Attribute(originalName: "title")
        var heading: String
    }
}

// Migration plan (lightweight migration)
enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

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

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )
}
Why this works SwiftData sees
originalName
and preserves data during lightweight migration.

适用场景:重命名属性且不丢失数据
swift
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Note.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String
        var title: String  // 原始名称
    }
}

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Note.self]
    }

    @Model
    final class Note {
        @Attribute(.unique) var id: String

        // 从"title"重命名为"heading"
        @Attribute(originalName: "title")
        var heading: String
    }
}

// 迁移计划(轻量迁移)
enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

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

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )
}
原理:SwiftData会识别
originalName
并在轻量迁移期间保留数据。

Pattern 6: Deduplication for Unique Constraints

模式6:为唯一约束去重

Use when Adding
@Attribute(.unique)
to a field that has duplicates
swift
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Trip.self]
    }

    @Model
    final class Trip {
        @Attribute(.unique) var id: String
        var name: String  // ❌ Not unique, has duplicates

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

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Trip.self]
    }

    @Model
    final class Trip {
        @Attribute(.unique) var id: String
        @Attribute(.unique) var name: String  // ✅ Now unique

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

enum TripMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

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

    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // Deduplicate before adding unique constraint
            let trips = try context.fetch(FetchDescriptor<SchemaV1.Trip>())

            var seenNames = Set<String>()
            for trip in trips {
                if seenNames.contains(trip.name) {
                    // Duplicate - delete or rename
                    context.delete(trip)
                } else {
                    seenNames.insert(trip.name)
                }
            }

            try context.save()
        },
        didMigrate: nil
    )
}

适用场景:为存在重复值的字段添加
@Attribute(.unique)
swift
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Trip.self]
    }

    @Model
    final class Trip {
        @Attribute(.unique) var id: String
        var name: String  // ❌ 不唯一,存在重复值

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

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Trip.self]
    }

    @Model
    final class Trip {
        @Attribute(.unique) var id: String
        @Attribute(.unique) var name: String  // ✅ 现在是唯一的

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

enum TripMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

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

    static let migrateV1toV2 = MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // 添加唯一约束前去重
            let trips = try context.fetch(FetchDescriptor<SchemaV1.Trip>())

            var seenNames = Set<String>()
            for trip in trips {
                if seenNames.contains(trip.name) {
                    // 重复项——删除或重命名
                    context.delete(trip)
                } else {
                    seenNames.insert(trip.name)
                }
            }

            try context.save()
        },
        didMigrate: nil
    )
}

Testing Migrations

测试迁移

Mandatory Testing Checklist

必测检查清单

  • Test fresh install (all migrations run from V1 → latest)
  • Test upgrade from each previous version
  • Test on REAL device (not just simulator)
  • Verify relationship integrity after migration
  • Check for data loss (count records before/after)
  • Test with production-sized dataset
  • 测试全新安装(从V1到最新版本的所有迁移都运行)
  • 测试从每个旧版本升级
  • 在真实设备上测试(不只是模拟器)
  • 验证迁移后的关系完整性
  • 检查数据是否丢失(迁移前后统计记录数)
  • 使用生产规模的数据集测试

Why Simulator Testing Is Insufficient

为什么模拟器测试不足

Simulator behavior Deletes database on rebuild, always sees fresh schema
Real device behavior Keeps persistent database across updates, schema must match
swift
// ❌ WRONG - only testing in simulator
// You rebuild → simulator deletes database → fresh install
// Migration code never runs!

// ✅ CORRECT - test on real device
// 1. Install v1 build on device
// 2. Create sample data
// 3. Install v2 build (with migration)
// 4. Verify data preserved
模拟器行为:重建时会删除数据库,始终使用全新架构
真实设备行为:更新时保留持久化数据库,架构必须匹配
swift
// ❌ 错误做法——仅在模拟器中测试
// 重建→模拟器删除数据库→全新安装
// 迁移代码从未运行!

// ✅ 正确做法——在真实设备上测试
// 1. 在设备上安装v1版本
// 2. 创建示例数据
// 3. 安装v2版本(带迁移)
// 4. 验证数据是否保留

Testing Workflow

测试流程

Before deploying any migration to production:
在将任何迁移部署到生产环境前:

1. Create Test Data Sets

1. 创建测试数据集

Prepare test data representing pre-migration state:
  • Minimal dataset - 10-20 records with all relationship types
  • Realistic dataset - 1,000+ records matching production scale
  • Edge cases - Empty relationships, max relationship counts, optional fields
准备代表迁移前状态的测试数据:
  • 最小数据集 - 10-20条记录,包含所有关系类型
  • 真实数据集 - 1000+条记录,匹配生产规模
  • 边缘情况 - 空关系、最大关系数、可选字段

2. Test in Simulator

2. 在模拟器中测试

Run migration with test data:
swift
// Create test data in V1 schema
let v1Container = try ModelContainer(for: Schema(versionedSchema: SchemaV1.self))
// ... populate test data ...

// Run migration
let v2Container = try ModelContainer(
    for: Schema(versionedSchema: SchemaV2.self),
    migrationPlan: MigrationPlan.self
)
Verify:
  • All relationships preserved
  • No data loss (count records before/after)
  • New fields populated correctly
  • Performance acceptable with realistic dataset size
使用测试数据运行迁移:
swift
// 在V1架构中创建测试数据
let v1Container = try ModelContainer(for: Schema(versionedSchema: SchemaV1.self))
// ... 填充测试数据 ...

// 运行迁移
let v2Container = try ModelContainer(
    for: Schema(versionedSchema: SchemaV2.self),
    migrationPlan: MigrationPlan.self
)
验证:
  • 所有关系均保留
  • 无数据丢失(迁移前后统计记录数)
  • 新字段填充正确
  • 真实规模数据集下性能可接受

3. Test on Real Device

3. 在真实设备上测试

CRITICAL - Simulator success does not guarantee production safety.
bash
undefined
关键 - 模拟器成功不代表生产环境安全。
bash
undefined

Workflow:

流程:

  1. Install v1 build on real device
  2. Create 100+ records with relationships
  3. Verify data exists
  4. Install v2 build (over existing app, don't delete)
  5. Launch app
  6. Verify:
    • App launches without crash
    • All 100+ records still exist
    • Relationships intact
    • New fields populated
undefined
  1. 在真实设备上安装v1版本
  2. 创建100+条带关系的记录
  3. 验证数据存在
  4. 安装v2版本(覆盖现有应用,不要删除)
  5. 启动应用
  6. 验证:
    • 应用启动无崩溃
    • 所有100+条记录仍存在
    • 关系完整
    • 新字段已填充
undefined

4. Validate with Production Data (If Possible)

4. (如有可能)使用生产数据验证

If you have access to production data:
  • Copy production database to development environment
  • Run migration against copy
  • Verify no data corruption
  • Check performance with production-sized dataset
See
axiom-swiftdata-migration-diag
for debugging tools if migration fails.
如果可以访问生产数据:
  • 将生产数据库复制到开发环境
  • 在副本上运行迁移
  • 验证无数据损坏
  • 检查生产规模数据集下的性能
如果迁移失败,请使用
axiom-swiftdata-migration-diag
调试工具。

Migration Test Pattern

迁移测试模式

swift
import Testing
import SwiftData

@Test func testMigrationFromV1ToV2() throws {
    // 1. Create V1 data
    let v1Schema = Schema(versionedSchema: SchemaV1.self)
    let v1Config = ModelConfiguration(isStoredInMemoryOnly: true)
    let v1Container = try ModelContainer(for: v1Schema, configurations: v1Config)

    let context = v1Container.mainContext
    let note = SchemaV1.Note(id: "1", title: "Test", content: "Original")
    context.insert(note)
    try context.save()

    // 2. Run migration to V2
    let v2Schema = Schema(versionedSchema: SchemaV2.self)
    let v2Container = try ModelContainer(
        for: v2Schema,
        migrationPlan: MigrationPlan.self,
        configurations: v1Config
    )

    // 3. Verify data migrated
    let v2Context = v2Container.mainContext
    let notes = try v2Context.fetch(FetchDescriptor<SchemaV2.Note>())

    #expect(notes.count == 1)
    #expect(notes.first?.content != nil)  // String → AttributedString
}

swift
import Testing
import SwiftData

@Test func testMigrationFromV1ToV2() throws {
    // 1. 创建V1数据
    let v1Schema = Schema(versionedSchema: SchemaV1.self)
    let v1Config = ModelConfiguration(isStoredInMemoryOnly: true)
    let v1Container = try ModelContainer(for: v1Schema, configurations: v1Config)

    let context = v1Container.mainContext
    let note = SchemaV1.Note(id: "1", title: "Test", content: "Original")
    context.insert(note)
    try context.save()

    // 2. 运行迁移到V2
    let v2Schema = Schema(versionedSchema: SchemaV2.self)
    let v2Container = try ModelContainer(
        for: v2Schema,
        migrationPlan: MigrationPlan.self,
        configurations: v1Config
    )

    // 3. 验证数据已迁移
    let v2Context = v2Container.mainContext
    let notes = try v2Context.fetch(FetchDescriptor<SchemaV2.Note>())

    #expect(notes.count == 1)
    #expect(notes.first?.content != nil)  // String → AttributedString
}

Decision Tree: Lightweight vs Custom Migration

决策树:轻量迁移 vs 自定义迁移

What change are you making?
├─ Adding optional property → Lightweight ✓
├─ Adding required property with default → Lightweight ✓
├─ Renaming property (with originalName) → Lightweight ✓
├─ Removing property → Lightweight ✓
├─ Changing relationship delete rule → Lightweight ✓
├─ Adding new model → Lightweight ✓
├─ Changing property type → Custom (two-stage) ✗
├─ Making optional → required → Custom (populate nulls first) ✗
├─ Adding unique constraint (duplicates exist) → Custom (deduplicate first) ✗
└─ Complex relationship restructure → Custom ✗

你要进行的变更是什么?
├─ 添加可选属性 → 轻量迁移 ✓
├─ 添加带默认值的必填属性 → 轻量迁移 ✓
├─ 重命名属性(使用originalName) → 轻量迁移 ✓
├─ 删除属性 → 轻量迁移 ✓
├─ 修改关系删除规则 → 轻量迁移 ✓
├─ 添加新模型 → 轻量迁移 ✓
├─ 变更属性类型 → 自定义(两阶段) ✗
├─ 可选属性改必填 → 自定义(先填充空值) ✗
├─ 添加唯一约束(存在重复值) → 自定义(先去重) ✗
└─ 复杂关系重构 → 自定义 ✗

Common Mistakes

常见错误

❌ Forgetting to include ALL models in VersionedSchema

❌ 忘记在VersionedSchema中包含所有模型

swift
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [Note.self]  // ❌ WRONG: Missing Folder and Tag
    }
}

// ✅ CORRECT: Include ALL models
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]  // ✅ Even if unchanged
    }
}
Why Each VersionedSchema is a complete snapshot of the data model, not a diff.

swift
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [Note.self]  // ❌ 错误:缺少Folder和Tag
    }
}

// ✅ 正确:包含所有模型
enum SchemaV1: VersionedSchema {
    static var models: [any PersistentModel.Type] {
        [Note.self, Folder.self, Tag.self]  // ✅ 即使未变更也要包含
    }
}
原因:每个VersionedSchema都是数据模型的完整快照,而非差异包。

❌ Trying to access old models in didMigrate

❌ 尝试在didMigrate中访问旧模型

swift
static let migrate = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: nil,
    didMigrate: { context in
        // ❌ CRASH: SchemaV1.Note doesn't exist here
        let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
    }
)

// ✅ CORRECT: Use willMigrate for old models
static let migrate = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: { context in
        // ✅ SchemaV1.Note exists here
        let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
    },
    didMigrate: nil
)

swift
static let migrate = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: nil,
    didMigrate: { context in
        // ❌ 崩溃:SchemaV1.Note在此处不存在
        let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
    },
)

// ✅ 正确:在willMigrate中访问旧模型
static let migrate = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: { context in
        // ✅ SchemaV1.Note在此处存在
        let oldNotes = try context.fetch(FetchDescriptor<SchemaV1.Note>())
    },
    didMigrate: nil
)

❌ Not testing on real device with real data

❌ 未在真实设备上使用真实数据测试

swift
// ❌ WRONG: Simulator success ≠ production safety
// Rebuild simulator → database deleted → fresh install
// Migration never actually runs!

// ✅ CORRECT: Test migration path
// 1. Install v1 on real device
// 2. Create data (100+ records)
// 3. Install v2 with migration
// 4. Verify data preserved

swift
// ❌ 错误:模拟器成功 ≠ 生产环境安全
// 重建模拟器→数据库删除→全新安装
// 迁移从未实际运行!

// ✅ 正确:测试迁移路径
// 1. 在真实设备上安装v1版本
// 2. 创建数据(100+条记录)
// 3. 安装带迁移的v2版本
// 4. 验证数据保留

❌ Many-to-many without explicit inverse

❌ 多对多关系未指定显式反向

swift
// ❌ WRONG: SwiftData can't infer many-to-many
@Model
final class Note {
    var tags: [Tag] = []  // ❌ Missing inverse
}

// ✅ CORRECT: Explicit inverse
@Model
final class Note {
    @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
    var tags: [Tag] = []  // ✅ Inverse specified
}

swift
// ❌ 错误:SwiftData无法推断多对多关系
@Model
final class Note {
    var tags: [Tag] = []  // ❌ 缺少反向关系
}

// ✅ 正确:显式指定反向关系
@Model
final class Note {
    @Relationship(deleteRule: .nullify, inverse: \Tag.notes)
    var tags: [Tag] = []  // ✅ 指定了反向关系
}

❌ Assuming simulator success = production success

❌ 假设模拟器成功=生产环境成功

Simulator deletes database on rebuild. Real devices keep persistent databases across updates.
Impact Migration bugs hidden in simulator, crash 100% of production users.
Fix ALWAYS test on real device before shipping.

模拟器重建时会删除数据库。真实设备在更新时会保留持久化数据库。
影响:模拟器中隐藏的迁移bug会导致100%的生产环境用户崩溃。
修复:发布前务必在真实设备上测试。

Debugging Failed Migrations

调试失败的迁移

Enable Core Data SQL Debug

启用Core Data SQL调试

bash
undefined
bash
undefined

In Xcode scheme, add argument:

在Xcode scheme中添加参数:

-com.apple.coredata.swiftdata.debug 1

**Output** Shows actual SQL queries during migration
CoreData: sql: SELECT Z_PK, Z_ENT, Z_OPT, ZID, ZTITLE FROM ZNOTE CoreData: sql: ALTER TABLE ZNOTE ADD COLUMN ZCONTENT TEXT
undefined
-com.apple.coredata.swiftdata.debug 1

**输出**:显示迁移期间的实际SQL查询
CoreData: sql: SELECT Z_PK, Z_ENT, Z_OPT, ZID, ZTITLE FROM ZNOTE CoreData: sql: ALTER TABLE ZNOTE ADD COLUMN ZCONTENT TEXT
undefined

Common Error Messages

常见错误信息

ErrorLikely CauseFix
"Expected only Arrays for Relationships"Many-to-many inverse missingAdd
@Relationship(inverse:)
"The model used to open the store is incompatible"Schema version mismatchVerify migration plan schemas array
"Failed to fulfill faulting for..."Relationship integrity brokenPrefetch relationships during migration
App crashes on launch after schema changeMissing model in VersionedSchemaInclude ALL models

错误可能原因修复方法
"Expected only Arrays for Relationships"缺少多对多反向关系添加
@Relationship(inverse:)
"The model used to open the store is incompatible"架构版本不匹配验证迁移计划的schemas数组
"Failed to fulfill faulting for..."关系完整性被破坏迁移期间预取关系
架构变更后应用启动崩溃VersionedSchema中缺少模型包含所有模型

Quick Reference

快速参考

Basic Migration Setup

基础迁移设置

swift
// 1. Define versioned schemas
enum SchemaV1: VersionedSchema { /* models */ }
enum SchemaV2: VersionedSchema { /* models */ }

// 2. Create migration plan
enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

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

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )
}

// 3. Apply to container
let schema = Schema(versionedSchema: SchemaV2.self)
let container = try ModelContainer(
    for: schema,
    migrationPlan: MigrationPlan.self
)

swift
// 1. 定义版本化架构
enum SchemaV1: VersionedSchema { /* 模型 */ }
enum SchemaV2: VersionedSchema { /* 模型 */ }

// 2. 创建迁移计划
enum MigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

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

    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )
}

// 3. 应用到容器
let schema = Schema(versionedSchema: SchemaV2.self)
let container = try ModelContainer(
    for: schema,
    migrationPlan: MigrationPlan.self
)

Resources

资源

WWDC: 2025-291, 2023-10195
Docs: /swiftdata
Skills: axiom-swiftdata, axiom-swiftdata-migration-diag, axiom-database-migration

Created 2025-12-09 Targets iOS 17+ (focus on iOS 26+ features) Framework SwiftData (Apple) Swift 5.9+
WWDC:2025-291, 2023-10195
文档:/swiftdata
技能:axiom-swiftdata, axiom-swiftdata-migration-diag, axiom-database-migration

创建时间 2025-12-09 目标平台 iOS 17+(重点关注iOS 26+特性) 框架 SwiftData(Apple) Swift版本 5.9+