SwiftUI 中 @ObservedObject 与 @StateObject 的区别

2024年1月19日 · 3 months ago

SwiftUI 中 @ObservedObject 与 @StateObject 的区别

在之前的学习笔记《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里额外添加的各种通知逻辑,异步读写数据逻辑被重复调用。所以上述例子中,DebugViewDebugViewSubview创建的两个ViewModel都只涉及View自身的数据,都应该使用@StateObject而不是ObservedObject声明。

当我们需要把Parent View的的@StateObject传递给Subview的时候,我们可以在Subview声明@ObservedObject。比如上述例子中,如果DebugViewSubview需要用到DebugViewDebugViewModel,那么我们可以这么写:

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)")
    }
}

这样,parentViewModelDebugView中被+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全局负责管理生命周期的appInfoMainView的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架构能提供一个新的解决方案呢?