SwiftData 迁移深度指南:从入门到“填坑”(下集)

书接上回。 办公室的空气仿佛凝固了。Jason 盯着屏幕,手里那行准备提交的代码停在半空,仿佛那是引爆核弹的按钮。他咽了口唾沫,转头看向 Chloe :"也就是说,如果我现在乱改模型,明天用户升级上来,App 就会当场闪退?" Chloe 推了推眼镜,眼神里闪烁着"智慧的光芒":"没错。这就是为什么下集至关重要。系好安全带,我们要进入深水区了。"


✅ 认清"轻量级迁移"的舒适区

"首先,别把事情想得太复杂,"Chloe 安慰道,"SwiftData 还是很聪明的。只要你的改动在'舒适区'内,它完全可以自己搞定。"

以下变更属于轻量级变更,不需要你写任何自定义逻辑:

  • 增加一个可选 (Optional)属性(比如 notes: String?)。
  • 删除一个属性(数据会被直接丢弃,再见不送)。
  • 把属性从非可选 改为可选(Non-optional → Optional)。
  • 重命名属性(前提是你告诉了它原来的名字)。

这些变动不需要 SwiftData 去"凭空创造"新值。它要么保留旧值,要么移动它,要么在新坑里填个 nil

🏷️ 安全地重命名:别让数据"失忆"

"提到重命名,"Chloe 指着屏幕,"假设 Kevin 那个'事儿妈'突然说:'我觉得 name 这个字段不够高大上,必须改成 title'。"

这时候如果你直接改代码,SwiftData 会认为你删了 name 并加了个全新的 title,结果就是------用户的旧数据全没了。

"这得写迁移脚本了吧?"Jason 问道。

"杀鸡焉用牛刀,"Chloe 敲下一行代码,"用 @Attribute(originalName:) 就行。"

swift 复制代码
@Model
final class Exercise {
  // 👇 告诉 SwiftData:这货以前叫 "name",别把数据丢了!
  @Attribute(originalName: "name")
  var title: String

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

这样,数据库里的列名可能还是旧的(或者底层做了映射),但你的代码里已经可以用高大上的 title 了。


🚫 警告:轻量级迁移的"禁区"

"但是,"Chloe 话锋一转,语气变得严肃,"一旦你跨过那条线,轻量级迁移就得歇菜。"

当新 Schema 引入了旧数据无法满足的新要求时,或者说,当 SwiftData 无法自动推断如何从旧模型变到新模型时,你就麻烦了。

典型的"翻车"场景包括:

  1. 新增非可选属性且没有默认值(旧数据里这个字段是空的,你又不让它空,SwiftData 会崩)。
  2. 任何需要转换步骤 的变更:
    • 解析/组合值(比如把 fullName 拆成 firstNamelastName)。
    • 合并或拆分实体。
    • 改变值的类型(比如 StringInt)。
    • 数据清理(去重、格式化字符串、修复无效状态)。

"这时候,"Chloe 拍了拍 Jason 的肩膀,"你就得进入**手动迁移(Manual Migration)**的领域了。"

⚠️ 关于"默认值"的一个陷阱

Jason 插嘴道:"如果我加个非可选属性,但在 init 里给个默认值,不就完事了吗?"

"大错特错! "Chloe 的声音提高了一个八度,"这是新手最容易踩的坑。Swift 初始化器里的默认值,并不意味着磁盘上的旧数据在迁移时会自动获得这个值。"

如果你引入一个必须存在的字段(Required Field),请默认你必须显式地回填它,除非你在真机上用真实的旧数据测试过。


🛠️ 手动迁移:SchemaMigrationPlan 的实战

"来,我们实战一下。"Chloe 打开了一个新文件,"假设 Kevin 要求必须记录运动的创建时间 createdAt: Date,而且这个字段不能是空的。"

我们有两个选择:

  • 选项 A(认怂版): 把它设为 Date?,旧数据就是 nil。这很安全,但数据模型就不纯粹了。
  • 选项 B(硬核版): 手写迁移,保持属性非可选,给旧数据填上当前时间。

"作为有追求的工程师,我们当然选 B。"Chloe 开始敲代码。

首先,定义 V1 和 V2:

swift 复制代码
import SwiftData

// V1 版本:没有 createdAt
enum ExerciseCreatedAtSchemaV1: VersionedSchema {
  static var versionIdentifier = Schema.Version(1, 0, 0)
  static var models: [any PersistentModel.Type] = [Exercise.self]

  @Model
  final class Exercise {
    var name: String
    init(name: String) { self.name = name }
  }
}

// V2 版本:新增了非可选的 createdAt
enum ExerciseCreatedAtSchemaV2: VersionedSchema {
  static var versionIdentifier = Schema.Version(2, 0, 0)
  static var models: [any PersistentModel.Type] = [Exercise.self]

  @Model
  final class Exercise {
    var name: String
    var createdAt: Date // 👈 必须有值!

    // 注意:这里的默认值只对新创建的对象有效,对迁移无效!
    init(name: String, createdAt: Date = .now) {
      self.name = name
      self.createdAt = createdAt
    }
  }
}

接下来,我们编写自定义迁移阶段来给旧数据"补课":

swift 复制代码
enum AppMigrationPlan: SchemaMigrationPlan {
  static var schemas: [any VersionedSchema.Type] = [
      ExerciseCreatedAtSchemaV1.self, 
      ExerciseCreatedAtSchemaV2.self
  ]
  static var stages: [MigrationStage] = [v1ToV2]

  static let v1ToV2 = MigrationStage.custom(
    fromVersion: ExerciseCreatedAtSchemaV1.self,
    toVersion: ExerciseCreatedAtSchemaV2.self,
    willMigrate: { _ in 
        // 迁移前清理,暂时用不到
    },
    didMigrate: { context in
      // 👇 重点来了!在这里我们获取所有 V2 模型(此时 createdAt 是无效状态)
      let exercises = try context.fetch(FetchDescriptor<ExerciseCreatedAtSchemaV2.Exercise>())
      
      // 遍历所有数据,手动赋予当前时间
      for exercise in exercises {
        exercise.createdAt = Date()
      }
      
      // 💾 保存更改,这一步至关重要
      try context.save()
    }
  )
}

🔍 深入理解:willMigrate vs didMigrate

Jason 看着代码有点晕:"这俩闭包有啥区别?"

  • willMigrate :在 Schema 变更之前 运行。这时候你只能看到旧数据。通常用来做数据清理,比如去重。你没法在这里给新属性赋值,因为新属性还不存在。
  • didMigrate :在 Schema 变更之后 运行。这时候你看到的是新模型。这是你回填数据、赋值的主战场。

"记住,"Chloe 总结道,"我 90% 的工作都是在 didMigrate 里完成的。"


🌉 终极挑战:复杂的"桥接版本"迁移

"好了,现在给你上点强度。"Chloe 露出了大魔王般的笑容,"假设你的 V1 模型里,WeightData(举铁数据)把重量、组数、次数都存在自己身上。"

现在 Kevin 想把结构改成:WeightData 包含一个 PerformedSet(组)的列表。

这是一个结构性的巨变。你不能直接把原来的属性删了,因为你需要用原来的数据去创建新的 PerformedSet 对象。

"这就需要用到**桥接版本(Bridge Version)**策略:"

  1. V2 (桥接版):保留旧字段(标记为 legacy),同时添加新关系。
  2. V3 (清理版):等数据迁移完,再把旧字段删掉。

来看看 V2 长什么样:

swift 复制代码
enum WeightSchemaV2: VersionedSchema {
  // ... 版本号定义 ...

  @Model
  final class WeightData {
    // 👇 用 originalName 留住旧数据,改名叫 legacy
    @Attribute(originalName: "weight")
    var legacyWeight: Float

    @Attribute(originalName: "reps")
    var legacyReps: Int
    
    // 👇 新增的关系
    @Relationship(inverse: \WeightSchemaV2.PerformedSet.weightData)
    var performedSets: [PerformedSet] = []
    
    // ... init ...
  }

  // 👇 新增的模型
  @Model
  final class PerformedSet {
    var weight: Float
    var reps: Int
    var weightData: WeightData?
    // ... init ...
  }
}

然后,在 didMigrate 里,把 legacy 的数据搬运到新的 PerformedSet 里:

swift 复制代码
static let migrateV1toV2 = MigrationStage.custom(
  fromVersion: WeightSchemaV1.self,
  toVersion: WeightSchemaV2.self,
  willMigrate: nil,
  didMigrate: { context in
    // 获取桥接版数据
    let allWeightData = try context.fetch(FetchDescriptor<WeightSchemaV2.WeightData>())

    for weightData in allWeightData {
      // 🏗️ 利用旧数据创建新对象
      let performedSet = WeightSchemaV2.PerformedSet(
        weight: weightData.legacyWeight,
        reps: weightData.legacyReps,
        sets: weightData.legacySets,
        weightData: weightData
      )
      
      // 🔗 建立关联
      weightData.performedSets.append(performedSet)
    }
    
    try context.save()
  }
)

等你发布了这个版本,用户升级上来,数据成功搬运。下个版本 V3,你就可以放心地把 legacyWeight 这些字段删掉了。


📝 总结:别当"赌狗"

窗外天色已亮,Jason 看着满屏严谨的迁移代码,长出了一口气。

Chloe 合上笔记本,做了最后的总结:

  1. 版本化是基本功 :把 VersionedSchema 当作发布产物,不要在开发过程中乱升版本,发版时再一次性升。
  2. 能自动就自动:增加可选字段、改名(带映射)都很安全。
  3. 不能自动就手写 :一旦需要"凭空创造"数据(如非可选字段),立马用 SchemaMigrationPlan
  4. 复杂情况用桥接:不要试图在一个版本里完成"大换血",分两步走,稳得一笔。
  5. 一定要测试! 别只在模拟器上删了 App 重装。要模拟真实场景:装旧版 -> 塞满脏数据 -> 覆盖安装新版。

"好了,"Chloe 站起身,"去给 Kevin 演示吧。"

就在这时,办公室的门被推开了。Kevin 顶着黑眼圈走了进来,手里拿着新的需求文档:"嘿,伙计们,关于那个运动模型,我昨晚想了一宿,觉得我们应该把所有数据都改成区块链存储......"

Jason 和 Chloe 对视一眼,异口同声:"滚!"

(全剧终)

相关推荐
大熊猫侯佩1 小时前
SwiftData 迁移深度指南:从入门到“填坑”(上集)
数据库·swift·编程语言
我星期八休息1 小时前
Linux系统编程—mmap文件映射
java·linux·运维·服务器·数据库·mysql·spring
桌面运维家2 小时前
基于vDisk技术的Vol云桌面技术解析
数据库
放下华子我只抽RuiKe52 小时前
FastAPI 全栈后端(八):部署与运维
运维·数据库·react.js·oracle·数据挖掘·前端框架·fastapi
J.P.August2 小时前
Oracle RAC双活存储配置三个关键点
数据库·oracle
弹简特2 小时前
【Java项目-轻聊】10-实现会话管理模块
java·开发语言·数据库
网管NO.12 小时前
MySQL 8.0 JSON 操作 | 新增 / 查询 / 修改,适配新兴业务
数据库·mysql·json
yurenpai(27届找实习中)2 小时前
Feed 流推送与附近商户:从推模式到 GeoHash,一条 Timeline 的完整旅程
java·数据库·oracle·feed
IT策士2 小时前
MySQL 系列:第1篇 数据库时代与MySQL
数据库·mysql