In short - when construction a custom SwiftUI View with a ViewModel ("MyView"/"MyViewModel") like this:
struct ContentView: View {
var body: some View {
MyView(viewModel: MyViewModel())
}
}
Why is this:
struct MyView: View {
@StateObject var viewModel: MyViewModel
var body: some View {
Text("Hello world!")
}
}
Not the same as this:
struct MyView: View {
@StateObject var viewModel: MyViewModel
init(viewModel: MyViewModel) {
self._viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
Text("Hello world!")
}
}
When debugging, it looks like the second option causes new instances of MyViewModel
to be created every time MyView
is reconstructed by SwiftUI. But in the first option, the StateObject wrapper seems to do its job, and makes sure only one instance of MyViewModel
is created for all reconstructions of MyView
.
Is there some extra SwiftUI magic applied to using the View's default memberwise initializer VS a custom one? Perhaps by the ViewBuilder?
Below is a simple example app, MyApp.swift, to see the behaviour in action.
//
// MyApp.swift
//
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
@AppStorage("redBackground") private var redBackground: Bool = false
var body: some View {
ZStack {
// Flipping "redBackground" will cause a reconstruction of the view hierarchy
if redBackground {
Color.red
} else {
Color.green
}
MyView(viewModel: MyViewModel())
}
}
}
final class MyViewModel: ObservableObject {
init() {
print("MyViewModel.init")
}
}
struct MyView: View {
@StateObject var viewModel: MyViewModel
@AppStorage("redBackground") private var redBackground: Bool = false
// WARNING: Uncommenting this causes the view model to be recreated every reconstruction of the view!
// init(viewModel: MyViewModel) {
// self._viewModel = StateObject(wrappedValue: viewModel)
// }
var body: some View {
VStack {
Button("Toggle background") {
redBackground = !redBackground
}
}
}
}
CodePudding user response:
Notice that the StateObject.init(wrappedValue:)
initialiser takes an autoclosure.
init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
This @autoclosure
is propagated to the auto-generated memberwise initialiser of MyView
, making the expression MyViewModel()
you passed to the view lazily evaluated. This is what causes SwiftUI to only create one view model for all redraws of the view.
I can't find any documentation documenting this propagation of @autoclosure
, but I can confirm it happens with this code:
@propertyWrapper
struct MyStateObject {
var wrappedValue: String
init(wrappedValue: @autoclosure () -> String) {
self.wrappedValue = wrappedValue()
}
}
struct MyView {
@MyStateObject var foo: String
}
When you compile it, there is a symbol in the binary named MyView.init(foo: @autoclosure () -> Swift.String) -> MyView
. See godbolt.org
On the other hand, your handwritten initialiser does not take @autoclosure
, so MyViewModel()
is eagerly evaluated. The lazy expression you pass into StateObject.init(wrappedValue:)
now is just the parameter name viewModel
, which is not a complicated thing to evaluate :)
So to recreate the same behaviour with your own handwritten initialiser, you should add @autoclosure
too:
init(viewModel: @autoclosure @escaping () -> MyViewModel) {
self._viewModel = StateObject(wrappedValue: viewModel())
}