在之前的学习笔记《SwiftUI学习笔记04 – 如何调试SwiftUI?》有提到@ObservedObject
容易导致View刷新被重复创建的问题。其中有一部分是我使用不当导致的。今天我们来分析下SwiftUI 中 @ObservedObject
与 @StateObject
的区别。
1. 生命周期不同
我们先来看一个常规的操作:
final class DebugViewModel: ObservableObject {
@Published var items: [String] = ["001", "002", "003"]
@Published var count = 5
init() {
print("DebugViewModel init \(Unmanaged.passUnretained(self).toOpaque())")
}
}
struct DebugView: View {
@ObservedObject private var viewModel = DebugViewModel()
var body: some View {
#if DEBUG
let _ = Self._printChanges()
#endif
NavigationStack {
DebugViewSubview()
List {
ForEach((1...viewModel.count), id: \.self) {
Text("\($0)")
}
}
.listStyle(.plain)
.toolbar {
ToolbarItem {
Button("+1") {
viewModel.count += 1
}
}
}
}
}
}
#Preview {
DebugView()
}
非常完美,DebugViewModel
只会被创建一次,点+1
按钮改变DebugViewModel
的属性,自己就跟着刷新了。但如果我们引入一个DebugViewSubview
,问题就来了。
final class DebugViewSubviewModel: ObservableObject {
@Published var count = 1
init() {
print("DebugViewSubviewModel init \(Unmanaged.passUnretained(self).toOpaque())")
}
}
struct DebugViewSubview: View {
@ObservedObject private var viewModel = DebugViewSubviewModel()
var body: some View {
Text("I'm a Subview. Count: \(viewModel.count)")
}
}
然后// 在DebugView的List上面加一个DebugViewSubview
NavigationStack {
DebugViewSubview() // <-这里
//…
}
我们看到DebugViewSubviewModel
的创建时机是DebugViewSubview
创建的时候。这时候我们再点+1
按钮,SwiftUI在刷新时就会倾向于重新创建View
。SwiftUI的View
都是轻量的Struct
,重新创建与绘制理论上应该是很高效的。但是这时候DebugViewSubviewModel
也会随着View
的创建而被重新创建一遍。每点一次+1
按钮就会重新创建一次。
DebugView: _viewModel changed.
DebugViewSubviewModel init 0x0000000281c129c0
DebugView: _viewModel changed.
DebugViewSubviewModel init 0x0000000281ca44c0
DebugView: _viewModel changed.
DebugViewSubviewModel init 0x0000000281c9bc40
在之前的文章中,我们提到可以通过EquatableView
或者Equatable
来规避部分Subview的重绘。但上面这种用法其实是错误的,我们应该使用@StateObject
而不是@ObservedObject
。我们把上面所有代码都不变,只改@StateObject
:
@StateObject private var viewModel = DebugViewSubviewModel()
这样再点+1
按钮,就不会一直创建了:
DebugViewSubviewModel init 0x0000000282318500
DebugView: _viewModel changed.
DebugView: _viewModel changed.
DebugView: _viewModel changed.
2. 什么时候用@StateObject和@ObservedObject?
SwiftUI提供了@StateObject
, @ObservedObject
和@EnvironmentObject
这几种常用的Property Wrapper。
大部分情况下,我们用@StateObject
来作为一个View
的数据来源,通过@StateObject
初始化的Property,即便View
多次被刷新,其初始化方法也只会被调用一次。这样我们就不用担心@StateObject
里额外添加的各种通知逻辑,异步读写数据逻辑被重复调用。所以上述例子中,DebugView
和DebugViewSubview
创建的两个ViewModel
都只涉及View自身的数据,都应该使用@StateObject
而不是ObservedObject
声明。
当我们需要把Parent View的的@StateObject
传递给Subview的时候,我们可以在Subview声明@ObservedObject
。比如上述例子中,如果DebugViewSubview
需要用到DebugView
的DebugViewModel
,那么我们可以这么写:
struct DebugViewSubview: View {
@StateObject private var viewModel = DebugViewSubviewModel()
@ObservedObject private var parentViewModel: DebugViewModel
init(parentViewModel: DebugViewModel) {
self.parentViewModel = parentViewModel
}
var body: some View {
Text("I'm a Subview. Count: \(viewModel.count)")
Text("parentViewModel Count: \(parentViewModel.count)")
}
}
这样,parentViewModel
在DebugView
中被+1
了之后,DebugViewSubview
也会跟着变化。这就是@ObservedObject
的真正用途: 在不同的View
之间传递ObservableObject
。
DebugViewModel init 0x0000000283289dd0
DebugView: @self, @identity, _viewModel changed.
DebugViewSubviewModel init 0x0000000283ce6140
DebugView: _viewModel changed.
DebugView: _viewModel changed.
这时候我们发现,有些数据源我希望挂在App
上为全局使用,比如一个帐号是否已登录之类的。通常我们会在App
上创建一个@StateObject
,但如果我有很多个View
都需要用到这个数据,那我岂不是得创建很多个@ObservableObject
然后一层层传下去?为了方便大家开发,SwiftUI提供了@EnvironmentObject
。用于在View
层级上传递数据。比如一个典型的SwiftUI App入口:
@main
struct SomeApp: App {
@StateObject private var appInfo: AppInfo
init() {
self._appInfo = StateObject(wrappedValue: AppInfo.shared)
}
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(appInfo) // <-- 这里
}
}
}
在上述代码中,我把appInfo
通过.environmentObject()
传递给了MainView
,这样它的所有Subviews都可以共享这个appInfo
实例。只需要在MainView
中这样写:
import SwiftUI
struct MainView: View {
@EnvironmentObject private var appInfo: AppInfo
// …
}
这样就能获得由App
全局负责管理生命周期的appInfo
。MainView
的Subviews如果也想用到,只要依法炮制即可。
3. 新的 @Observable Macro
在WWDC23中,Apple推出了SwiftUI 5的新特性,用@Observable
宏代替了@ObservedObject
与@Published
声明。我们可以把上面DebugViewSubview
的代码改写为:
// 这里用 @Observable 声明整个类
@Observable final class DebugViewSubviewModel{
var count = 1 // <-- 这里删掉了之前的 @Published
@ObservationIgnored // <-- 不需要发出变更的通知用 @ObservationIgnored 修饰
var propertyWontSendWillChange = 0
init() {
print("DebugViewSubviewModel init \(Unmanaged.passUnretained(self).toOpaque())")
}
}
struct DebugViewSubview: View {
@State private var viewModel = DebugViewSubviewModel()
// …
}
@Observable
是一个Macro,在Xcode 15中我们可以通过Editor -> Expand Macro
来看这它生成了什么代码,在此之前,我们需要确保有import Observation
。
可以看到生成的代码很简单,_$observationRegistrar
用来保存监听变化的对象,当@ObservationTracked
属性发生变化时回调给监听者。func access<Member>()
是getter,withMutation<Member, MutationResult>
是setter。然后还让DebugViewSubviewModel
遵循了Observation.Observable
协议。
我们展开@ObservationTracked
Macro可以看到:
每个@ObservationTracked
property会生成一个private
property,getter/setter用的就是上面那两个,从而实现监听和通知。
使用时我们改用@State
代替@StateObject
,如果是@Binding
类型的,就用@Bindable
声明。比如:
struct DebugViewSubview: View {
@Bindable private var viewModel = DebugViewSubviewModel()
// …
}
这些Macro的更新是有限制条件的: SwiftUI 5 only。所以如果你打算开发只支持 iOS 17 以上的 App,那就可以把所有的 @StateObject
全部替换为 @Observable
Macro实现了。
但经过我的测试,如下声明的DebugViewSubview
,因为DebugViewSubviewModel
的Property Wrapper从@StateObject
改为@State
了,也就失去了@StateObject
只会创建一次的生命周期管理特性。于是每一次Parent View的+1
按钮点了,DebugViewSubviewModel
就会被重新创建,跟我们使用@ObservedObject
声明一样。
@Observable
final class DebugViewSubviewModel {
var count = 1
init() {
print("DebugViewSubviewModel init \(Unmanaged.passUnretained(self).toOpaque())")
}
}
struct DebugViewSubview: View {
@State private var viewModel = DebugViewSubviewModel()
//…
}
上述声明的代码,在Parent View里的+1
按钮被按下以后,DebugViewSubviewModel
会被不断创建。
DebugView: @dependencies changed.
DebugViewSubviewModel init 0x00000002814eb7a0
DebugViewSubview: @self changed.
DebugView: @dependencies changed.
DebugViewSubviewModel init 0x000000028149a7c0
DebugViewSubview: @self changed.
目前我没有找到什么好办法来规避使用@Observable
下Subviews的重绘与标记为@State
的ViewModel的重建,只能说这种刷新是设计如此。
综上所述,现状我还是使用@StateObject
为主,如果未来要迁移到@Observable
Macro我就得想办法解决Subviews里的ViewModel
会一直被重新创建的问题。比如说,以后所有的ViewModel
都需要跟View
的生命周期分离,不可以由当前View
来创建,而是谁能决定它被刷新,就由谁来创建。
比如DebugViewSubview
它的ViewModel
就交给DebugView
来创建,这样它就不会因为View
被刷新而频繁init。但是这样也不好,因为DebugViewSubview
往下可能还有Subviews呢?我要把所有的Subviews ViewModel全部提到最上面一层来吗?用Environment
确实可以做到,但我也不知道这样是否合理。
简单来说,SwiftUI 5这次带来的Observation
升级,是直接把在View的生命周期内只会创建一次的@StateObject特性给砍掉了,必然会对我们原有的设计与写法产生影响。另一个方面想,也许是我们熟悉的MVC/MVVM不适合SwiftUI的设计理念,探索TCA架构能提供一个新的解决方案呢?