Swift、SwiftUI和SwiftData的一些坑

(用SwiftUI写小半个月小项目后的一些心得)

最近在用SwiftUI写一个简单的程序,其实一开始只是觉得SwiftUI也许很适合画图,然后发现Swift Charts画图好像很好看,就想画点看起来比较华丽的图表。不知不觉就写多了……然后就把SwiftUI、SwiftData和Swift Charts都用了一遍。

也差不多小半个月的时间,大概写出了第一个版本,感觉是时候总结一下一些Swift、SwiftUI、SwiftData的坑了呢。毕竟感觉讨论Swift相关的技术栈的资料并不是很好找,记下来说不定以后也能派上用场。

看起来没那么简单的Preview

提到SwiftUI的话,大概首先看到的就是那个看起来就像实机运行的Preview了。修改UI上面的元素,就可以实时预览在不同机器上的效果,感觉也很方便。

不过在一个稍微比简单的Demo复杂一点的,需要定义数据类型并且使用SwiftData作为ORM的场景下,可能就有点麻烦了。也许在一个简单的没有参数的环境下,Preview也可以正常运行,加一两个let或者var进去也可以,但不知什么时候就不行了。

可能会有好几种出错的原因,一种是因为Preview没有被隔离在主线程上,简单搜一下也可以看到很多人给出的建议,就是用@MainActorMainActor.assumeIsolated把Preview的代码入口包装起来。当然,这样的话,调用视图的地方需要先return出去。

另一种黑屏的情况和SwiftData有关,大体上,如果View通过构造器传参数进去的话,这个参数又是一个SwiftData的@Model数据类型,就很有可能触发这种情况,尤其是Model里面有relatonship的情况下。我也找了一段时间没找到解决办法,直到看到有人在stackoverflow上提到「不要直接把dummy data传进视图里」,然后想到也许应该先把数据存进model context里,再把container替换掉原本的modelContainer(for: Type.self, …

稍微看了一下,主程序的启动机制大约也是类似,先用一个IIFE来构建一个model container,然后把model container通过.modelContainer(container)塞入视图里。这里的Schema里面就可以把之前定义的SwiftData中需要用到的Model数据类型都放上来,然后在构造container之后和开始程序之间,也可以把预先定义的数据准备好。

考虑到我这里几乎每个(需要preview的)视图都会用到所有的数据类型,我就把这个初始化函数提取出来了。

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
@MainActor
func initializePreviewContainer() -> ModelContainer {
let previewContainer: ModelContainer = {
let schema = Schema([
Item.self, // 这个Item类型是一开始就有的,后面有用处

Application.self,
Company.self,
... // 还有其他所有有需要的数据类型
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer for preview: \(error)")
}
}()

// 因为Preview的时候不希望保存上一次的数据,所以在这里逐个清空原有的数据
do {
try previewContainer.mainContext.delete(model: Application.self)
try previewContainer.mainContext.delete(model: Company.self)
... // 其他需要抹除的数据类型
} catch {
fatalError(error.localizedDescription)
}

return previewContainer
}

这样的话,拿到ModelContainer后,在一个简单的Preview里面大概可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#Preview {
MainActor.assumeIsolated {
// 调用之前的Utils方法
var previewContainer: ModelContainer = initializePreviewContainer()

// 为这个预览提供初始化的数据
let applications = [
Application(...),
...
]

applications.forEach {
previewContainer.mainContext.insert($0)
}

// 因为视图也需要参数,所以这里也传入
// 不过实际上很多情况下都可以用@Query或者@Publisher等机制间接传入而不需要直接传参
return AnalyticsView(applications: applications)
.modelContainer(previewContainer)
}
}

于是一个完整的Preview就构造起来了,甚至可以进行跨页浏览和交互。

当然,这样的Preview可能和Preview的初衷有点违背,也许对某些视图来说,本来Preview就该crash吧(比如说可能视图过于复杂)。这里只是我自己的一个想法而已,相应的,这样加载的话Preview的第一次渲染也会稍微慢点。所以到底该怎么用见仁见智了。

简单实现一个带提示的Editor

SwiftUI自带有TextField和Editor两种不同的输入方式。Editor大概相当于HTML的textarea标签。不过,虽然TextField可以放个placeholder,Editor默认是不带这个的。

之前做自己的实验项目的时候也想了一下怎么解决这个placeholder的问题,大概做出来的结果是这样:

1
2
3
4
5
6
7
8
9
ZStack(alignment: .topLeading) {
TextEditor(text: $text)
.frame(height: 160)
if (text == "") {
Text(placeholder).fontWeight(.light).foregroundColor(.black.opacity(0.25))
.padding(8)
.padding([.leading], 0)
}
}

也就是用一个ZStack把一个预先设置的文本和一个Editor并在一起,如果用户输入任何文字,这个文本就会消失,等用户删除所有文字后才会重新出现。

不过之前的文本框也有个问题,那就是用户如果按在文字上,文本框不会被触发。

解决办法倒是很简单,在Text上加一个修饰就行了:

1
2
3
4
Text(placeholder).fontWeight(.light).foregroundColor(.black.opacity(0.25))
.padding(8)
.padding([.leading], 0)
.allowsHitTesting(false)

var text: String前面的@State改为@Binding,那么一个简单的控件就做好了。SwiftUI的Binding还是蛮方便的,感觉有点像Vue的响应式的概念呢。

自动移动Focus到新插入的字段

这个大概算是我一直想做的效果,也就是在一个长列表里,用户按下「新增」按钮后,会在列表里自动添加一个空白项,并且把光标自动移动到那里。用户输入完之后,这一项会自动存下来。当然了,如果什么也没有输入,那这一项就会被删除。

在SwiftUI里,这个设计的实现还是很直观的。我这里的话,首先定义一下可能的编辑状态:

1
2
3
enum EditState {
case none, adding // 以后如果有需要,也可以添加新的状态,比如editing或者deleting
}

然后在视图里添加数据源,例如这里的CareerType。除此之外还有一个临时存储用户输入的text,和一个判断当前光标在哪里的@FocusState

1
2
3
4
5
@Query private var careerTypes: [CareerType]

@State private var text = ""

@FocusState private var focusedCareer: CareerType?

因为列表里面可能会有一个「空元素」,为了和正常的列表区分,所以添加一个专门的计算属性。这里也给前面提到的CareerType增加一个static的空元素了。

1
2
3
4
5
6
7
var editingList: [CareerType] {
if (editing == .adding) {
return careerTypes + [CareerType.empty]
} else {
return careerTypes
}
}

简化的代码实现如下:

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
ForEach(editingList) { item in
if item == CareerType.empty {
TextField("New item", text: $text)
.focused($focusedCareer, equals: item) // 把这个TextField和@FocusState绑定起来
.onAppear {
// 在TextField刚出现的时候,把光标移动到这个文本框上
// 实际上就是把FocusState绑定到这个`CareerType.empty`值上
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
self.focusedCareer = item
}
}
.onChange(of: focusedCareer) { foc in
// 监测Focus的状态,在Focus消失(也就是用户结束输入)的时候被激活
if (foc != item) {
// 在用户输入不为空的情况下
if (text != "") {
// 把用户输入转为CareerType类型的新值存入数据库
modelContext.insert(CareerType(name: text))
}
text = ""
editing = .none
}
}
} else {
// 普通的字段
HStack {
Text(item.name)
}
}
}

Focus这个概念其实还是蛮好用的,之后在实现「修改已有字段」的功能的时候也用了类似的思路,毕竟文本直接改文本框还是不太好做的。

使用SwiftData存储复杂对象

作为一个轻量级的ORM,SwiftData感觉还是蛮好用或者说无感的,不过,数据类型稍微复杂一点也会发现一些坑。

这里就遇到了好几个情况,第一个是在Model字段的getValue上报错EXEC_BREAKPOINT(code=1)的问题。

这里提到的解决方法大概就是,relationship两边的对象都单独创建,然后再用一个赋值语句把它们连接起来。

另一个问题是「Illegal attempt to establish a relationship between objects in different contexts」,例如这里。这里提到的是在Model里面,每个跟其他Model相连的字段(relationship)都必须是可空或者提供默认值的。

简单改一下, 存储对象的地方的代码大概就是这样(应该能猜出数据结构的样子大概是什么样的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
modelContext.insert(jobDescription)

let createdApplication = Application()

modelContext.insert(createdApplication)

createdApplication.jobDescription = jobDescription
createdApplication.resume = Resume(content: resume)
createdApplication.cover = CoverLetter(content: cover)
createdApplication.dateCreated = creationDate

let newEvent = Event(type: .preparation, updateTime: creationDate)
modelContext.insert(newEvent)
newEvent.type = .preparation

createdApplication.events.append(newEvent)

当然,Model里面也预先定义了无参构造和字段的默认值。

虽然看起来有点像Hibernate就是了……不过这样一来就不会有什么问题了。

说到这里也顺便提一下EXEC_BREAKPOINT这个看起来很吓人的东西了。不过,如果报错的位置往上的调用栈有自己可以碰到的地方,可以用一段do ... catch来包围起来,这样在catch段里就可以拿到error,然后比如说print一下看看错误原因是什么。

当然,如果报错的地方在View里面,或者是SwiftData自动生成的代码部分,那就难说了。

使用NavigationStack跨页面导航

因为是多页面的应用,也就需要一套路由和导航的机制。看了这个之后就决定用NavigationStack了。

不过NavigationLink那边提供了好几套API,具体来说,有以下两种:

  • NavigationLink(destionation:label)
  • NavigationLink(value:label)

用Destination直接呼叫另一个视图的算是旧API,传value进去的是新API。两个混用的话,导航会乱掉。所以后面也就花了一段时间把代码里所有用旧API的地方都改掉了。

简单说的话,NavigationStack下,NavigationLink(value:label)的作用是按下一个对应的元件后立刻产生一个值然后根据这个值来导航到目的地。除此之外,也可以用在Button的closure里在path里append或者remove值的方法,可能更适用于在按下按钮到导航之间需要预先进行一段计算的情况。最后一种办法就是presentationMode.wrappedValue.dismiss()了,可以回退一页。

不过要让页面正确导航的话,还得在NavigationStack被声明的那一层视图里加入一个NavigationPath的变量,然后通过参数,环境变量等办法把它传递到所有的子页面里。当然了,对于每个NavigationLink或者path.append(value)里面被压入的数据类型,都要在NavigationStack下的navigationDestination(for:destination)里面逐个声明并写入哪个类型对应哪个视图。

一个比较好的办法就是单独定义一个enum然后对enum去switch case:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.navigationDestination(for: PageType.self) { dest in
switch dest {
case .home:
ApplicationListView(pathManager: $pathManager)
.navigationBarBackButtonHidden(true)
case .settings:
SettingsView(pathManager: $pathManager)
.navigationBarBackButtonHidden(true)
...
}
}
.navigationDestination(for: ...) {
...
}

enum PageType {
case home, settings, ...
}

另外一个需要注意的地方就是只有顶层视图可以被NavigationStack包围,所有的子视图里都不应该再出现NavigationStack了。

感觉新的导航API还是蛮好用的,但是需要注意的细节也很多。

在Swift里设计树状数据结构的继承关系

对于习惯Kotlin的人来说,Swift的继承关系有点奇怪,有许多东西也不太方便弄。想要设计一个有一个基类和两个子类的树状结构的时候,就遇到这种情况了。

首先是「接口提供默认方法」的这种常用思路,Swift里面的写法大概是,先写个Protocol,然后写个基类,再用typealias把它们连起来。

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
protocol PaintSourceProtocol {

var label: String { get set }

func traverse() -> [PaintEdge]

func count(label: String) -> Int

func count() -> Int
}

class PaintSourceClass : Hashable {

var identifier: String {
return UUID().uuidString
}

public func hash(into hasher: inout Hasher) {
return hasher.combine(identifier)
}

public static func == (lhs: PaintSourceClass, rhs: PaintSourceClass) -> Bool {
return lhs.identifier == rhs.identifier
}

}

typealias PaintSource = PaintSourceClass & PaintSourceProtocol

这样的话,如果子类继承PaintSource,就可以在获得PaintSourceProtocol的所有约束的情况下,也可以获得PaintSourceClass的Hashable实现。

然后这里也直接用UUID来实现hash,虽然会慢许多,但是考虑到这个程序的数据集不会过大,同时仅靠每个叶子结点自身的数据很可能出现hash重复的情况,这也算是一种妥协了吧。

另一个吐槽点大概是Swift的switch不会自动推导类型,需要手动标记,例如这样:

1
2
3
4
5
6
7
8
9
return self.children
.flatMap { child in
switch child {
case let e as PaintEdge: [e]
case let n as PaintNode: n.traverse()
default:
[] as [PaintEdge]
}
}

这里如果default里面的[]没有标记类型,会被推导为Void,然后表达式类型不匹配。在有些表达式里,还会出现例如「Cannot convert value of type '[any CouldBeEmptyEdge]' to specified type 'String'」这样的情况。

所以为了省事可能还是每个switch arm都标一下好了(

存在的问题:

  1. 从path里面remove可能会出现第一次没反应第二次直接挂掉的情况。在这之前塞个dummy data然后立刻删除可以解决问题(这也就是前面说保留Item的作用)。
  2. 在decode JSON到SwiftData的时候,会在decode enum的时候报错「Invalid number of keys found, expected one」的错误导致decode失败。
  3. 在把encode过的JSON decode回来的时候,会出现类似前面提到的遇到relationship时不分开赋值而是直接在构造器里赋值一样的「illegal attempt to establish a relationship between objects in different contexts」错误。因为decode过程依赖于model的required init构造器,也就是在构造器内部对字段进行赋值,我本来的想法是在考虑要不要不依赖JSONDecoder,手动读取存入数据结构或者手写个JSON出来了。

这几个问题之后也解决了,在下一篇文章里会讨论它们的解决思路。

小结

感觉SwiftUI总体来说还是蛮有趣的,可以用来画出美观的UI。不过这一整套框架里面的坑倒是也不少,能查到的参考资料也比较缺失。

不过稍微弄一下也能弄出简单的应用,感觉也还是很有成就感的一件事呢。


Swift、SwiftUI和SwiftData的一些坑
http://inori.moe/2023/12/09/swiftui-and-swiftdata/
作者
inori
发布于
2023年12月9日
许可协议