Investigation of @StateObject lifecycle
Why do this?
@StateObject
feels like magic - and it's easy to understand for simple use-cases. The standard explanation goes like this:
Use
@StateObject
when your SwiftUI view needs ownership of an observable object, and you want to keep the same instance of the object while its owning view is alive. Use@ObservedObject
for observable objects who are owned by another view.
But when something is "magic", I worry that I might accidentally be creating the wrong number of objects. So this blogpost is an exploration of @StateObject
behaviour.
First experiment: a simple example
Take this simplest example of @StateObject
and @ObservedObject
:
class ViewModel: ObservableObject {
var objectId: String {
ObjectIdentifier(self).debugDescription
}
}
struct ParentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
VStack {
Text("Parent \(self.viewModel.objectId)")
ChildView(viewModel: self.viewModel)
}
}
}
struct ChildView: View {
@ObservedObject var viewModel: ViewModel
var body: some View {
Text("Child \(self.viewModel.objectId)")
}
}
Here, I'm using ObjectIdentifier
to find the memory location for the ViewModel
object, and displaying that location in both ParentView
and ChildView
. ParentView
creates a ViewModel
using @StateObject
and passes it to ChildView
, who observes it using @ObservedObject
.
If you run this, you'll see that both ParentView
and ChildView
show the same memory address - so the ViewModel
is indeed shared by both views.
Let's explore further.
Second experiment: multiple instances in the view hierarchy
class ViewModel: ObservableObject {
var objectId: String {
ObjectIdentifier(self).debugDescription
}
}
struct ParentView: View {
var body: some View {
VStack {
ChildView()
ChildView()
}
}
}
struct ChildView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
Text("Child \(self.viewModel.objectId)")
}
}
Here, ChildView
owns a ViewModel
which is created using @StateObject
, and our ParentView
has two children.
As you'd expect, each child gets its own instance of ViewModel
.
This is consistent with Apple's documentation for @StateObject
:
SwiftUI creates a new instance of the object only once for each instance of the structure that declares the object.
ChildView
struct appears twice in the view hierarchy. Two instances of ChildView
means two instances of ViewModel
.
But what if we create multiple ChildView
views in a loop?
Third experiment: multiple instances in a loop
class ViewModel: ObservableObject {
var objectId: String {
ObjectIdentifier(self).debugDescription
}
}
struct ParentView: View {
var body: some View {
VStack {
ForEach(0..<4, id: \.self) { _ in
ChildView()
}
}
}
}
struct ChildView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
Text("Child \(self.viewModel.objectId)")
}
}
Here, ChildView
is created in a loop... so it only appears once in the code. But at runtime, there are four instances of ChildView
. How many ViewModel
s - one or four?
Well, there are four instances of ViewModel
too. Interesting.
Let's look back at that Apple documentation:
SwiftUI creates a new instance of the object only once for each instance of the structure that declares the object.
I've highlighted "instance" there. A SwiftUI View
isn't like a UIKit UIView
. When we write a UIView
, that object is instantiated at runtime. But in SwiftUI, a View
is a description of what the UI should look like. It's not the runtime UI.
Fourth experiment - creating and destroying views
What happens when we create and destroy views which own @StateObject
objects?
class ViewModel: ObservableObject {
var objectId: String {
ObjectIdentifier(self).debugDescription
}
init() {
print("-> ViewModel init")
}
deinit {
print("-> ViewModel deinit")
}
}
struct ParentView: View {
@State var childCount = 0
var body: some View {
VStack {
Button {
if self.childCount == 0 {
childCount = 4
} else {
childCount = 0
}
} label: {
Text("Tap me")
}
ForEach(0..<self.childCount, id: \.self) { _ in
ChildView()
}
}
}
}
struct ChildView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
Text("Child \(self.viewModel.objectId)")
}
}
Here, I'm using a button to adjust the number of ChildView
instances that are displayed, toggling the number between 0 and 4.
Each time the four ChildView
instances are displayed, they get a new set of ViewModel
objects - ViewModel
s are not reused, because the owning view instance has been removed from the runtime view hierarchy. When the ChildView
s are added back into the view hierarchy, they are new instances, so they get new ViewModel
s.
I've added some debugging into ViewModel.init()
and ViewModel.deinit
- and we can see that the ViewModels are deallocated when the four ChildView
instances are removed.
So when are @StateObject
objects created? And how can they be created multiple times?
Another interesting observation: -> ViewModel init
is only printed when the four ChildView
instances are presented on-screen, not when the app first starts. But looking at the code, it appears that a ViewModel
instance should be created whenever the SwiftUI view structs are evaluated.
The clue is in the initializer for the StateObject
property wrapper:
init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
Yeah - it's an escaping autoclosure. So when we write this:
@StateObject var viewModel = ViewModel()
Then ViewModel()
is wrapped inside a closure. StateObject
property wrapper stores this closure, and calls it whenever a new ViewModel
is needed. Nice.
Fifth experiment: when the owner is the detail in a NavigationView
class ViewModel: ObservableObject {
var objectId: String {
ObjectIdentifier(self).debugDescription
}
init() {
print("-> ViewModel init")
}
deinit {
print("-> ViewModel deinit")
}
}
struct MainView: View {
var body: some View {
NavigationView {
List(0..<10, id: \.self) { index in
NavigationLink {
DetailView()
} label: {
Text("\(index)")
}
}
}
}
}
struct DetailView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
Text("Detail \(self.viewModel.objectId)")
}
}
This works exactly as you'd expect:
- selecting a cell in the primary view creates a new
DetailView
- when
DetailView
is presented, a newViewModel
is created - selecting a different cell causes the previous
ViewModel
to be deallocated, as the previousDetailView
is destroyed - selecting a cell which is already selected has no effect; the exising
ViewModel
for the presentedDetailView
remains
First published 3 January 2022