A sneak peek into SwiftUI's graph
One performance tip that the SwiftUI team has given us during last year's WWDC is to make views initializers as light as possible: this is because our views can be created and destroyed multiple times every second.
From another perspective, our View
s are not actual views, they should be seen more as recipes to how views look and behave, but their actual implementation is handled behind the scenes by SwiftUI.
Furthermore, this explains why we use property wrappers such as @State
and @StateObject
: our View
s do not own/hold such values, it's their SwiftUI implementation counterparts that do so.
SwiftUI is a state-driven framework: things happens because something changes, and something else is observing that change.
In Let's build @State we've covered how we can create our own property wrappers observed by SwiftUI, in this new article let's explore how SwiftUI knows what to observe in the first place.
As usual, I have no access to the actual SwiftUI code/implementation. What we find here is a best guess/mock of the original behavior, there’s probably much more to it in the real implementation.
The SwiftUI graph
In graph theory a tree is a specific type of graph. This article uses both 'graph' and 'tree': even when it's just written 'graph', we're talking about a 'tree graph'.
When we display a SwiftUI view, SwiftUI creates and tracks its associated view graph.
Imagine that this is our app main view:
struct ContentView: View {
var body: some View {
NavigationView {
HStack {
NavigationLink(
"Go to A",
destination: ViewA()
)
NavigationLink(
"Go to B",
destination: ViewB()
)
}
}
}
}
Where ViewA
and ViewB
are defined as following:
struct ViewA: View {
@State var stringState: String = "A"
var body: some View {
VStack {
Text(stringState)
Button("Change string") {
stringState = ["A", "AA", "AAA", "AAAA"].randomElement()!
}
}
}
}
struct ViewB: View {
var body: some View {
Text("B")
}
}
Using the same approach in Inspecting SwiftUI views, we can explore the associated view tree, which roughly matches SwiftUI's internal graph:
NavigationView<
HStack<
TupleView<
(
NavigationLink<Text, ViewA>,
NavigationLink<Text, ViewB>
)
>
>
>
Formatted for better readability.
Where NavigationView
is the root of our tree, and each NavigationLink
is a leaf node.
When the user views ContentView
, the tree above is all SwiftUI needs to worry about.
Let's assume that the user taps on Go to A
next, at that point the active SwiftUI graph will expand to contain ViewA
's own tree:
VStack<
TupleView<
(
Text,
Button<Text>
)
>
>
The new active SwiftUI graph:
NavigationView<
HStack<
TupleView<
(
NavigationLink<
Text,
VStack<
TupleView<
(
Text,
Button<Text>
)
>
>
>,
NavigationLink<Text, ViewB>
)
>
>
>
ViewA
is not a static view, but comes with its own @State
property wrapper, which in turn comes with its own storage and publisher:
- as long as
ViewA
is part of the active graph, SwiftUI will need to allocate, hold, and subscribe toViewA
's dynamic properties. - when the user moves back to
ContentView
,ViewA
will be removed from SwiftUI's active graph, and allViewA
's dynamic properties and associated storage/publishers will need to be freed as well.
How does SwiftUI know which storage/publishers is associated to each view? Let's answer that next.
Dynamic vs. static views
Imagine that our app needs to display ViewA
:
before doing so, SwiftUI needs to figure out whether ViewA
is dynamic (a.k.a. has its own storage/publishers) or static (a.k.a. it's a set of primitives such as Int
, String
etc).
The answer to this question lays on the view's property wrappers (or the lack of them).
It's important to note how SwiftUI's property wrappers do not come with the actual associated values, but only with a reference to them: these values are not part of the View
itself.
This is to say, we can initialize a dynamic View
even when its associated storage has not been allocated yet.
With this in mind, SwiftUI can use reflection to figure out which dynamic properties a view has, before the view becomes part of the active graph:
extension View {
/// Finds and returns the dynamic properties of a view instance.
func dynamicProperties() -> [(String, DynamicProperty)] {
Mirror(reflecting: self)
.children
.compactMap { child in
if var name = child.label,
let property = child.value as? DynamicProperty {
// Property wrappers have underscore-prefixed names.
name = String(name.first == "_" ? name.dropFirst(1) : name.dropFirst(0))
return (name, property)
}
return nil
}
}
}
This new View
method:
- gets a
Mirror
representation of the view - extracts the view properties via the mirror's
children
property - filters and returns the name and and value of each
DynamicProperty
of the view.
As we've seen in Let's build @State, all SwiftUI's property wrappers conform to the
DynamicProperty
protocol.
With dynamicProperties()
(or a similar method) SwiftUI can determine whether a view is static or dynamic, and can add such findings to the associated view node in its internal graph.
Thanks to this knowledge SwiftUI has what it needs to let the user navigate within its graph, and knows what and when to instantiate and/or destroy at each movement.
ViewA example
To make things clearer, let's call dynamicProperties()
on ViewA
.
First, let's do so in ViewA
's initializer:
struct ViewA: View {
@State var stringState: String = "A"
init() {
print(dynamicProperties())
}
var body: some View {
...
}
}
The first time this gets executed is when SwiftUI evaluates ContentView
's body, as ViewA()
is part of one NavigationLink
declaration. This is dynamicProperties()
's output:
[
(
"stringState",
SwiftUI.State<Swift.String>(_value: "A", _location: nil)
)
]
dynamicProperties()
correctly finds ViewA
's @State
property (named stringState
by us), however at this point ViewA
is not part of SwiftUI's active graph, therefore there's no associated storage yet (a.k.a. _location: nil
).
Let's now call dynamicProperties()
anywhere during ViewA
's life cycle, for example on onAppear
:
struct ViewA: View {
@State var stringState: String = "A"
var body: some View {
VStack {
...
}
.onAppear(perform: {
print(dynamicProperties())
})
}
}
The only way for ViewA
's onAppear
to trigger is for the view to be about to be shown to the user, making ViewA
part of SwiftUI's active graph, in this case dynamicProperties()
's output is as following:
[
(
"stringState",
SwiftUI.State<Swift.String>(
_value: "A",
_location: Optional(SwiftUI.StoredLocation<Swift.String>)
)
)
]
Our @State
property is found again however, as ViewA
is now part of SwiftUI's active graph, its associated state is fully initialized and managed, which we can confirm from the new location
value.
Once the user goes back to ContentView
, this @State
(and its SwiftUI.StoredLocation
) will be destroyed and will not be re-created until ViewA
will be part of active graph once more.
Conclusions
In this article we took another behind the scenes look at how SwiftUI works and manages our views:
most of the time we won't need to dive this deep, however it's great knowledge to have and might help us understand why things sometimes do not work the way we expect.
I have no "inside knowledge" beside my own experimentation/experience: there's no doubt that this article over-simplifies and neglects some aspects. Regardless, it's been very helpful during my own app development, and I hope it will be for you as well.
If there's anything that you'd like me to amend/add/further-clarify, feel free to reach me out!
Thank you for reading and stay tuned for more articles.