Debugging SwiftUI with AttributeGraph: From DisplayList to Transactions
Note: Reading this article requires you to have some understanding of the principles of SwiftUI and AttributeGraph, such as DisplayList / Animation / Attribute, etc.
For readers less familiar with SwiftUI internal mechanisms, here is a brief summary:
- SwiftUI Data Flow:
- -> Upstream DSL provides type information via
body - -> Builds a dependency graph based on type information using Attribute and Graph
- -> Over time, drives the generation of DisplayList
- -> Render completes on-screen rendering based on DisplayList
- -> Upstream DSL provides type information via
- AttributeGraph:
- A high-performance computation engine built on Attribute and Graph
Unless otherwise specified, the code environment in this article is iPhone 16 Pro + iOS 18.5 + Xcode 16.4
Problem Description
After recently implementing combineAnimation, it was found that in OpenSwiftUI, when the device screen is rotated during or after an animation, the corresponding View will remain at its original relative position within the container and then animate to the new correct position, which does not occur in SwiftUI.
Related issue: OpenSwiftUI Issue #461
Expected behavior / SwiftUI behavior:
Current behavior / OpenSwiftUI behavior:
struct ColorAnimationExample: View {
@State private var showRed = false
var body: some View {
VStack {
Color(platformColor: showRed ? .red : .blue)
.frame(width: showRed ? 200 : 400, height: showRed ? 200 : 400)
}
.animation(.easeInOut(duration: 5), value: showRed)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
showRed.toggle()
}
}
}
}
Problem Troubleshooting
DisplayList
First, set SWIFTUI_PRINT_TREE=1 to observe the first DisplayList output after stopping the animation:
// SwiftUI & OpenSwiftUI old vertical DL
View 0x0000000107504a10 at Time(seconds: 6.0884439583460335):
(display-list
(item #:identity 1 #:version 900
(frame (101.0 370.3333333333333; 200.0 200.0))
(content-seed 1801)
(color #FF0000FF)))
// rotate screen
// SwiftUI new
(frame (337.0 112.66666666666666; 200.0 200.0))
// OpenSwiftUI new
(frame (101.0 370.33333333333326; 200.0 200.0))
...
(frame (337.0 112.66666666666666; 200.0 200.0))
DL is calculated through the rootDL of ViewGraph, and in this example, it is calculated from the LeafDisplayList corresponding to the sole Color.
LeafDisplayList
We set a breakpoint at LeadDisplayList.updateValue - using the symbol after specializing with the ColorView generic $s7SwiftUI15LeafDisplayList33_65609C35608651F66D749EB1BD9D2226LLV11updateValueyyFAA9ColorViewV_Tg5 to set a symbolic breakpoint
We can then arrive at this stack:

SwiftUICore`generic specialization of SwiftUI.LeafDisplayList.updateValue() -> ():
-> 0x1d45e16b4 <+0>: sub sp, sp, #0x170
0x1d45e16b8 <+4>: stp d9, d8, [sp, #0x100]
0x1d45e16bc <+8>: stp x28, x27, [sp, #0x110]
0x1d45e16c0 <+12>: stp x26, x25, [sp, #0x120]
0x1d45e16c4 <+16>: stp x24, x23, [sp, #0x130]
0x1d45e16c8 <+20>: stp x22, x21, [sp, #0x140]
0x1d45e16cc <+24>: stp x20, x19, [sp, #0x150]
0x1d45e16d0 <+28>: stp x29, x30, [sp, #0x160]
0x1d45e16d4 <+32>: add x29, sp, #0x160
0x1d45e16d8 <+36>: add x23, sp, #0x60
We can get the relevant definitions using the swift-section tool.
private struct LeafDisplayList<V>: StatefulRule, CustomStringConvertible where V: RendererLeafView {
let identity: DisplayList.Identity
@Attribute var view: V
@Attribute var position: ViewOrigin
@Attribute var size: CGSize
@Attribute var containerPosition: ViewOrigin
let options: DisplayList.Options
var contentSeed: DisplayList.Seed
}
Use memory read -fx -s8 $x20 to view the address corresponding to the x20 register, and we can then restore the current LeafDisplayList state:
(lldb) mr $x20
0x10f431034: 0x000010c000000001 0x000011e9000011c9
0x10f431044: 0x06ff600000000358 0x00002a3d00000000
0x10f431054: 0x00001a4020000018 0x000019b800000082
0x10f431064: 0x000019b000000021 0x000010c000000000
// identity: 0x1
// _view: #0x10c0
// _position: #0x11c9
// _size: #0x11e9
// _containerPosition: #0x358
The frame.origin of DisplayList.Item is calculated by position - containerPosition
Simple debugging reveals that the values of containerPosition are all .zero, and the evaluation results of position are inconsistent.
4553 % 1 = 1, so the _position here is an indirect node. Through the source, we can know its parent node, and then obtain the body type of the parent node:
(lldb) po AnyAttribute(rawValue: 0x11c9).source._bodyType
SwiftUI.AnimatableFrameAttribute
(lldb) po AnyAttribute(rawValue: 0x11c9).source.valueType
SwiftUI.ViewFrame
AnimatableFrameAttribute
Re-prepare the environment, after the animation has stopped, add $s7SwiftUI24AnimatableFrameAttributeV11updateValueyyF breakpoint, and rotate the device screen, then we can break into the evaluation function of AnimatableFrameAttribute.
After a simple analysis, we found that the results calculated by the two at sourceValue are consistent. After the helper.update function, the viewFrame value of OpenSwiftUI has changed.
package struct AnimatableFrameAttribute: StatefulRule, AsyncAttribute, ObservedAttribute {
@Attribute
private var position: ViewOrigin
@Attribute
private var size: ViewSize
@Attribute
private var pixelLength: CGFloat
@Attribute
private var environment: EnvironmentValues
private var helper: AnimatableAttributeHelper<ViewFrame>
package mutating func updateValue() {
...
var sourceValue = (
value: viewFrame,
changed: anyChanged
)
if !animationsDisabled {
helper.update(
value: &sourceValue,
defaultAnimation: nil,
environment: $environment
)
}
guard sourceValue.changed || !hasValue else {
return
}
value = sourceValue.value
}
}
Transaction
Continue to drill down, and the distinguishing point between the two is that the animation obtained from the transaction in SwiftUI is nil
SwiftUICore`function signature specialization of closure #1 (A.AnimatableData, SwiftUI.Time) -> () in SwiftUI.AnimatableAttributeHelper.update(value: inout (value: A, changed: Swift.Bool), defaultAnimation: Swift.Optional, environment: AttributeGraph.Attribute) -> (), Argument Types : []> of generic specialization of SwiftUI.AnimatableAttributeHelper.update(value: inout (value: τ_0_0, changed: Swift.Bool), defaultAnimation: Swift.Optional, environment: AttributeGraph.Attribute, sampleCollector: (τ_0_0.AnimatableData, SwiftUI.Time) -> ()) -> ():
...
0x1d4afab24 <+384>: adrp x2, 108902
0x1d4afab28 <+388>: add x2, x2, #0x3b8 ; type metadata for SwiftUI.Transaction
0x1d4afab2c <+392>: mov x0, x21
-> 0x1d4afab30 <+396>: mov w1, #0x0 ; =0
0x1d4afab34 <+400>: bl 0x1d4d83d38 ; symbol stub for: AGGraphGetValue
0x1d4afab38 <+404>: ldr x26, [x0]
0x1d4afab3c <+408>: mov x0, x26
0x1d4afab40 <+412>: bl 0x1d4d85c4c ; symbol stub for: swift_retain
0x1d4afab44 <+416>: sub x0, x29, #0xf0
0x1d4afab48 <+420>: bl 0x1d4595bb0 ; outlined release of SwiftUI.AnimatableAttribute
0x1d4afab4c <+424>: mov x0, x20
0x1d4afab50 <+428>: bl 0x1d4d83e04 ; symbol stub for: AGGraphSetUpdate
0x1d4afab54 <+432>: mov x0, x26
0x1d4afab58 <+436>: bl 0x1d4d85c4c ; symbol stub for: swift_retain
0x1d4afab5c <+440>: bl 0x1d451feb0 ; generic specialization > of SwiftUI.find<τ_0_0 where τ_0_0: SwiftUI.PropertyKey>(_: Swift.Optional>, key: τ_0_0.Type) -> Swift.Optional>>
The corresponding function implementation is roughly as follows:
let transaction: Transaction = Graph.withoutUpdate { self.transaction }
guard let animation = transaction.effectiveAnimation else {
return
}
Debugging the transaction here reveals that its bodyType is the private struct ChildTransaction in AnimationModifier.swift
(lldb) reg read x21
x21 = 0x0000000000000e18
(lldb) po AnyAttribute(0xe18)._bodyType
SwiftUI.(unknown context at $1d4e8e3d4).ChildTransaction
For more information about private discriminator, please refer to swift-pd-guess
ChildTransaction
Similarly, we can directly perform a simple reverse operation on ChildTransaction, and then refer to the steps above:
Re-prepare the environment, after the animation has stopped, add $s7SwiftUI16ChildTransaction33_530459AF10BEFD7ED901D8CE93C1E289LLV5valueAA0D0Vvg breakpoint, and rotate the device screen, then we can break into the evaluation function of ChildTransaction.
SwiftUICore`SwiftUI.ChildTransaction.value.getter : SwiftUI.Transaction:
-> 0x1d4b40708 <+0>: sub sp, sp, #0x70
0x1d4b4070c <+4>: stp x26, x25, [sp, #0x20]
0x1d4b40710 <+8>: stp x24, x23, [sp, #0x30]
0x1d4b40714 <+12>: stp x22, x21, [sp, #0x40]
0x1d4b40718 <+16>: stp x20, x19, [sp, #0x50]
0x1d4b4071c <+20>: stp x29, x30, [sp, #0x60]
Similarly, we can restore the relevant implementation and the state of the current ChildTransaction:
private struct ChildTransaction: Rule, AsyncAttribute {
@Attribute var valueTransactionSeed: UInt32
@Attribute var animation: Animation?
@Attribute var transaction: Transaction
@Attribute var transactionSeed: UInt32
var value: Transaction {
var transaction = transaction
guard !transaction.disablesAnimations else {
return transaction
}
let oldTransactionSeed = Graph.withoutUpdate { transactionSeed }
guard valueTransactionSeed == oldTransactionSeed else {
return transaction
}
transaction.animation = animation
Swift.assert(transactionSeed == oldTransactionSeed)
return transaction
}
}
(lldb) rr x0 x1
x0 = 0x00000df100000dc8
x1 = 0x00000300000002b8
; _valueTransactionSeed: 0xdc8
; _transactionSeed: 0x300
After a simple analysis, we can see that the difference in logic lies in:
SwiftUI:
- oldTransactionSeed / w25 = 0x7c
- valueTransactionSeed / w8 = 0x6
OpenSwiftUI:
- oldTransactionSeed = 0x1
- valueTransactionSeed = 0x1
Therefore, we continued to examine the upstream sources of these two Attributes. After a simple analysis of _AnimationModifier, we found that ChildTransaction.transactionSeed is GraphHost.currentHost.data.$transactionSeed
public struct _AnimationModifier<Value>: ViewModifier, PrimitiveViewModifier where Value: Equatable {
public var animation: Animation?
public var value: Value
@inlinable
public init(animation: Animation?, value: Value) {
self.animation = animation
self.value = value
}
nonisolated static func _makeInputs(
modifier: _GraphValue<Self>,
inputs: inout _GraphInputs
) {
let transactionSeed = GraphHost.currentHost.data.$transactionSeed
let seed = Attribute(
ValueTransactionSeed(
value: modifier.value[offset: { .of(&$0.value) }],
transactionSeed: transactionSeed
)
)
seed.flags = .transactional
inputs.transaction = Attribute(
ChildTransaction(
valueTransactionSeed: seed,
animation: modifier.value[offset: { .of(&$0.animation) }],
transaction: inputs.transaction,
transactionSeed: transactionSeed
)
)
}
}
We're almost on the verge of identifying the root cause of the issue. A reasonable assumption is that it's due to the transactionSeed on GraphHost data failing to trigger an update correctly.
GraphHost.data.transactionSeed
A simple idea is to add breakpoints to the setters related to transactionSeed, and after screen rotation, check which stack has calls made on it:
SwiftUI.GraphHost.Data.transactionSeed.setter
-$s7SwiftUI9GraphHostC4DataV15transactionSeeds6UInt32VvsSwiftUI.GraphHost.Data.$transactionSeed.setter
-$s7SwiftUI9GraphHostC4DataV16$transactionSeed09AttributeC00H0Vys6UInt32VGvs
But if in fact none of these functions will be triggered, because the type of transactionSeed is Attribute<UInt32>, so it is highly likely that this is triggered via Attribute.wrappedValue.setter (which will be inlined into an AGGraphSetValue call)
We add a symbolic breakpoint for AGGraphSetValue, add an action to read registers x0 and x2, and set it to automatic continue mode:
View 0x000000011db04bc0 at Time(seconds: 27.712069541739766):
(display-list
(item #:identity 1 #:version 848
(frame (337.0 112.66666666666666; 200.0 200.0))
(content-seed 1679)
(color #FF0000FF)))
x0 = 0x0000000000000218
x2 = 0x00000001ef490c60 SwiftUICore`type metadata for SwiftUI.Time
x0 = 0x00000000000002e0
x2 = 0x00000001e83fce38 libswiftCore.dylib`type metadata for Swift.UInt32
x0 = 0x0000000000000300
x2 = 0x00000001e83fce38 libswiftCore.dylib`type metadata for Swift.UInt32
x0 = 0x0000000000000218
x2 = 0x00000001ef490c60 SwiftUICore`type metadata for SwiftUI.Time
x0 = 0x00000000000002e0
x2 = 0x00000001e83fce38 libswiftCore.dylib`type metadata for Swift.UInt32
x0 = 0x0000000000000300
x2 = 0x00000001e83fce38 libswiftCore.dylib`type metadata for Swift.UInt32
It can be seen that the expected 0x300 indeed triggered the AGGraphSetValue function call.
Reconfigure the symbolic breakpoint for AGGraphSetValue, add the condition $x0 == 0x300, and we will finally obtain the relevant updated context
By examining the previous frame of the current stack, it was ultimately determined that OpenSwiftUI missed a line data.transactionSeed &+= 1 call in ViewGraph.updateOutputs(async: Swift.Bool)
SwiftUICore`SwiftUI.ViewGraph.updateOutputs(async: Swift.Bool) -> ():
...
0x1d4d6f09c <+264>: mov x0, x20
0x1d4d6f0a0 <+268>: mov x2, x22
0x1d4d6f0a4 <+272>: bl 0x1d4d83e1c ; symbol stub for: AGGraphSetValue
-> 0x1d4d6f0a8 <+276>: sub x0, x29, #0x100
Final fix PR: OpenSwiftUI #464
Problem Summary
The logic data.transactionSeed &+= 1 was missing in ViewGraph.updateOutputs(async: Swift.Bool).
As a result, the evaluation of transaction in _AnimationModifier was incorrect: nodes that should not have had animation still carried animation, which in turn affected the generation of the DisplayList.
Debugging Techniques
Using Graph/Attribute API in Assembly Mode
Use the following debugging code to enter Swift language mode and import OpenGraphShims (add OpenGraph package dependency) or AttributeGraph (add DarwinPrivateFrameworks package dependency), after which we can obtain the corresponding values through the relevant APIs
- Eg.
AnyAttribute._valueType/AnyAttribute.valuePointer/AnyAttribute.bodyType
(lldb) settings set target.language swift
po import OpenGraphShims
(lldb) po AnyAttribute(rawValue: 0x11c9)
▿ #4553
- rawValue : 4553
AnyAttribute.debugDescription
The latest version of OpenGraphShims provides a debugDescription implementation for AnyAttribute, allowing for quick viewing of relevant complete attribute information
Related PR: OpenGraph #169
Example:
(lldb) po AnyAttribute(rawValue: 0x11c9).debugDescription
rawValue: 4553
graph:
(indirect attribute)
source attribute:
rawValue: 4432
graph:
(direct attribute)
valueType: ViewFrame
value: ViewFrame(origin: (1.0, 270.3333333333333), size: SwiftUI.ViewSize(value: (400.0, 400.0), _proposal: (400.0, 400.0)))
bodyType: AnimatableFrameAttribute
bodyValue:
AnimatableFrameAttribute(_position: Attribute {
rawValue: 4136
graph:
(direct attribute)
valueType: CGPoint
value: (1.0, 270.1666666666667)
bodyType: LayoutPositionQuery
bodyValue:
LayoutPositionQuery(_parentPosition: Attribute {
Summary
Through this in-depth debugging analysis, we successfully located and fixed an animation-related bug in OpenSwiftUI. The entire process demonstrated how to:
- Use DisplayList output to analyze view rendering issues
- Use symbolic breakpoints and assembly debugging to deeply analyze SwiftUI's internal mechanisms
- Use AttributeGraph's debugging tools for problem localization
- Understand SwiftUI's Transaction and Animation systems
This debugging approach is very valuable for understanding SwiftUI's internal working principles and solving complex rendering issues.