StateObject is an attribute wrapper added in SwiftUI 2.0, which solves unexpected issues when using ObservedObject in certain situations. This article will introduce the similarities, differences, principles, and precautions between the two.
Conclusion
Both StateObject and ObservedObject are property wrappers used to subscribe to observable objects (reference types that conform to the ObservableObject protocol). When the subscribed observable object sends data through the built-in Publisher (by using @Published or directly calling its objectWillChange.send method), StateObject and ObservedObject will drive their associated views to update.
ObservedObject only maintains the subscription relationship during the view’s lifetime, while StateObject, in addition to maintaining the subscription relationship, also maintains a strong reference to the observable object.
Based on Swift’s ARC (Automatic Reference Counting) mechanism, StateObject ensures that the lifetime of the observable object is not less than the lifetime of the view, thus ensuring data stability during the view’s lifetime.
However, since ObservedObject only maintains the subscription relationship, if the lifetime of the subscribed observable object is less than the lifetime of the view, the view will display various uncontrollable behaviors.
Some may wonder, does the instance corresponding to testObject in the code below have a shorter lifespan than the view?
struct DemoView: View {
@ObservedObject var testObject = TestObject()
var body: some View {
Text(testObject.name)
}
}
In some cases, this is indeed true. The following will explore the reasons in detail.
Principle
ARC
Swift uses Automatic Reference Counting (ARC) to track and manage the memory use of instances of reference types. As long as a strong reference to a class instance exists, ARC will not release the memory used by that instance. In other words, once the strong reference to an instance is 0, the instance will be destroyed by Swift, and the memory it occupies will be reclaimed.
StateObject ensures that the lifetime of the observable object instance is not less than that of the view by maintaining a strong reference to the observable object.
Subscription and Cancellable
In Combine, when using sink or assign to subscribe to a Publisher, it is necessary to hold the subscription relationship to make the subscription work normally. The subscription relationship is wrapped into AnyCancellable type, and developers can manually cancel the subscription by calling the cancel method of AnyCancellable.
var cancellable: AnyCancellable?
init() {
cancellable = NotificationCenter.default.publisher(for: .AVAssetContainsFragmentsDidChange)
.sink { print($0) }
}
var cancellable = Set<AnyCancellable>()
init() {
NotificationCenter.default.publisher(for: .AVAssetContainsFragmentsDidChange)
.sink { print($0) }
.store(in: &cancellable)
}
In addition to actively canceling the subscription relationship from the subscriber, if the Publisher no longer exists, the subscription relationship will also be automatically released.
Both ObservedObject and StateObject store the subscription relationship between the view and the observable object. During the existence of the view, they will not actively cancel this subscription. However, ObservedObject cannot guarantee whether the observable object will unsubscribe early due to being destroyed.
Description, Example, and View
SwiftUI is a declarative framework where developers use code to declare (describe) the desired UI presentation. Below is an example of a view declaration (description):
struct DemoView: View {
@StateObject var store = Store()
var body: some View {
Text("Hello \\(store.username)")
}
}
When SwiftUI starts creating the view generated by this description, it will roughly follow these steps:
- Create an instance of DemoView
- Do some preparation work related to the view (e.g. dependency injection)
- Evaluate the body property of the instance
- Render the view
From SwiftUI’s perspective, a view is a piece of data corresponding to a certain area on the screen, which is calculated by calling the body property of an instance created based on a description that describes that area.
The lifespan of a view begins when it is loaded into the view hierarchy and ends when it is removed from the view hierarchy.
During the lifespan of a view, its value will constantly change based on the source of truth (various dependency sources). SwiftUI will also constantly create new instances of the area that describe the view during its lifespan for various reasons, in order to ensure accurate computed values.
As instances are repeatedly created, developers must use specific identifiers (@State, @StateObject, etc.) to inform SwiftUI that certain states are bound to the lifespan of the view and are unique during this period.
When a view is loaded into the view hierarchy, SwiftUI will host the states that need to be bound (@State, @StateObject, onReceive, etc.) in its managed data pool based on the instance used at the time. Regardless of how many times the instance is recreated later, SwiftUI will only use the states created during the first creation. This means that the task of binding states to views is only performed once.
Please read the SwiftUI View Lifecycle Study article to learn more about the relationship between views and instances.
Property Wrappers
Swift’s Property Wrappers add a layer of separation between the code that manages property storage and the code that defines the property. On one hand, it is convenient for developers to encapsulate some common logic and apply it to a given data. On the other hand, if developers do not fully understand the purpose of a property wrapper, it may lead to inconsistency between what is perceived and what is actually happening (misunderstanding).
In many cases, we need to understand the names of SwiftUI’s property wrappers from a view perspective, such as:
- ObservedObject (view subscribes to an observable object)
- StateObject (subscribes to an observable object and holds a strong reference to it)
- State (holds a value)
ObservedObject and StateObject both satisfy the DynamicProperty protocol to achieve the above functions. When SwiftUI adds a view to the view tree, it calls the _makeProperty method to save the necessary subscription relationships, strong references, and other information to SwiftUI’s internal data pool.
Please read the article “Avoiding Redundant Computations in SwiftUI Views” for more details on the implementation of DynamicProperty.
The Reason for the Occurrence of Strange Phenomena in ObservedObject
If code like @ObservedObject var testObject = TestObject()
is used, strange phenomena may occur at times.
The article “Investigating @StateObject” shows a code snippet that triggers strange phenomena due to incorrect use of ObservedObject.
This occurs because once SwiftUI creates a new instance and uses it during the view’s lifespan (in some cases, creating a new instance does not necessarily mean using it), the originally created TestObject instance will be released (because there is no strong reference), and the subscription relationship held in ObservedObject will also become invalid.
Some views, perhaps because of their high position in the view tree (such as the root view), or because of their short lifespan, or because they are less affected by other states, tend to ensure that SwiftUI creates only one instance during their lifespan. This is also the reason why @ObservedObject var testObject = TestObject()
does not always fail.
Notes
-
Avoid creating code like
@ObservedObject var testObject = TestObject()
The reason has already been explained above. The correct usage of ObservedObject is:
@ObservedObject var testObject:TestObject
. By passing an observable object from a parent view with a longer lifespan than the current view, unpredictable situations can be avoided. -
Avoid creating code like
@StateObject var testObject:TestObject
Similar to
@ObservedObject var testObject = TestObject()
,@StateObject var testObject:TestObject
may also occasionally have unexpected behaviors. For example, in some cases, the developer needs the parent view to continuously generate a brand new observable object instance to pass to the child view. However, because the child view uses StateObject, it will only retain a strong reference to the first instance passed in, and subsequent instances passed in will be ignored. It is best to use@StateObject var testObject = TestObject()
to avoid ambiguous expressions. -
Use lightweight constructor methods for reference types used in lightweight views
Regardless of whether ObservedObject or StateObject is used, or no property wrapper is added, the class instances declared in the view will be created repeatedly with each view description. Avoid introducing irrelevant operations in their constructor methods to greatly reduce the burden on the system. For data preparation work, onAppear or task can be used to perform them when the view is loaded.
When to Choose ObservedObject
Although this article has thoroughly explored the working principles of StateObject and ObservedObject, it hasn’t yet touched upon a core question: When is ObservedObject the best choice? In which scenarios is its use particularly important?
Let’s simplify some complex concepts first. A significant feature of StateObject is the uniqueness of its instances. In other words, once you use @StateObject, the marked object instance remains unique throughout the entire lifecycle of its associated view. This means that even if the view itself undergoes updates (i.e., the view’s constructor method is called again), the object instance will not be recreated. This is the key difference between ObservedObject and StateObject.
As for ObservedObject, one of its major characteristics is that throughout the entire lifecycle of the view, @ObservedObject can flexibly switch and associate with different instances. For example, in a NavigationSplitView, the sidebar might list several different instances complying with the ObservableObject protocol, and the detail view responds to one of these instances. By selecting different instances in the sidebar, the detail view can dynamically switch its data source, even though the view itself gets updated, it is not rebuilt.
The following code example further illustrates this point:
class NVStore: ObservableObject {
var item: Item?
@Published var id = UUID()
class Item: ObservableObject {
let id: Int
init(id: Int) {
self.id = id
}
}
}
struct NVTest: View {
@StateObject var store = NVStore()
var body: some View {
NavigationSplitView {
List(0..<10) { i in
Button {
store.item = .init(id: i)
store.id = UUID()
} label: {
Text("\(i)")
}
}
} detail: {
if let item = store.item {
NVDetailView(item: item)
}
}
}
}
struct NVDetailView: View {
@State var id = UUID()
@ObservedObject var item: NVStore.Item
var body: some View {
VStack {
Text("\(id)")
Text("\(item.id)")
}
}
}
This implies that in scenarios dealing with many-to-one relationships, choosing ObservedObject is the most appropriate and effective strategy.
Summary
StateObject and ObservedObject are property wrappers that we often use, and they each have their own strengths. Understanding their connotations not only helps in selecting appropriate application scenarios, but also helps in mastering the longevity mechanism of SwiftUI views.