酱酱不惊

Break my life, rebuild it

0%

SwiftUI State的生命周期研究

本文并不是介绍State基本使用的文章,需要你对SwiftUI有基本的了解。

首先需要明确的是,State是有生命周期的。如果只是简单的视图,基本上无需特别考虑这个问题。但随着视图复杂起来后,如果不考虑生命周期,就可能产生一些不符合预期的现象及潜在的性能问题。

State生命周期结束后的影响

让我们通过下面的示例来看看,当State生命周期结束时对视图状态的影响。在示例中有一个Toggle,用于控制是否显示PlayButton。

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
struct PlayButton: View {

@State private var isPlaying: Bool = false // Create the state.

var body: some View {
VStack {
Button(isPlaying ? "Pause" : "Play") { // Read the state.
isPlaying.toggle() // Write the state.
}
}
}
}

struct ContentView: View {

@State private var showPlayButton = true

var body: some View {
VStack {
Toggle(isOn: $showPlayButton) {
Text("Show Play Button")
}

if showPlayButton {
PlayButton()
}
}
.padding()
}
}

操作步骤:

  1. 点击PlayButton视图的Button,按扭标题这时显示成Pause。
  2. 点击Toggle,隐藏PlayButton。
  3. 再次点击Toggle,显示PlayButton。

这个时候PlayButton的标题显示的是Play,可见isPlaying的值已经被重置了。

如果你不清楚这里发生了什么,请继续往下看。

利用Self._printChanges()了解视图的变化

为了搞清楚视图发生了什么,我们可以通过Self._printChanges()打印视图变更的日志,来分析视图因为什么原因导致了变更。另外,在PlayButton类型中,还添加了一个uuid属性,通过这个属性我们可以识别当前的实例是否重建过,这是一个非常重要的变化。

更新后的代码:

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
33
34
35
36
struct PlayButton: View {

@State private var isPlaying: Bool = false // Create the state.

private let uuid = UUID()

var body: some View {
let _ = Self._printChanges()
VStack {
Button(isPlaying ? "Pause" : "Play") { // Read the state.
isPlaying.toggle() // Write the state.
}
Text("UUID: \(uuid)")
}
}

}

struct ContentView: View {

@State private var showPlayButton = true

var body: some View {
let _ = Self._printChanges()
VStack {
Toggle(isOn: $showPlayButton) {
Text("Show Play Button")
}

if showPlayButton {
PlayButton()
}
}
.padding()
}
}

再按上述的步骤操作界面,我们来分析一下打印的视图变化日志。

界面首次展示

1
2
ContentView: @self, @identity, _showPlayButton changed.
PlayButton: @self, @identity, _isPlaying changed.

Self._printChanges()打印的日志,基本上可以分为3类:@self、@identity和其它。
当包含@self时,表示视图的值发生了变化,但View是不可变,所以这里可以理解视图进行重新创建。
当包含@identity时,表示身份标识变化导致变更。
其它的可以归类为视图的依赖项。

点击PlayButton视图的Button

1
PlayButton: _isPlaying changed.

当PlayButton中的Button被点击后,只有PlayButton需要重新计算body。

点击Toggle

1
ContentView: _showPlayButton changed.

当ContentView的Toggle被点击后,只有ContentView需要重新计算body。

再次点击Toggle

1
2
ContentView: _showPlayButton changed.
PlayButton: @self, @identity, _isPlaying changed.

当ContentView的Toggle再次被点击后,除了_showPlayButton的变更外,还可以看到PlayButton的变更日志,跟首次展示时一模一样。通过PlayButton中显示的UUID,可以看到其值也发生了变化。

为什么PlayButton的identity会发生变化?

当showPlayButton为false的时候,显示的视图树中并不包含PlayButton,这个时候PlayButton的生命周期也就结束了。当视图生命周期结束时,其声明的State值也结束了生命周期,进而被释放掉。

当showPlayButton的值再次变成ture后,PlayButton重新加入到显示树中,故重新赋予了identity。而_isPlaying也被SwiftUI重新托管。

关于视图的identity

视图的identity,在SwiftUI的文档中较少提及,但在WWDC的视频中做了非常详细的说明:Demystify SwiftUI

简单的说,每个视图都有identity,这个被称为Structural identity,根据视图的类型与所在的位置生成identity。除此之外,还可以自行设置Explicit identity,即通过id(_:) modifier主动设置的。

视图的identity也会对动画产生影响,但不是本文的重点,具体情况可以查看上面的Demystify SwiftUI。

视图的value不等于视图的identity

Untitled.jpg

这一点对理解identity非常重要,即视图的value并不是视图的identity。视图会根据情况不断重复创建,而视图的identity并不会发生变化。为了展示这种情况,我们添加一个名为count的State,然后在CountText中显示。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
struct PlayButton: View {

@State private var isPlaying: Bool = false // Create the state.

private let uuid = UUID()

var body: some View {
let _ = Self._printChanges()
VStack {
Button(isPlaying ? "Pause" : "Play") { // Read the state.
isPlaying.toggle() // Write the state.
}
Text("UUID: \(uuid)")
}
}

}

struct CountText : View {

let count: Int

private let uuid = UUID()

var body: some View {
let _ = Self._printChanges()
VStack {
Text("Count: \(count)")
Text("UUID: \(uuid)")
}
}
}

struct ContentView: View {

@State private var showPlayButton = true

@State private var count = 0

var body: some View {
let _ = Self._printChanges()
VStack {
Toggle(isOn: $showPlayButton) {
Text("Show Play Button")
}

if showPlayButton {
PlayButton()
}

Button("+ 1") {
count += 1
}

CountText(count: count)
}
.padding()
}
}

当点击+ 1按扭时,通过Self._printChanges()打印的日志,可以看到CountText的实例进行了重建,通过变化的UUID你就能发现这一点,但没有@identity的日志信息。

1
2
3
ContentView: _count changed.
PlayButton: @self changed.
CountText: @self changed.

State的生命周期等于视图的生命周期

Untitled2.jpg

在前面的示例中,PlayButton会根据showPlayButton的状态来判断是否显示在界面上,这个逻辑就导致了PlayButton的identity发生了变化,进而导致State的生命周期变化。

为了更好的理解State生命周期的变化,我们来通过它的对象版本StateObject,来观察一下其生命周期的变化。在新的示例中,创建一个自定义对象,并在init和deinit中打印日志。

StateObject的生命周期

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
33
34
35
extension PlayButton {

class DataModel: ObservableObject {
@Published var isEnabled = false

init() {
print("PlayButton DataModel init")

}

deinit {
print("PlayButton DataModel deinit")
}
}
}

struct PlayButton: View {

@State private var isPlaying: Bool = false // Create the state.

@StateObject private var dataModel = DataModel()

private let uuid = UUID()

var body: some View {
let _ = Self._printChanges()
VStack {
Button(isPlaying ? "Pause" : "Play") { // Read the state.
isPlaying.toggle() // Write the state.
}
Text("UUID: \(uuid)")
}
}

}

界面首次展示时,可以看到init的打印日志。

1
PlayButton DataModel init

点击Toggle后

1
PlayButton DataModel deinit

我们可以看到deinit的打印内容,这就证明了PlayButton的生命周期结束了。

再次点击Toggle

1
PlayButton DataModel init

可以重新看到init的打印日志。

点击+1按钮

1
2
3
ContentView: _count changed.
PlayButton: @self changed.
CountText: @self changed.

当点击+1按钮后,PlayButton会重新创建,但这个时候并没有PlayButton DataModel init的日志,这是因为StateObject只会在生命周期内初始化一次。

通过Observable看State的生命周期

iOS 17开始提供了Observation框架,使用@Observable宏的对象,需要使用State来修饰。State在实例重建的时候,会总是新建实例,为了更好的理解与StateObject的差异,我们添加了一些额外的代码:

  1. DataModel添加了一个uuid和now的属性用于显示,其中now使用@ObservationIgnored标注。
  2. 在init和deinit方法中打印当前对象的内存地址,用于观察释放的实例。
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
extension PlayButton {

@Observable
class DataModel {
var isEnabled = false

var uuid = UUID()

@ObservationIgnored
var now = Date()

init() {
print("PlayButton DataModel init")

let address = Unmanaged.passUnretained(self).toOpaque()
print("DataModel address: \(address)")
}

deinit {
print("PlayButton DataModel deinit")
let address = Unmanaged.passUnretained(self).toOpaque()
print("DataModel address: \(address)")
}
}
}

struct PlayButton: View {

@State private var isPlaying: Bool = false // Create the state.

@State private var dataModel = DataModel()

private let uuid = UUID()

var body: some View {
let _ = Self._printChanges()
VStack(spacing: 20) {
Button(isPlaying ? "Pause" : "Play") { // Read the state.
isPlaying.toggle() // Write the state.
}
Text("UUID: \(uuid)")
Text("DataModel UUID: \(dataModel.uuid)")
Text("DataModel now: \(dataModel.now)")
}
}

}

界面首次展示时,可以看到init和address的打印日志。(现在只关注这2个日志,其余的不列举)

1
2
PlayButton DataModel init
DataModel address: 0x0000600000c435d0

点击Toggle后

1
2
PlayButton DataModel deinit
DataModel address: 0x0000600000c435d0

我们可以看到deinit和address的打印内容,这就证明了PlayButton的生命周期结束了。

再次点击Toggle

1
2
PlayButton DataModel init
DataModel address: 0x0000600000c30690

可以重新看到init和address的打印日志。

点击+1按钮

当点击+1按钮后,PlayButton会重新创建,可以看到PlayButton DataModel init的日志信息。虽然DataModel重建了,但显示的UUID和now并没有发生变化。即:因identity变更后首次创建的实例并没有释放掉,由SwiftUI一直保存着。

1
2
PlayButton DataModel init
DataModel address: 0x0000600001714180

再次点击+1按钮

当点击+1按钮后,PlayButton会重新创建,可以看到PlayButton DataModel init及上一个实例的deinit日志信息。

1
2
3
4
PlayButton DataModel init
DataModel address: 0x0000600001701340
PlayButton DataModel deinit
DataModel address: 0x0000600001714180

再再次点击Toggle

可以看到因identity变更导致创建的实例及上一个创建的实例都释放了。

1
2
3
4
PlayButton DataModel deinit
DataModel address: 0x0000600001701340
PlayButton DataModel deinit
DataModel address: 0x0000600000c30690

如果从iOS 17开始支持,那么@Observable是更推荐的方式,所以在对象的init或deinit添加事件上报之类的处理时,就需要考虑是否符合预期。

关于Binding

大多数介绍Binding的文章中,主要关注在双向绑定的功能上。对于无需修改数据源的情况下,并不需要使用Binding。

下面我们在CountText中为count添加@Binding,再次点击+ 1按扭时,看看Self._printChanges()打印的日志

1
CountText: _count changed.

你可以看到只有CountText的修改日志。这个时候只是重新计算了body的值,而不是重建实例再计算body,CountText中显示的UUID并无变化。

其实Binding最本质的功能是保持值的source of truth。当共享由SwiftUI托管的值,无论是否修改都应该使用Binding。

总结

从本文中的例子可以看到,即使是相同的UI,在视图重建过程中,会因为不同创建方式出现不同的区别。而视图重建也会对State的产生影响。

视图更新的差异

场景 identity变更 实例重建
首次显示或再次显示的时候
CountText通过常量接收count
CountText通过Binding接收count

视图生命周期变更对State和StateObject的影响

State在生命周期内,虽然会多次重建实例,但最终还是会使用原始实例的数据。StateObject只会生命周期内初始化一次。

场景 State StateObject
identity变更时的视图重建 执行init,生命周期开始 执行init,生命周期开始
视图从显示的树移除 执行deinit,生命周期结束 执行deinit,生命周期结束
identity未变更下的视图重建 执行init,但对由SwiftUI托管的值无影响

建议:

  • 保持视图的Identify稳定性,如无必要,不要使用if/switch分支组织视图。
  • 值共享时,使用Binding。
  • 关注printChanges,避免非预期的视图重建。