Add SwiftUI Lifecycle
This commit is contained in:
parent
07ee3bbbb6
commit
3479c45c70
|
@ -0,0 +1,74 @@
|
|||
```
|
||||
title = "Your View's Lifetime Is Not Yours"
|
||||
tags = ["swift"]
|
||||
date = "2024-11-30 18:41:42 -0500"
|
||||
slug = "swiftui-lifecycle"
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
<!-- excerpt-end -->
|
||||
|
||||
<aside class="inline">
|
||||
|
||||
A note on definitions: in this post, I'm using the word 'lifecycle' and 'lifetime' in very particular ways. Lifecycle is connected to the conceptual view, and encompasses things like the `onAppear`/`onDisappear` modifiers. Lifetime, however, is a property of a value: the time from its initialization to its destruction (that is, how long the value exists in memory).
|
||||
|
||||
</aside>
|
||||
|
||||
|
||||
|
||||
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:
|
||||
|
||||
```swift
|
||||
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.
|
||||
|
||||
```swift
|
||||
@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.
|
||||
|
||||
[^1]: 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### Ignore `View` Lifetimes
|
||||
|
||||
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](https://developer.apple.com/videos/play/wwdc2021/10022/), 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](https://developer.apple.com/documentation/swiftui/state#Store-observable-objects) 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.
|
Loading…
Reference in New Issue