知阅百微 见微知著

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
  • 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:

Image

Current behavior / OpenSwiftUI behavior:

Image
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:

Xcode Screenshot

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
    - $s7SwiftUI9GraphHostC4DataV15transactionSeeds6UInt32Vvs
  • SwiftUI.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:

  1. Use DisplayList output to analyze view rendering issues
  2. Use symbolic breakpoints and assembly debugging to deeply analyze SwiftUI's internal mechanisms
  3. Use AttributeGraph's debugging tools for problem localization
  4. 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.