5

SwiftUI 中 @ObservedObject 与 @StateObject 的区别

 7 months ago
source link: https://justinyan.me/post/5926
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
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

observable-macro.png

可以看到生成的代码很简单,_$observationRegistrar用来保存监听变化的对象,当@ObservationTracked属性发生变化时回调给监听者。func access<Member>()是getter,withMutation<Member, MutationResult>是setter。然后还让DebugViewSubviewModel遵循了Observation.Observable协议。

我们展开@ObservationTracked Macro可以看到:

ObservationTracked-v2.png

每个@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架构能提供一个新的解决方案呢?


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK