问题背景
在测试应用当中,我想要在现有的SwiftData中新增Date字段,每次新增书籍信息时,当新增书籍的时间设置为当前的新增时间。
在当前的视图下,我已经创建了多个书籍评分信息:
SwiftData结构为:
@Model
class Book {
var title: String
var author: String
var genre: String
var review: String
var rating: Int
init(title: String, author: String, genre: String, review: String, rating: Int) {
self.title = title
self.author = author
self.genre = genre
self.review = review
self.rating = rating
}
}
此时SwiftData在数据库中的信息。
新增/修改字段
在代码中新增了Date时间字段,用于存储书籍评分时将存储时间一并保存,以便查看书籍评分的时间。
@Model
class Book {
var title: String
var author: String
var genre: String
var review: String
var rating: Int
var date:Date
init(title: String, author: String, genre: String, review: String, rating: Int, date: Date) {
self.title = title
self.author = author
self.genre = genre
self.review = review
self.rating = rating
self.date = Date
}
}
在存储书籍评分页面中,将保存按钮修改为当前时间:
Button("Save") {
let newBook = Book(title: title, author: author, genre: genre, review: review, rating: rating,date: Date())
modelContext.insert(newBook)
}
问题复现
修改时间字段后,重新运行代码,会发现之前存储的书籍信息已经消失:
但是数据库的字段还是未添加Date字段之前的内容。
现在问题在于,当尝试修改SwiftData中的字段时,会导致修改后的类与SwiftData数据库中的字段不兼容,因此视图中不会显示SwiftData数据。
通过拷贝SwiftData数据库中的一条数据,我们可以看到对应的SQL语句中没有新增的ZDATE字段。
INSERT INTO "main"."ZBOOK" ("Z_PK", "Z_ENT", "Z_OPT", "ZRATING", "ZAUTHOR", "ZGENRE", "ZREVIEW", "ZTITLE") VALUES (1, 1, 1, 3, '11', 'Fantasy', '11', '11111');
这也印证了修改SwiftData并不会实现数据的迁移,同时会存在一些问题。
当我尝试新增数据信息时,发现了严重错误:
SwiftData/ModelContainer.swift:144: Fatal error: failed to find a currently active container for Book
这段代码的意思是存在致命错误:无法找到Book的当前活动容器。
经过排查定位到问题在于,新增书籍信息页面中,保存按钮的代码中涉及Date字段,但实际上SwiftData后台的SQLite数据库并没有该字段。
Button("Save") {
let newBook = Book(title: title, author: author, genre: genre, review: review, rating: rating,date: Date())
modelContext.insert(newBook)
}
这也就引出了本文的主题“SwiftData修改字段导致的崩溃问题”。
修改数据库
因此本次测试的设备是Xcode模拟器,为了尝试解决这一问题,在对应的数据库中新增一个“ZDATE”字段。
重新运行应用后,仍然存在严重错误。
这个问题经过分析,认为虽然是手动修改了Swift数据库结构,但是没有同步代码和数据库schema(数据模型)。SwiftData会自动管理模型和数据库之间的同步关系,直接修改底层数据库可能导致不一致,从而引发运行时错误。
删除数据库
因为使用的是Xcode模拟器,因此采用比较极端的删除大法。删除模拟器的数据库文件。
重新运行Swift代码,SwiftData会根据最新的模型自动生成正确的数据库结构。
根据模拟器的截图,可以看到重新运行后,在展示列表中能够新增并且展示书籍信息的对应时间字段。
但是这一解决方式仅限于模拟器,如果在真机中修改SwiftData字段,就无法删除对应的数据库文件,即使可以找到并删除,但也会存在丢失数据的问题,因此,需要通过数据的迁移来解决这一问题。
迁移数据
重新改为未添加date字段时的SwiftData数据库,并尝试新的解决方案。
新的解决方案为,在新增字段时,将新增字段改为可选类型,避免修改数据库字段。
@Model
class Book {
var title: String
var author: String
var genre: String
var review: String
var rating: Int
var date:Date?
init(title: String, author: String, genre: String, review: String, rating: Int,date: Date) {
self.title = title
self.author = author
self.genre = genre
self.review = review
self.rating = rating
self.date = date
}
}
提交书籍信息按钮,再次添加上date类型。
Button("Save") {
let newBook = Book(title: title, author: author, genre: genre, review: review, rating: rating, date: Date())
modelContext.insert(newBook)
}
并在视图中添加一个日期字段进行展示,因为时间是可选字段,所以展示文本时添加了可选符号以及为nil时的展示内容。
Text(book.date?.formatted(date: .long, time: .omitted) ?? "date nil")
当重新尝试运行应用,发现之前的书籍信息时间字段为data nil,但是新添加的书籍信息是有字段的。
查看SwiftData数据库可以看到,SwiftData实际上能够自动识别模型的变化并且更新了模型。同时因为新增字段使用的是可选类型,也避免因缺失数据引发错误。
这里变相的完成了更新SwiftData模型、SwiftData数据库结构。
配置默认值
当我们迁移数据时,发现SwiftData数据库中datezi段都是空的,因此我们可以在迁移数据的过程中,配置新增字段的默认值。
还是将SwiftData还原到未添加date字段的结构,并新增多条测试数据。
新增测试数据后,重新在SwiftData中新增date字段:
var date: Date?
然后在视图启动代码中添加migrateData方法,这个方法的功能为:检测date字段的值,如果旧的值为nil则填充默认值。
func migrateData(context: ModelContext) {
do {
let allBooks = try context.fetch(FetchDescriptor<Book>())
for book in allBooks where book.date == nil {
book.date = Date() // 设置默认值
}
try! context.save()
} catch {
print("转换失败")
}
}
代码解析
func migrateData(context: ModelContext) {
定义一个函数 migrateData,用于进行数据迁移。
参数 context 是 ModelContext 类型,用于管理 SwiftData 模型的上下文。通过这个上下文,可以读取和修改数据库中的数据。
let allBooks = try context.fetch(FetchDescriptor<Book>())
FetchDescriptor<Book>():这是一个描述符,用来从 SwiftData 数据库中提取 Book 模型的所有记录。
context.fetch():通过上下文执行提取操作,将所有 Book 模型实例从数据库加载到内存中。
try:尝试解包,如果 fetch 操作失败(如数据库读取失败)会被do-catch 捕获错误。
for book in allBooks where book.date == nil {
book.date = Date() // 设置默认值
}
for book in allBooks:遍历从数据库中提取的所有 Book 实例。
where book.date == nil:添加一个过滤条件,只处理 date 字段为 nil 的记录。
book.date = Date():将当前日期(Date())赋值给 book.date,为缺失日期字段的记录设置默认值。
try! context.save()
保存对数据的修改:
将所有更改(如设置了默认值的记录)写回数据库。
try!:强制执行保存操作,如果保存失败,会被 do-catch 捕获保存错误。
运行应用
启动代码可以放在ContentView等含义上下文的视图的onAppear方法中:
@Environment(\.modelContext) var modelContext
var body: some View {
NavigationStack {
...
}
.onAppear {
migrateData(context: modelContext)
}
}
重新启动应用,会发现新增的时间字段是存在默认时间信息的。
总结
目前主要的问题是在SwiftData新增字段后,需要尝试将新增的字段改为可选类型,否则可能会引起各种崩溃。
特别是在真机的环境当中,应该考虑新增字段时给旧的数据提供默认值。
不要尝试手动修改SwiftData数据库,因为会存在SwiftData模型不一致的问题,同样引起严重报错。