A sneak peek into SwiftUI's graph

30 March 2021

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 Views 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 Views 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 to ViewA's dynamic properties.
  • when the user moves back to ContentView, ViewA will be removed from SwiftUI's active graph, and all ViewA'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.

⭑⭑⭑⭑⭑

Related articles

More SwiftUI articles

Browse all