本文并不是介绍State基本使用的文章,需要你对SwiftUI有基本的了解。
首先需要明确的是,State是有生命周期的。如果只是简单的视图,基本上无需特别考虑这个问题。但随着视图复杂起来后,如果不考虑生命周期,就可能产生一些不符合预期的现象及潜在的性能问题。
State生命周期结束后的影响
让我们通过下面的示例来看看,当State生命周期结束时对视图状态的影响。在示例中有一个Toggle,用于控制是否显示PlayButton。
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()
}
}
操作步骤:
- 点击PlayButton视图的Button,按扭标题这时显示成Pause。
- 点击Toggle,隐藏PlayButton。
- 再次点击Toggle,显示PlayButton。
这个时候PlayButton的标题显示的是Play,可见isPlaying的值已经被重置了。
如果你不清楚这里发生了什么,请继续往下看。
利用Self._printChanges()了解视图的变化
为了搞清楚视图发生了什么,我们可以通过Self._printChanges()打印视图变更的日志,来分析视图因为什么原因导致了变更。另外,在PlayButton类型中,还添加了一个uuid属性,通过这个属性我们可以识别当前的实例是否重建过,这是一个非常重要的变化。
更新后的代码:
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()
}
}
再按上述的步骤操作界面,我们来分析一下打印的视图变化日志。
界面首次展示
ContentView: @self, @identity, _showPlayButton changed.
PlayButton: @self, @identity, _isPlaying changed.
Self._printChanges()打印的日志,基本上可以分为3类:@self、@identity和其它。 当包含@self时,表示视图的值发生了变化,但View是不可变,所以这里可以理解视图进行重新创建。 当包含@identity时,表示身份标识变化导致变更。 其它的可以归类为视图的依赖项。
点击PlayButton视图的Button
PlayButton: _isPlaying changed.
当PlayButton中的Button被点击后,只有PlayButton需要重新计算body。
点击Toggle
ContentView: _showPlayButton changed.
当ContentView的Toggle被点击后,只有ContentView需要重新计算body。
再次点击Toggle
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
这一点对理解identity非常重要,即视图的value并不是视图的identity。视图会根据情况不断重复创建,而视图的identity并不会发生变化。为了展示这种情况,我们添加一个名为count的State,然后在CountText中显示。
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的日志信息。
ContentView: _count changed.
PlayButton: @self changed.
CountText: @self changed.
State的生命周期等于视图的生命周期
在前面的示例中,PlayButton会根据showPlayButton的状态来判断是否显示在界面上,这个逻辑就导致了PlayButton的identity发生了变化,进而导致State的生命周期变化。
为了更好的理解State生命周期的变化,我们来通过它的对象版本StateObject,来观察一下其生命周期的变化。在新的示例中,创建一个自定义对象,并在init和deinit中打印日志。
StateObject的生命周期
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的打印日志。
PlayButton DataModel init
点击Toggle后
PlayButton DataModel deinit
我们可以看到deinit的打印内容,这就证明了PlayButton的生命周期结束了。
再次点击Toggle
PlayButton DataModel init
可以重新看到init的打印日志。
点击+1按钮
ContentView: _count changed.
PlayButton: @self changed.
CountText: @self changed.
当点击+1按钮后,PlayButton会重新创建,但这个时候并没有PlayButton DataModel init的日志,这是因为StateObject只会在生命周期内初始化一次。
通过Observable看State的生命周期
iOS 17开始提供了Observation框架,使用@Observable宏的对象,需要使用State来修饰。State在实例重建的时候,会总是新建实例,为了更好的理解与StateObject的差异,我们添加了一些额外的代码:
- DataModel添加了一个uuid和now的属性用于显示,其中now使用@ObservationIgnored标注。
- 在init和deinit方法中打印当前对象的内存地址,用于观察释放的实例。
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个日志,其余的不列举)
PlayButton DataModel init
DataModel address: 0x0000600000c435d0
点击Toggle后
PlayButton DataModel deinit
DataModel address: 0x0000600000c435d0
我们可以看到deinit和address的打印内容,这就证明了PlayButton的生命周期结束了。
再次点击Toggle
PlayButton DataModel init
DataModel address: 0x0000600000c30690
可以重新看到init和address的打印日志。
点击+1按钮
当点击+1按钮后,PlayButton会重新创建,可以看到PlayButton DataModel init的日志信息。虽然DataModel重建了,但显示的UUID和now并没有发生变化。即:因identity变更后首次创建的实例并没有释放掉,由SwiftUI一直保存着。
PlayButton DataModel init
DataModel address: 0x0000600001714180
再次点击+1按钮
当点击+1按钮后,PlayButton会重新创建,可以看到PlayButton DataModel init及上一个实例的deinit日志信息。
PlayButton DataModel init
DataModel address: 0x0000600001701340
PlayButton DataModel deinit
DataModel address: 0x0000600001714180
再再次点击Toggle
可以看到因identity变更导致创建的实例及上一个创建的实例都释放了。
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()打印的日志
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,避免非预期的视图重建。