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