When SwiftUI was announced in 2019 (oh boy, more than 5 years ago), one of the big things that the Apple engineers emphasized was that a SwiftUI View
is not like a UIKit/AppKit view. As Apple was at pains to note, SwiftUI may evaluate the body
property of a View
arbitrarily often. It’s easy to miss an important consequence this has, though: your View
will also be initilaized arbitrarily often. This is why SwiftUI views are supposed to be structs which are simple value types and can be stack-allocated: constructing a View
needs to be cheap.
More precisely, what I mean by the title of this post is that the lifetime of a struct that conforms to View
is unmoored from that of the conceptual thing representing a piece of your user interface.
This comes up on social media with some regularity. Every few months there’ll be questions about (either directly, or in the form of questions that boil down to) why some view’s initializer is being called more than expected. One particularly common case is people migrating from Combine/ObservableObject
to the iOS 17+ @Observable
.
If you were not paying attention to SwiftUI in the early days, or have only started learning it more recently, there are some important details about the nature of the framework you might have missed. One of the things the SwiftUI engineers emphasized when it was announced was that the body
property can be called at any time by the framework and may be called as often as the framework likes. What follows from this—that’s not entirely obvious or always explicitly stated—is that View
initializers run with the same frequency. Consider the minimal possible case:
struct MyParentView: View {
var body: some View {
MyChildView()
}
}
Evaluating MyParentView
’s body necessarily means running the initializer for MyChildView
. SwiftUI does a lot of work to make things feel magical, but, at the end of the day, the code that you’re writing is ultimately actually running. So, the initializer runs.
After that happens, SwiftUI can compare the new and old values of MyChildView
to decide whether it’s changed and, in turn, whether to read its body. But, by the time the framework is making that decision, the initializer has already been run.
An Autoclosure Corollary
It follows, I argue, from the reasons given above that it is a poor API design choice for SwiftUI property wrappers to use @autoclosure
parameters in the wrapped value initializer.
An entirely reasonable principle of API design is that you should hide unnecessary complexity from consumers of your API. There is, however, a very fine line between hiding unnecessary complexity and obscuring necessary complexity—or worse, being actively misleading about what’s happening. I think @StateObject
and its autoclosure initializer tread too close to that line.
The use of an autoclosure hides the fact that the property wrapper itself is getting initialized frequently—just like the view that contains it (initializing the view necessarily means initializing all of its properties—i.e., the property wrappers themselves). If your API design is such that you can very easily write two pieces code that, on the surface, look almost identical but have dramatically different performance characteristics, then it hinders (especially inexperienced) developers’ understanding.
@StateObject var model = MyViewModel()
// vs.
@State var model = MyViewModel()
That the name StateObject
conflates the behavior with State
certainly doesn’t help either.
Some Recommendations
Not realizing that view initializers—not just the body
property—are in the hot path can quickly get you into hot water, particularly if you’re doing any expensive work or are trying to use RAII[1] with a value in a view property. So, what follows are some general recommendations for avoiding those issues that are as close to universally correct as I’m willing to claim in writing.
No Expensive Work in Inits
This is a simple one. As discussed earlier, this is equivalent to doing that same expensive work in a view’s body
property. But SwiftUI calls both of those at arbitrary times with arbitrary frequency. Doing expensive work in either place will quickly cause performance issues.
View
Lifetimes
Ignore Even more generally, you should ignore the lifetime of your View
structs altogether. “When is my view initialized? Who knows, I don’t care!” Even if, at some point during development, you find that a particular view’s initializer is called at a useful time, avoid depending on that, even if the work you need to do is cheap. While, right now, you might have a good handle on a particular View
’s lifetime, it’s very easy to inadvertently change that in the future, either by introducing additional dependencies in the view’s parent, due to changes to the view’s identity, or due to entirely framework-internal changes.
Note that this is a particularly important idea and is worth hammering home: a View
’s initializer is called as part of its parent’s body
. This is particularly prone to action at a distance: seemingly-unrelated changes at the callsite where a view is used (or beyond) can dramatically change how often that view is initialized.
Use Lifecycle Modifiers
The @State
docs call this out with regard to Observable
, but it’s true in general. Rather than putting expensive objects as the wraped value of a @State
property wrapper, use SwiftUI’s lifecycle modifiers like .onAppear
or .task
to construct them.
What’s more, if you need to do any one-time setup work—even inexpensive—related to a particular instance of a conceptual view, the lifecycle modifiers are the way to go. They abstract you away from the particularities of the lifetime of the struct instance itself.
Resource Acquisition Is Initialization: the idea that the lifetime of a value is tied to some other resource (e.g., an open file). The wrapped value of a @State
property is evaluated every time the containing view is initialized, so doing any resource acquisition work therein can be expensive. ↩
Comments
Comments powered by ActivityPub. To respond to this post, enter your username and instance below, or copy its URL into the search interface for client for Mastodon, Pleroma, or other compatible software. Learn more.