SwiftData踩坑记(续)

上次提到使用SwiftUI和SwiftData时候的几个问题,后来也陆续解决了。

SwiftUI的「返回」导航

先说第一个关于SwiftUI的问题。

这里实际上我想做的大概是,在创建新数据的过程中,取消创建并且回到主页面。原本的实现是这样的:

1
2
3
4
5
6
Button {
pathManager.path.removeLast(2)
} label: {
Label("Discard", systemImage: "minus")
.padding(12)
}

也就是说在堆栈里去掉最后两页。不过removeLast似乎在这种情况下不会有反应。临时的解决方案是在Button的回调里,在调用removeLast之前先往SwiftData塞一个空白对象然后立即删除,来触发SwiftData的IO操作。

1
2
3
4
5
6
let dummy = Item(timestamp: .now)

modelContext.insert(dummy)
modelContext.delete(dummy)

pathManager.path.removeLast(2)

这种情况下,removeLast(2)就可以正常工作了。不过也有人建议把removeLast(2)换成removeLast(path.count),应该也能正常运行并且可能更符合API设计。

但其实因为这里不需要做额外的操作(只有确认的情况下才需要塞入数据,取消的时候数据库没有变动),所以其实可以直接用NavigationLink替代:

1
2
3
4
NavigationLink(value: PageType.home, label: {
Label("Discard", systemImage: "minus")
.padding(12)
})

那么这个问题也就解决了。

接下来讨论SwiftData相关的三个问题:

  1. 对Relationship的处理
  2. Enum的解码问题
  3. 对重复字段的处理
  4. 随机出现的UI冻结问题

前两个问题其实多少有点相连(或者说至少在报错的时候),这也是之前出现问题的时候比较困扰我的原因。

Relationship的decode

这个问题在把数据库导出到JSON再试着导入的时候出现。

大体上来说,我这里的数据模型可以被精简为这样的结构:

1
2
3
4
5
6
7
        ┌─────────┐ 1
│ Company │┄┄┄┄┄┄┄┄┄┄┄╮
└─────────┘ ┆
┆ *
┌─────────────┐ ┌─────────────┐
│ Application │┄┄┄┄┄┄┄┄┄┄│ Description │
└─────────────┘ 1 1 └─────────────┘

导入导出JSON的时候,是从Application一端开始,也就是说JSON的schema大概可以看成是一个[Application]类型的数组。导出的时候没有任何问题。

重新导入数据的时候,程序会停在Description类型里面的var company: Company?字段。报错内容是Thread 1: EXC_BREAKPOINT(code=1)

展开的宏大概长这样,报错的指针在self.getValue那里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
@storageRestrictions(accesses: _$backingData, initializes: _company)
init(initialValue) {
_$backingData.setValue(forKey: \.company, to: initialValue)
_company = _SwiftDataNoType()
}

get {
_$observationRegistrar.access(self, keyPath: \.company)
return self.getValue(forKey: \.company)
}

set {
_$observationRegistrar.withMutation(of: self, keyPath: \.company) {
self.setValue(forKey: \.company, to: newValue)
}
}
}

调用栈的上一层在SwiftUI的渲染层里面,也就是例如这里面的company字段。

1
2
Text(app.description?.company?.name ?? "")
.font(.subheadline)

这里的问题原因还是在Company数据类型里。这个数据类型大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Model
final class Company : Codable {

var name: String
var website: String

init(...) {
...
}

@Relationship(deleteRule: .cascade, inverse: \Description.company)
var positions: [Description] = []

enum CodingKeys: CodingKey {
case name, website
}

required init(from decoder: Decoder) throws {
guard let context = decoder.userInfo[CodingUserInfoKey(rawValue: "modelcontext")!] as? ModelContext else {
fatalError()
}
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
self.website = try container.decode(String.self, forKey: .website)
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(website, forKey: .website)
}
}

于是也就可以看到问题出在哪了,作为relationship的Description类型字段没有被encode/decode过程处理,从JSON decode回来的数据不完整,所以自然就会报错了。

但是反过来说,这里又该怎么encode和decode呢,Company本身也是Description里面的字段,如果在Company里面再反过来encode那个Description数组(position字段),就会导致死循环。

在这个相对简单的数据模型里面,一个解决方法就是把decode的入口反过来放在Company类型里面,也就是JSON里面存放的是[Company]而不是[Application]数据类型。这样一来,就可以保证不会出现「many to one」的情况。

这个修改还是需要改动不少东西,毕竟程序的主页的数据源就是一个[Application],不过也不算很难改。改完重新输出再输入,问题解决。

当然了,如果数据结构比较复杂,有很多多对多的relationship,或者无法简单地抽取出一个入口点,那么也许就需要一个单独的全局Model来定义输入输出的JSON的格式,把每个数据类型以一组列表来存储下来,再用例如UUID之类的机制把它们串联起来。不过这种情况感觉也会相对比较复杂就是了。

Enum的decode

然后继续测试的时候,发现许多时候输出的JSON里面只有一部分字段被输入回来,比如说6个Application可能只有3个被导入回来,报错内容是「Invalid number of keys found, expected one」。

考虑到之前也遇到带关联值的enum decode回来的时候出现「Invalid number of keys found, expected one」的错误,也就参考这里试着实现了一个手动decode/encode的实现,不过这个思路会导致程序直接在新建对象后报错,原因是访问enum的时候出错,所以这个思路也就只能放弃了。

再次检查,发现丢字段的情况不仅限于有关联值的enum,只要enum的字面量含有_下划线就会出现,意识到可能是JSONDecoder的decode策略的问题。检查一下发现decode策略是把snake case转换成camel case。于是问题原因应该就很清晰了,因为我的enum也是snake case的名字,例如not_started

把enum名字也全部改为camel case(也就是notStarted这种)之后问题解决。但这就要求enum和类型字段必须用相同的命名惯例,或者不进行字段名称的转换。

重复字段的处理

现在JSON的输入应该基本上OK了,但是会发现一些字段会被重复导入,比如说,如果不同company里面都有相同的字段例如:

1
2
3
4
category: {
name: "R&D",
icon: null
}

那么导入后的数据库里面就会有很多一模一样的R&D字段。

第一个想法就是在对应的model里面加@Attribute(.unique)注解。但加入这个后,并不会只创建一个对象,而是在创建重复对象的时候直接报错退出程序。

这么一来,可能就得换个思路了……考虑到JSON到SwiftData的过程并不是那么好控制,也许先让它重复,先把SwiftData的数据库建造出来,再对每个字段进行去重操作?

简单写出了实现的算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
...
let decoded = try decoder.decode([Company].self, from: data)

/// Mark: Processing de-duplication of entries

// Fetch descriptors for retrieving SwiftData data
let categoryDescriptor = FetchDescriptor<Category>(sortBy: [SortDescriptor(\.name)])
let descriptionDescriptor = FetchDescriptor<Description>()

// find out all inserted types and get un-duplicate names
let insertedTypes = try modelContext.fetch(categoryDescriptor)
let categoryNames = Set(insertedTypes.map { $0.name })

// Build two groups of these category types: first occurences of each one; and their duplicata
let firstCategories = categoryNames.map { name in insertedTypes.first(where: { $0.name == name }) }
let duplicatedCategories = insertedTypes.filter { !firstCategories.contains($0) }

// Retrieve all descriptions
let existingDescs = try modelContext.fetch(descriptionDescriptor)

// Rearrange category types in case of duplicata
existingDescs.forEach { job in
if let match = duplicatedCategories.first(where: { $0 == job.type }) {
job.type = firstCategories.first(where: { $0!.name == match.name })!
}
}

// Remove all duplicated types
duplicatedCategories.forEach {
modelContext.delete($0)
}

显而易见的是这套做法肯定会慢很多,不过至少还是能用的。

随机发生的冻结情况

这之后,也发现了新的问题:导入数据的时候可能会随机性的发生窗口冻结的事情。

稍微打断点发现抛出的错误是EXC_BAD_ACCESS(code=1)

按照这里的说法,需要把SwiftData相关的内容(也就是我这里的数据导入相关逻辑)放进@MainActor标记的函数里。

稍微把这段函数抽出来加上标注后,问题似乎也解决了。

到这里,上次提到的所有问题也都解决了。

这次大概也就先到这里了。


SwiftData踩坑记(续)
http://inori.moe/2023/12/13/swiftdata-cont/
作者
inori
发布于
2023年12月13日
许可协议