Using @Observable
view model with an array collection, if addressed in init, will trigger a second App lifecycle when mutated.
Breaking it up:
Root:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
MainContentView()
}
}
}
ViewModel
@Observable
class ContentViewModel {
var items = [String]()
init() {
print("Init")
if items.isEmpty {}
}
deinit {
print("deinit")
}
func update() {
items.append("New")
}
}
View:
struct ContentView: View {
@State var viewModel: ContentViewModel = .init()
var body: some View {
Button("Update", action: viewModel.update)
}
}
When running this, it will print "Init" (expected of course), but clicking "Update" triggers a complete lifecycle.
It will print another "Init". Setting a breakpoint on the (single) WindowGroup
will stop there. Meaning (I think) that the whole var body: some Scene
is getting triggered.
items.isEmpty
, the problem goes away.ContentViewModel
twice.@Observable
(does not happens if using ObservableObject/ObservedObject
)The key difference between @StateObject
and @State
is that the property initialiser of @State
is called every time the view's initialiser is called.
So after viewModel
is first initialised, you end up creating a temporary instance of ContentViewModel
every subsequent time that ContentView.init
is called.
Normally, the temporary instance is immediately deinitialised, but it can still affect performance if you are doing something heavy in the @Observable
class' initialiser. This is pointed out in the documentation for State
.
A
State
property always instantiates its default value when SwiftUI instantiates the view. For this reason, avoid side effects and performance-intensive work when initializing the default value. For example, if a view updates frequently, allocating a new default object each time the view initializes can become expensive. Instead, you can defer the creation of the object using thetask(priority:_:)
modifier, which is called only once when the view first appears:struct ContentView: View { @State private var library: Library? var body: some View { LibraryView(library: library) .task { library = Library() } } }
Sometimes, one extra instance is retained because of some quirks in SwiftUI's algorithm of creating a dependency graph. From my experience, I have only seen at most one extra instance per @State
.
In your case, the line if items.isEmpty {}
access the observation-tracked property items
, and this access is registered in the observationRegistrar
. This affects SwiftUI's dependency graph one way or another, and the instance is not released.
You can solve this by doing what the documentation recommends - making it optional and initialising it in task
or onAppear
.
Alternatively, you can use this property wrapper by malhal.
@MainActor
@propertyWrapper struct StateObservable<T>: DynamicProperty where T: Observable & AnyObject {
@StateObject var storage: Storage
class Storage: ObservableObject {
@Published var object: T // @Published allows the object to be replaced and body will be called
init(object: T) {
self.object = object
}
}
var wrappedValue: T {
get {
return storage.object
}
nonmutating set {
storage.object = newValue
}
}
// this allows $ syntax to be used in the same View where the wrapper is declared.
var projectedValue: Bindable<T> {
get {
Bindable(storage.object)
}
}
@inlinable public init(wrappedValue thunk: @autoclosure @escaping () -> T) {
_storage = StateObject(wrappedValue: Storage(object: thunk()))
}
}
This property wrapper wraps the property initialiser expression into an @autoclosure
, so that it acts similarly to @StateObject
.