Untangling the AttributeGraph

rens breur
SwiftUISwiftUI InternalsPublished May 28, 2024

As is generally known, SwiftUI hands off some of its work to a private framework called AttributeGraph. In this article we will explore how SwiftUI uses that framework to efficiently update only those parts of an app necessary and to efficiently get the data out of your view graph it needs for rendering your app.

Debugging AttributeGraph

You've probably seen the stack trace of a SwiftUI app before, but for the purposes of this section I'm creating a new SwiftUI project in Xcode and putting a breakpoint in the body of the auto-generated ContentView. Whereas the functions in the SwiftUI framework are now hidden with names consisting of __lldb_unnamed_symbol and a number, most of the functions in the stack trace from AttributeGraph are just readable. It turns out there are many more interesting symbols from the framework exposed. If - after hitting the breakpoint - you right click on any AttributeGraph function in the stack trace in Xcode, you can see the path to the AttributeGraph framework binary, and we can run nm on that to see all exported symbols.

nm -C "/Library/Developer/CoreSimulator/.../AttributeGraph.framework/AttributeGraph" | xcrun swift-demangle

Among many others, we see the following promising print functions:

000000000000a49c t AG::Graph::print_stack() const
0000000000024928 t AG::Graph::print_attribute(AG::data::ptr<AG::Node>) const
00000000000242a0 t AG::Graph::print() const
0000000000016018 t AG::Subgraph::print(int) const

With a bit of guess work we can actually call those functions from the lldb debugger. Let's try to call the AG::Graph::print() function. The guessing part required is to find out that that function is not static and requires a pointer to a Graph. We can get such a pointer by stealing it from another function.

On my Apple Silicon mac, function parameters are passed using the CPU registers. To get a Graph pointer, we can first put a breakpoint AGGraphGetValue.

(lldb) br set -n AGGraphGetValue -s AttributeGraph

And when the breakpoint hits, we can copy the first register value that the following command returns.

(lldb) register read

(As a side note, it is also possible to find a Graph pointer in the memory graph in Xcode, but it is a bit hard to find as it doesn't have a name.)

Now that we have the parameter we need to pass to AG::Graph::print(), we also need to find the memory address that the AttributeGraph framework binary is loaded into. The following command will tell us the load address of every framework in our app.

(lldb) image list

With that information, we can now call AG::Graph::print() by creating a pointer to the address of the AttributeGraph memory location plus the relative address of the function from that nm returned, and calling that function on the Graph pointer that we captured with the breakpoint on AGGraphGetValue.

(lldb) e -l c -- typedef void (*$Func)(void *);
(lldb) e -l c -- $Func $printData = ($Func)(0x00000001ace95000+0x000000000001c564);
(lldb) e -l c -- $printData((void *)0x0000000127d07bd0);

And now, behold, AttributeGraph will print us its entire graph structure in GraphViz format in the Xcode console!

digraph {
  _8216[label="8216: AccessibilityLargeContentViewTransform" style="dashed" color=red];
  _7192 -> _7480[ label="@0"];
  ...
}

The nodes and edges in the graph even have different colors based on things such as whether they need to be updated or are currently updating.

The rest of this article will be mainly about exploring this graph structure and not about how to use the debugger, but I want to mention a couple more really useful things that helped me test the AttributeGraph framework.

First, there are the other print functions we saw above. There is AG::Graph::print_stack(), that prints information on which nodes AttributeGraph is currently updating and in which order. There is also AG::Subgraph::print(int) and AG::Graph::print_attribute(AG::data::ptr<AG::Node>) that make it possible to find out which subgraphs or attributes are used by functions while debuging. Using this together with regex breakpoints like below gives great insight into the attribute graph.

(lldb) br set -r .*AG.* -s AttributeGraph

Finally, there is also a function named AGGraphArchiveJSON, and calling that will export the entire attribute graph together with debug tree structures to a JSON file. It requires a string argument, and a Graph pointer like we saw above.

It turns out that I'm not the first to find these methods. Someone else even managed to start the debug server hinted at by the symbol AG::DebugServer::run. They are using it in an awesome attempt to rebuild both SwiftUI and the attribute graph, so definitely take a look there as well.

The attribute graph

The attribute graph of even a simple SwiftUI app looks very complex. As an example, just take the SwiftUI app template from Xcode. This is the biggest connected part of the graph that GraphViz generates for the data from AG::Graph::print:

However complex it looks, it certainly is understandable. It is better to see that using smaller diagrams that focus only on several nodes, so I will use those from now on. For example, zooming into the graph above, there is a node named ContentView, and also a node for ContentView's body.

Notice that the attribute graph certainly is not a tree. Also, although it contains a node for the body of ContentView, it doesn't contain individual nodes for the views inside the ContentView's body.

The attribute graph is a directed graph, and it doesn't contain any cycles. The nodes are of a certain type, and they can hold values. They can also have certain flags, including whether they are dirty or currently updating. Nodes that are dirty can update themselves using parent nodes, but there is not a unified way for them to do so. Every node has a different way of updating. For example, when the body node above needs to be updated, it can call the body of the view, but more on updating later.

There are certain nodes that only have edges extending outwards, such as @State values, as we will soon see. There are also nodes with only inwards edges, such as the root DisplayList. The DisplayList seems to be the main piece of information used to render a view.

As the root display list node only has parent nodes but no children, it is updated using the rest of graph, but it doesn't update other nodes. This is the description of the value for the root display list.

((display-list
  (item #:identity 1 #:version 13
    (frame (185.66666666666666 417.66666666666663; 21.666667938232422 21.666667938232422))
    (effect
      (item #:version 12
        (frame (0.0 0.0; 21.666667938232422 21.666667938232422))
        (content-seed 25)
        (shape
          (path 10.8053 {...} 3 14.9928 c h)
          (paint SwiftUI._AnyResolvedPaint<SwiftUI.Color.Resolved>)
          (style FillStyle(isEOFilled: false, isAntialiased: true))))))
  (item #:identity 2 #:version 11
    (frame (149.33333333333331 441.3333333333333; 94.66666666666666 20.333333333333332))
    (effect
      (item #:version 4
        (frame (0.0 0.0; 94.66666666666666 20.333333333333332))
        (content-seed 9)
        (text "Hello, world!" #:size (94.66666666666666, 20.333333333333332)))))), SwiftUI.DisplayList.Version(value: 14))

We can see that it contains two display items, one for the globe icon and one for the "Hello world" text. There are is no item for the VStack. That makes sense, stacks also don't get a UIView and CALayer.

The attribute graph is divided into subgraphs. Subgraphs can have children and parents. The autogenerated project above only contains two subgraphs, one root subgraph with a child. We will later see that SwiftUI uses subgraphs to combine the nodes that are added and removed together by changes to the view hierarchy.

As a debug feature, the AttributeGraph framework can also keep a record of the view trees that are used to update the nodes, but otherwise AttributeGraph doesn't really seem to care about them.

Graph updates

To get more insight into how AttributeGraph works, we need to see what happens to the attribute graph when a view is updated. I've created a very simple SwiftUI view with a label whose text depends on some state variable. To keep the changes to SwiftUI - and thus to the attribute graph - to a minimum, I'm setting this view up in a UIHostingController. I'm also copying the state variable outside of the view in onAppear so that I can have a UIKit button change that state (something you should obviously normally never do).

var state: State<Bool>?

struct MyView: View {
    @State var value = true

    var body: some View {
        Text("Value: \(value)")
            .onAppear { state = _value }
    }
}

Here is a part of the attribute graph of this SwiftUI app, containing a Void node for the value @State variable and the body of MyView. There are more inwards and outwards edges to other nodes that I haven't drawn.

The attribute graph now changes as follows when we change the state. First, changing the state marks the Void node as changed. Then, that change is propagated through the attribute graph, by recursively flagging all the children of that node as dirty. I've marked the dirty nodes yellow.

Now, many nodes are evaluated lazily, meaning the new values are only computed when the value of the node in question is needed, but a number of nodes are updated immediately. The first such one a node called AnimatableFrameAttribute.

To update a node, AttributeGraph starts a traversal backwards through the atribute graph in the following recursive way. First, it is checked if the node contains any parent nodes that are dirty. It will then update them first. Then, it can compute and return its own value. If its value is changed, it can be marked as changed itself and propagate the dirty flags again.

In the diagram below, I've colored this update path in purple. The currently updating node is the body of MyView.

Next up is the ResolvedTextFilter node, as that is also a parent of TextChildQuery.

Once the update of AnimatableFrameAttribute is complete, the next node evaluated eagerly is one called AppearanceEffect, in the same fashion.

Only on the next display cycle, when iOS will try to render the layers, is the RootDisplayList requested. It is updated in the same way as sketched above. This display list is used to change the text in the actual CALayer.

Layout changes

What happens if we resize a SwiftUI view, so that its layout has to be re-evaluated? I will change MyView as follows:

struct MyView: View {
    @State var value = false

    var body: some View {
        Color.blue
            .padding(4)
            .frame(width: value ? 50 : 30)
            .onAppear { state = _value }
    }
}

As we've seen in the last example, the state variable update will mark all nodes coming after it as dirty, which include the body of MyView and all of the nodes in the diagram below. I've only drawn the ones related to layout here. For every layout modifier there is a LayoutComputer node that performs layout calculations, and a ChildGeometry node that holds the current geometry of a layout item. There is also RootGeometry, that holds the geometry of the entire view.

When SwiftUI performs the update, it will go through the view graph in reverse again, and then first re-evaluate the view's body.

Ones the body is computed, it can update the outputs of the nodes. AttributeGraph will see that the layout computer for the frame modifier has indeed changed, whereas the padding modifier is the same as before.

It will now continue updating. All of the above nodes are updated by the end of this update cycle. I've put the final size values of the ChildGeometry and RootGeometry nodes in the diagram below.

One can see how the layout computer for frame is used to update the root geometry and the frame child geometry, and how the frame child geometry is then used with the padding layout computer to update the padding child geometry. Exactly how you would expect if you studied the SwiftUI layout logic.

Environment and preferences

The attribute graph is starting to make sense. Two things that are special in SwiftUI are environment values and (the lesser known counterpart) preference values. They are special, because they allow updates from one part of the view tree to another, even if they are far away from each other.

Let's use an environment variable to indirectly update a child view, and see what happens.

struct MyView: View {
    @State var value = false

    var body: some View {
        MyChildView()
            .environment(\.myEnvironmentValue, value)
            .onAppear { state = _value }
    }
}

struct MyChildView: View {
    @Environment(\.myEnvironmentValue) var myEnvironmentValue

    var body: some View {
        Text("Value: \(myEnvironmentValue)")
    }
}

If we change the state, we see the the same attribute graph update behavior as before.

When the body of MyView is re-evaluated, it notices that the environment values have changed, but that its inputs to the body of MyChildView haven't, and it marks the nodes and edges accordingly.

The body of MyChildView is then re-evaluated like we expect. The nice thing is that you can see that MyView only updates MyChildView through the environment value.

What if we use a preference value instead?

struct MyView: View {
    @State var value = false

    var body: some View {
        MyChildView(value: value)
            .overlayPreferenceValue(MyPreferenceKey.self) { value in
                Text(value)
            }
            .onAppear { state = _value }
    }
}

struct MyChildView: View {
    var value: Bool

    var body: some View {
        Color.blue
            .frame(width: 200, height: 200)
            .preference(key: MyPreferenceKey.self, value: "Value: \(value)")
    }
}

There are three nodes related to the preference value. The first one, Delay:My, is for the preference overlay. The preference value is stored in $My and Read:My is for the content of the preference overlay. When we flip the state value, all of these nodes are marked as dirty.

The update traversal starts out going to the body of MyChildView through other nodes that aren't displayed in these diagrams.

The most important thing to see is that the body of MyChildView updates the preference value. That value is then used by the content of the preference overlay.

Adding and inserting view trees

There is still one important thing missing. The attribute graphs of all of the SwiftUI apps we looked at had one thing in common: they were static. The values of the nodes changed, but no nodes or edges were ever added or removed. That does happen if we add or remove a view based on the state value.

var state: State<Bool>?

struct MyView: View {
    @State var value = true

    var body: some View {
        VStack {
            Text("Value:")
            if value {
                Text("True")
            }
        }
        .onAppear {
            state = _value
        }
    }
}

First, take a look at the subgraph tree. The view graphs of the examples before had a simple subgraph tree consisting of only a root subgraph and one child subgraph. This one already has two extra subgraphs, even though value is still false.

It turns out that SwiftUI does this whenever it is dealing with a dynamic number of views. It will keep view lists, in this case just with an EmptyView, in a seperate subgraph to ensure the nodes in a single subgraph don't change. It will also add the actual view contents as seperate subgraphs individually.

When the state changes, the attribute graph is marked and updated like before, until it is at the node called AnyViewList.

This node does something special, because it doesn't just set output values, it also creates a new subgraph with new nodes. In particular, it removes the old view list for the EmptyView and adds a new one for the text value.

The update then continues again as usual until DynamicContainer can use the view list to actually add nodes for the contents of the views that are in the list. (This is the place where the _makeView functions are called.) In the end, the subgraph tree will look like this:

One update traversal can cause other nodes to change, which might require yet another update. With the insertion of a new view like here, so many things in the attribute graph change, that the update cycle needs to be run many times in a row until there are no more updates left.

After this update loop, SwiftUI fetches the root display list, and uses it to update the UIView tree. It adds subviews by calling insertSubview:atIndex:.

There is much more to discover in the attribute graph of SwiftUI views. For example, I'd love to find out more about lazines. But for now this tour of AttributeGraph has come to an end.

We've seen how to debug AttributeGraph, what the structure of the attribute graph is and how the atribute graph behaves under changes. We can also see some familiar dependencies in the attribute graph, like that from layout, body evaluation and environment values.

Personally, I find it amazing how much thought has been put into so many different parts of SwiftUI. When I was working on SwiftTUI, I never thought of using something even similar to AttributeGraph. But I have wondered about certain things. How can preferences and environment values work so efficiently? But more fundamentally, if the views participating in layout are different from those being rendered and different again from the structural ones like in view builders to add and remove views, how does SwiftUI keep track of all of their states without things becoming a mess? The attribute graph is a big part of the answer.