酱酱不惊

Break my life, rebuild it

0%

本文并不是介绍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,避免非预期的视图重建。

本文并不是介绍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,避免非预期的视图重建。

SwiftUI中的NavigationStack是iOS 16引入基于数据类型驱动的导航视图。通过将数据类型与视图关联起来,提供了类型安全的使用方式。本文并非介绍NavigationStack基本使用的文章,故这里就不多做介绍了。

在实际应用中,通过编码的方式维护导航视图栈是非常常见的行为,比如:打开不同的视图;退出部分视图等。虽然在官方的NavigationStack示例中,也提供了维护导航视图路径的方法,但仅能支持一些简单的场景。另外,在使用上也存在一些局限性。整体的可操作性与UIKit中的UINavigationController有些差距。

下面先来看看示例文档中路径绑定的例子。

NavigationStack的路径绑定方法

当需要维护多个视图界面时,通过在初始化方法中传入绑定path的数组,然后通过对数组的操作来完成视图的导航,比如:给数组插入元素或者删除元素,即完成界面的打开和退出。

1
2
3
4
5
6
7
8
9
10
@State private var presentedParks: [Park] = []

NavigationStack(path: $presentedParks) {
List(parks) { park in
NavigationLink(park.name, value: park)
}
.navigationDestination(for: Park.self) { park in
ParkDetails(park: park)
}
}

这个示例中的问题是只能支持打开相同的界面,因为绑定的数组容器元素是一个具体的类型。

为了解决这个问题,需要使用到类型无关的列表容器NavigationPath,初始化path参数也支持传入绑定NavigationPath。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@State private var presentedPaths = NavigationPath()

NavigationStack(path: $presentedPaths) {
List {
ForEach(parks) { park in
NavigationLink(park.name, value: park)
}
ForEach(zoos) { zoo in
NavigationLink(zoo.name, value: zoo)
}
}
.navigationDestination(for: Park.self) { park in
ParkDetails(park: park)
}
.navigationDestination(for: Zoo.self) { zoo in
ZooDetails(park: zoo)
}
}

NavigationPath也支持简单的数据添加和删除元素的等操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// Appends a new value to the end of this path.
public mutating func append<V>(_ value: V) where V : Hashable

/// Appends a new codable value to the end of this path.
public mutating func append<V>(_ value: V) where V : Decodable, V : Encodable, V : Hashable

/// Removes values from the end of this path.
///
/// - Parameters:
/// - k: The number of values to remove. The default value is `1`.
///
/// - Precondition: The input parameter `k` must be greater than or equal
/// to zero, and must be less than or equal to the number of elements in
/// the path.
public mutating func removeLast(_ k: Int = 1)

日常使用中的问题

虽然上述2种方法提供了维护视图栈的方法,但在实际使用过程中还是会有一些小问题:

  1. 总是需要创建一个类型来关联视图。比如:某些静态界面或者本来就不需要传入参数的视图。
  2. 类型与单个视图绑定。比如:多个视图接收相同的模型参数。
  3. 在视图中无法直接获取绑定的导航数据列表容器。
  4. NavigationPath中提供的容器操纵方法不够。

基于枚举的路径绑定

Swift中的枚举非常强大,跟Objective-C和C中的完全不是一种东西。甚至可以说如果不会使用枚举,就根本不了解Swift。这篇文章非常建议阅读:Swift 最佳实践之 Enum

首先,我们通过枚举来表示应用中的视图类型,结合嵌套的枚举来表示子级的视图类型。另外,通过关联值来传递子视图或视图的入参。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
enum AppViewRouter: Hashable {

enum Category: Hashable {
case list
case detail(Int)
}

case profile
case category(Category)

var title: String {
switch self {
case .profile:
return "Profile"
case .category(let category):
switch category {
case .list:
return "Category List"
case .detail(let id):
return "Category Detail: \(id)"
}
}
}
}

在NavigationStack的初始化方法中,通过包含视图枚举的数组来进行绑定。在一个navigationDestination中通过不同的枚举来完成对不同的视图创建,通过编译器,也可以帮助我们不会遗漏没有处理的视图。

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

@State
private var presentedRouters: [AppViewRouter] = []

var body: some View {
NavigationStack(path: $presentedRouters) {
LinkView(title: "Home")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: AppViewRouter.self) { park in
switch park {
case .profile:
LinkView(title: park.title)
case .category(let category):
switch category {
case .list:
LinkView(title: park.title)
case .detail(let id):
LinkView(title: park.title)
}
}
}
}
.environment(\.myNavigationPath, $presentedRouters)
}
}

为了能够在子视图中操作当前的导航栈,我们创建了一个环境Binding值,将当前的绑定的枚举数组注入到环境中。

1
2
3
4
5
6
7
8
9
10
11
12

private struct MyNavigationPathKey: EnvironmentKey {
static let defaultValue: Binding<[AppViewRouter]> = .constant([AppViewRouter]())
}

extension EnvironmentValues {
var myNavigationPath: Binding<[AppViewRouter]> {
get { self[MyNavigationPathKey.self] }
set { self[MyNavigationPathKey.self] = newValue }
}
}

在LinkView中,我们获取绑定路径的环境Binding值,通过对路径数据的添加或删除操作,以实现导航栈的控制。当然,也可以使用NavigationLink继续打开新的视图,以一种类型安全并且带有层级结构的方式。

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

let title: String

@Environment(\.myNavigationPath) var customValue

var body: some View {
VStack(alignment: .leading, spacing: 20) {
NavigationLink("Go Profile", value: AppViewRouter.profile)
NavigationLink("Go Category List", value: AppViewRouter.category(.list))
Button("Go Category Detail") {
customValue.wrappedValue.append(AppViewRouter.category(.detail(999)))

}
Button("Back") {
if !customValue.wrappedValue.isEmpty {
customValue.wrappedValue.removeLast()
}
}

Button("Back to Root") {
customValue.wrappedValue.removeAll()
}
}
.padding()
.navigationTitle(title)
}

}

总结

通过上述的例子我们可以看到,使用枚举来定义应用的视图层级是一种非常的好的方式,而关联值也很好的解决了视图的入参问题。将绑定的视图数组注入到环境中,也能让子视图方便的控制整个界面的导航。

整个过程不需要涉及新的内容,都是一些在SwiftUI开发中的常见的部分。但在使用体验上要比之前好得多。

完整Demo地址:Github地址

文中谈到的问题与Core Data有关,故并不局限于SwiftUI应用或不启用CloudKit的场景。

在开发SwiftUI应用时,结合Core Data with CloudKit来提供跨平台数据同步的是个非常不错的选择。当通过Xcode创建使用Core Data with CloudKit新项目时,会提供一些模板代码,大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct PersistenceController {
static let shared = PersistenceController()

let container: NSPersistentCloudKitContainer

init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "CoreDataDemo")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
container.viewContext.automaticallyMergesChangesFromParent = true
}
}

示例代码不多,但提供了一些方便的功能,可以支持应用的初期开发。

PersistenceController中的问题

上述示例中代码存在一些潜在的问题及职责不清晰,随着项目的不断迭代,会逐渐变得更加臃肿及无法扩展。下面我们来分析一下存在的问题。

Swift的单例问题

PersistenceController提供了一个shared的单例属性定义,但PersistenceController本身是一个结构体,即一个值类型。而值类型在赋值给一个变量、常量或者传递给函数的时候会产生一个拷贝,这就破坏了单例模式的设计初衷。

所以在Swift中创建单例时,正常的用法应该是使用引用类型进行声明。

1
2
3
class PersistenceController {
...
}

或者使用actor进行声明,关于Actor的介绍可以参考:Swift 新并发框架之 actor

1
2
3
actor PersistenceController {
...
}

职责过多的初始化方法

init(inMemory: Bool = false)中的代码耦合严重,处理了许多事情。在实际使用中,还会有更多的代码加入进来,整个代码变得更加复杂。问题如下:

  1. 以硬编码的方式创建NSPersistentCloudKitContainer实例。
  2. 通过inMemory参数修改NSPersistentCloudKitContainer的存储行为。
  3. 加载完毕后对NSPersistentCloudKitContainer实例进行配置。

虽然上述问题对这个示例来说无所谓,但在实际应用中会是个非常糟糕的例子。在我的开发过程中,还会碰到了以下需求:

  1. 修改数据库的存储路径到App Group。
  2. 定制不同行为的NSPersistentCloudKitContainer的实例。
  3. 将模型相关的代码转换成SPM,用于在主应用和extension中复用。
  4. 更方便的进行单元测试。

fatalError

虽然模板代码中已经加了注释,需要替换成合适的处理方法。但在开发过程中极少会触发到这个错误,导致容易被忽视。建议的做法是通过Debug宏进行隔离处理,避免线上启动就闪退的可能性。

重构PersistenceController

创建不同的NSPersistentCloudKitContainer的子类是个不错的选择,将对实例的配置及相关方法封装到特定的子类中。这也是苹果推荐的一种方式:Subclass the Persistent Container

为了更灵活的创建不同的NSPersistentCloudKitContainer实例,在初始化方法中通过配置信息来完成NSPersistentCloudKitContainer实例的创建。

优化init方法

首先,创建一个包含初始化配置信息的结构体。这个结构体中包含了NSPersistentCloudKitContainer的类型信息,允许从外部注入需要创建的子类类型。另外,也支持从外部传入NSManagedObjectModel对象,当将Core Data的相关代码模块化后,通常需要从非mainBundle进行加载,故可以自行创建NSManagedObjectModel实例后,传递到NSPersistentCloudKitContainer的初始化过程中。代码如下:

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
final class MyPersistenceController {

struct Configuration {

let containerClass: NSPersistentCloudKitContainer.Type

let name: String

let managedObjectModel: NSManagedObjectModel?

init(containerClass: NSPersistentCloudKitContainer.Type, name: String, managedObjectModel: NSManagedObjectModel? = nil) {
self.containerClass = containerClass
self.name = name
self.managedObjectModel = managedObjectModel
}

}

let container: NSPersistentCloudKitContainer

init(_ configuration: MyPersistenceController.Configuration) {
if let managedObjectModel = configuration.managedObjectModel {
container = configuration.containerClass.init(name: configuration.name, managedObjectModel: managedObjectModel)
} else {
container = configuration.containerClass.init(name: configuration.name)
}

container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
#if DEBUG
fatalError("Unresolved error \(error), \(error.userInfo)")
#else

#endif
}
})
}

}

创建NSPersistentCloudKitContainer的子类

针对CloudKit持久化和内存中持久化,创建各自不同的子类MyPersistentCloudKitContainer和MyInMemeryContainer。

Extension已经成为iOS开发中不可缺少的一环,为了能够在extension中访问Core Data的数据,需要修改默认保存的数据库文件目录,可在重写defaultDirectoryURL方法修改默认保存的路径。关于怎么通过App Group创建共享目录这里就不展开了。

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
class MyPersistentCloudKitContainer: NSPersistentCloudKitContainer {

// override class func defaultDirectoryURL() -> URL {
// FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "xxx")!
// }

override func loadPersistentStores(completionHandler block: @escaping (NSPersistentStoreDescription, Error?) -> Void) {
super.loadPersistentStores(completionHandler: block)

viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
viewContext.automaticallyMergesChangesFromParent = true
}

}

class MyInMemeryContainer: NSPersistentCloudKitContainer {

override init(name: String, managedObjectModel model: NSManagedObjectModel) {
super.init(name: name, managedObjectModel: model)

persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")

addPreviewData()
}

func addPreviewData() {
for _ in 0..<10 {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
}
}

}

创建不同的静态属性

通过扩展的方式,创建不同的静态属性,可以根据实际的情况创建不同的NSPersistentCloudKitContainer配置。比如:可以提供一个专门用于在extension访问配置,禁用掉CloudKit相关的配置属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
extension MyPersistenceController {

static var managedObjectModel: NSManagedObjectModel = {
NSManagedObjectModel.mergedModel(from: [.main])!
}()

static var `default`: MyPersistenceController = {
let configuration: MyPersistenceController.Configuration = .init(
containerClass: MyPersistentCloudKitContainer.self,
name: "CoreDataDemo",
managedObjectModel: managedObjectModel)

return MyPersistenceController(configuration)
}()

static var preview: MyPersistenceController = {
MyPersistenceController(.init(containerClass: MyInMemeryContainer.self, name: "CoreDataDemo"))
}()

}

总结

通过在初始化方法中注入创建NSPersistentCloudKitContainer所需的参数,完成了对NSPersistentCloudKitContainer的强依赖。然后,通过创建不同的NSPersistentCloudKitContainer子类封装不同的配置。最终可以非常方便的创建不同的PersistenceController实例,应用到不同的场景中。

完整Demo代码: Github地址

CocoaPods是iOS平台上流行的第三方包管理工具。基本的介绍及使用方法可参考《唐巧的博客——用CocoaPods做iOS程序的依赖管理》,这里就不做介绍了。

在CocoaPods的使用过程中,会根据Podfile的内容,来抓取第三方库的源文件和生成其它相关的文件。这些文件均存放在Pods目录中,并通过生成的Pods.xcodeproj项目来进行管理。除此之外,还有几个关键的文件位于目录:Pods/Target Support Files/Pods-XXX/ 中,下面会讲到如何使用它们来解决我们标题中的问题。

问题:什么情况下需要共享Pods.xcodeproj ?

有很多iOS App会同时开发iPhone和iPad两个独立客户端,而不是开发单独的Universal客户端。这时候通常有两个.xcodeproj工程,虽然单个工程通过两个Target也能达到类似的效果,但这里不考虑这种情况。如果两个.xcodeproj工程可以共享Pods.xcodeproj,那么维护Pods会是非常的方便。

阅读全文 »

学习

在整个2015年里,自己花在读书上的时间很少,这个问题也一直持续到2016年上半年。在结束这1年的高强度工作后,终于有时间可以多看看书了。今年在还买了Kindle后,明显的提高了我看书的效率,可以有更多的碎片时间拿来利用。

2016年我的书单:《成为技术领导者》、《从优秀到卓越》、《人性的弱点》、《把时间当作朋友》、《人类简史:从动物到上帝》、《尼采:在世纪的转折点上》、《必然》、《引爆点》、《重新定义公司:谷歌是如何运营的》。

年度最喜欢的书非《人类简史:从动物到上帝》莫属了,其实《把时间当作朋友》我也非常喜欢。

除了看书,今年还报名了《混沌研习社》,成为付费会员,听了很多不同内容的分享。算是我在网上付费最大一笔的学习支出了,个人觉得还是非常值得的。

职业

在今年里,终于结束了1年在创业公司的工作。这段的经历给我深深的上了一课,“传统企业玩互联网是找死,不转是等死”,这话说的真没错,传统企业是做不好互联网的。这次的失败让我对不靠谱的公司有了更一步的理解。

这一整年里,自己的职业发展没有任何进步,这给了我非常大的紧迫感,我需要再次变革。

生活

2016年里上海房价疯狂上涨,幅度前所未闻,不少地方都直接翻翻了,让我非常的郁闷。为什么一个国家可以让这样不公平的事情一直发生?虽然,我大兲朝本来就是这样。

2016年将要过去了,我们又多相处了一年,也见了我们的父母,终于快要修成正果了,这算是我2016里最大的收获。

2017

2017年必然是我人生最重要的一个阶段,我必须投入更大的努力来证明我可以的。

最近这段时间发现自己对GTD的实践有所懈怠,所以想重读《尽管去做——无压工作的艺术》,后来改变主意还是想找另外一本讲GTD的书,结果找到了这一本《把时间当朋友》。由于之前在KnewOne上买过东西,立即对这本书有了好感。虽然4月份就买了,但一直到7月才开始阅读,然后一发不可收拾,这本书的名字太低调了。让我想起了电影《三傻大闹宝莱坞》,虽然名字很平常,但所展现的内容却是非同一般。

在书里从心理学、科学、哲学多个的角度来描述开启心智的重要性。这个重要性足以影响每个人的生活、工作、思想的方方面面。

通过各种日常生活中的例子,更深入一下展现这些例子背后的问题,而这些问题通常是我们不想去想、不敢去想或者根本就没有想过的。特别是一些很现实的问题,比如:绝大多数人都不“成功”。那所谓的“成功”究竟是什么意思呢?是这个社会定义的“成功”?还是你自己定义的“成功”?还是你所追求的只是别人的成功?

有意思的是,这个问题的答案,在我最近读的一本书《人类简史》里,可以找到一些线索。而且这本书里谈到了“自知”的问题,里面有句话:“平庸的人也有权利快乐”。

这本书里的内容是不能从别人那里理解的,开启心智只能是自己的事。附上摘抄的笔记:

阅读全文 »

无论你是在上班还是在创业,这本书都能帮助你预测公司的未来。书中提到的“3个训练有素”非常具有指导意义:

  1. 训练有素的人
  2. 训练有素的思想
  3. 训练有素的行为

更新笔记整理 2016.10.1

阅读全文 »