LODY/정리

애플 플랫폼 백과사전 / AttributeGraph 풀어 보기

AttributeGraph 풀어 보기

원문: Untangling the AttributeGraph — rensbr.eu 저자: Rens Breur 발행: 2024년 5월 28일

SwiftUI 는 자기 일의 일부를 AttributeGraph 라는 private framework 에 넘긴다. 이 프레임워크가 어떻게 앱의 꼭 필요한 부분만 효율적으로 갱신하고, 렌더링에 필요한 데이터를 view graph 에서 가져오는지 살펴본다.


AttributeGraph 디버깅하기

새로 만든 SwiftUI 프로젝트의 view body 에 breakpoint 를 걸어 보면 SwiftUI 쪽 심볼은 가려져 있는 반면 AttributeGraph 함수들은 스택 트레이스에서 그대로 읽힌다. 프레임워크 바이너리에는 print 계열을 포함한 심볼이 export 되어 있다.

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

이 디버깅 함수들은 lldb 로 호출할 수 있다. AG::Graph::print() 를 부르려면 먼저 breakpoint 를 건다.

(lldb) br set -n AGGraphGetValue -s AttributeGraph

breakpoint 에 걸리면 CPU register 에서 Graph 포인터를 얻는다.

(lldb) register read

프레임워크의 메모리 주소도 확인한다.

(lldb) image list

이 정보로 함수를 호출할 수 있다.

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

이렇게 하면 GraphViz 형식의 출력 이 나온다. 전체 graph 구조를 보여 주는데, 노드와 엣지가 update 상태에 따라 색으로 구분되어 있다.

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

다른 유용한 디버깅 함수로는 현재 update 중인 노드와 그 순서를 보여 주는 AG::Graph::print_stack(), 특정 subgraph 와 attribute 를 검사할 수 있는 AG::Subgraph::print(int), AG::Graph::print_attribute() 가 있다. 테스트에는 regex breakpoint 가 쓸 만하다.

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

AGGraphArchiveJSON 함수는 attribute graph 전체를 debug tree 구조와 함께 JSON 으로 내보낸다. string 인자와 Graph 포인터를 받는다.

다른 사람들도 이런 기법을 발견해 왔다. AG::DebugServer::run 심볼이 힌트를 주는 "debug server 띄우기" 시도 같은 것들이다. OpenSwiftUIProject/AGDebugKit 는 SwiftUI 와 attribute graph 둘 다 재구축하려는 시도다.


Attribute Graph

간단한 SwiftUI 앱이라도 attribute graph 는 복잡하다. 기본 템플릿조차 수많은 노드들이 서로 얽혀 있다. 초점을 좁힌 다이어그램으로 봐야 구조가 보인다.

Sample app

그래프 안에는 ContentView 노드가 있고, 그 body 노드도 있다. attribute graph 는 tree 가 아니고 cycle 도 없다. 노드는 값과 플래그를 가지는데, dirty 여부와 update 상태 같은 플래그가 포함된다. body 노드를 update 해야 할 때 view 의 body 함수가 호출될 수 있지만, 노드 타입마다 update 방식이 다르다.

Detail 1 Detail 2

어떤 노드는 나가는 엣지만 가진다 (예: @State 값). 어떤 노드는 들어오는 엣지만 가진다 (예: root 의 DisplayList). root display list 가 곧 렌더링에 쓰이는 주 데이터다.

DisplayList 값은 모든 display item 을 구조화해 담는다.

((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))

display item 이 두 개 있다. 하나는 globe 아이콘, 하나는 텍스트다. VStack 에 대한 item 은 없는데, stack 이 UIView 나 CALayer 를 만들지 않는 것과 같은 맥락이다.

attribute graph 는 parent·child 관계를 가진 subgraph 들로 나뉜다. 템플릿 프로젝트는 root subgraph 와 그 자식 한 개로 구성된다. SwiftUI 는 view 계층이 바뀔 때 함께 붙거나 떨어질 노드들을 subgraph 로 묶는다. AttributeGraph 는 노드 update 중에 쓰는 view tree 기록을 유지할 수 있지만, 프레임워크가 평소에는 거의 참조하지 않는다.


Graph update

view update 중에 attribute graph 가 어떻게 바뀌는지 보려면 단순한 케이스가 좋다. state 에 의존하는 라벨 하나가 있다고 하자. SwiftUI 쪽 변화를 최소화하려고 UIHostingController 를 쓴다.

var state: State<Bool>?
 
struct MyView: View {
    @State var value = true
 
    var body: some View {
        Text("Value: \(value)")
            .onAppear { state = _value }
    }
}

attribute graph 에는 @State 변수용 Void 노드와 body 노드가 있다.

Simple state 1

state 가 바뀌면 이 Void 노드가 changed 로 표시되고, 변경이 재귀적으로 전파되면서 모든 자식 노드가 dirty 로 플래그된다.

Simple state 2

많은 노드는 lazy 하게 평가된다. 필요할 때만 새 값이 계산된다. 하지만 어떤 노드는 즉시 update 된다. AnimatableFrameAttribute 같은 것들이 그렇다. AttributeGraph 는 graph 를 역방향으로 재귀 순회한다. 노드에 dirty parent 가 있는지 확인하고 (있으면 먼저 update 한 뒤) 자기 값을 계산한다. 값이 바뀌면 자신을 changed 로 표시하고 dirty 플래그를 전파한다.

Simple state 3

AnimatableFrameAttribute 가 끝나면 그다음으로 즉시 평가되는 노드는 AppearanceEffect 다. 같은 과정을 거친다. 다음 display cycle 에서 iOS 가 layer 를 렌더링하려 할 때가 되어야 RootDisplayList 가 요청되고 update 된다. 그 결과 실제 CALayer 의 텍스트가 바뀐다.

Simple state 4


Layout 변화

SwiftUI view 크기가 바뀌어 layout 을 다시 계산해야 할 때를 보자.

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

Layout 1

state update 는 뒤에 이어지는 모든 노드를 dirty 로 표시한다. body 와 layout 관련 노드들도 포함된다. 각 layout modifier 마다 계산을 맡는 LayoutComputer 노드와 geometry 를 담는 ChildGeometry 노드가 있다. view 전체의 geometry 는 RootGeometry 노드가 담는다.

Layout 2

update 과정에서 SwiftUI 는 view graph 를 역방향으로 따라가며 body 를 다시 평가한다. 계산이 끝나면 노드의 출력을 update 한다. AttributeGraph 는 frame modifier 의 layout computer 가 바뀌었고 padding 은 그대로라는 사실을 인식한다.

Layout 3

이어서 frame 의 layout computer 가 root geometry 와 frame child geometry 를 둘 다 update 한다. 그런 다음 padding layout computer 와 결합해 padding child geometry 를 update 한다. SwiftUI 의 문서화된 layout 동작 그대로다.

Layout 4


Environment 와 Preference

environment 값과 preference 값은 view tree 의 멀리 떨어진 영역 사이에 update 를 전달 할 수 있게 해 준다. environment 변수가 자식 view 를 간접적으로 update 하는 경우를 테스트해 보자.

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)")
    }
}

Environment 1

state 가 바뀌면 앞서와 같은 graph update 가 일어난다. 프레임워크가 MyView 의 body 를 다시 평가하는 과정에서 environment 값은 바뀌었지만 MyChildView 의 body 입력은 그대로라는 걸 인식하고, 노드와 엣지를 그에 맞게 마킹한다.

Environment 2

MyView 는 오직 environment 값을 통해서만 MyChildView 를 update 한다.

Environment 3

preference 값으로 바꿔 보면 이렇다.

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)")
    }
}

Preference 1

preference 관련 노드가 세 개 있다. preference overlay 용 Delay:My, 값을 담는 $My, overlay content 용 Read:My. state 를 뒤집으면 세 노드가 모두 dirty 로 표시된다.

Preference 2

update 가 중간 노드들을 지나 MyChildView 의 body 에 도달한다. 여기서 중요한 관찰은, 이 body 가 preference 값을 update 하고, 그 값을 overlay content 가 쓴다는 것이다.

Preference 3


View tree 추가와 삽입

앞서 살펴본 앱들은 attribute graph 가 정적이었다. 노드 값은 바뀌어도 노드나 엣지 자체는 바뀌지 않았다. state 에 따라 view 를 추가·제거하면 얘기가 달라진다.

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
        }
    }
}

ViewList 1

subgraph 구조가 사뭇 다르다. 루트 하나에 자식 하나인 단순 구조가 아니라, value 가 false 인 초기 상태에서도 subgraph 가 두 개 더 존재한다. SwiftUI 는 view 개수가 동적으로 변하는 구간을 별도의 subgraph 로 분리하고, 각 subgraph 안에는 정적인 노드 집합만 둔다. view content 들도 각각 별도의 subgraph 가 된다.

ViewList 2

state 가 바뀌면 일반적인 마킹과 update 과정이 AnyViewList 노드에 도달할 때까지 진행된다. 이 노드는 단순히 출력값만 설정하는 게 아니라 특별한 동작을 한다. 새 subgraph 와 노드를 생성한다. 구체적으로는 예전 EmptyView 리스트를 제거하고 텍스트용 새 리스트를 추가한다.

ViewList 3

update 는 계속 이어져 DynamicContainer 가 view list 를 이용해 view content 용 노드를 추가한다 (여기서 _makeView 함수가 실행된다). 최종 subgraph 트리는 구조 변화를 반영한다.

ViewList 4

한 번의 update 순회가 연쇄적인 변화를 일으켜 여러 cycle 이 필요할 수도 있다. view 가 삽입되면 attribute graph 에 큰 변화가 생기고, update cycle 이 반복되어야 한다. 완료 후 SwiftUI 는 root display list 를 가져와 insertSubview:atIndex: 같은 호출로 UIView tree 를 갱신한다.


맺음말

attribute graph 는 SwiftUI 가 여러 view 표현을 효율적으로 관리하는 방식을 설명해 준다. 프레임워크는 단일 통합 구조를 유지하는 대신 layout, 렌더링, 구조적 view 를 분리하면서 모든 state 를 동기화한다. 이 구조 덕분에 명시적 의존성 추적 없이도 preference 와 environment 값을 효율적으로 전파할 수 있다. 이번 탐색은 AttributeGraph 가 어떻게 공들여 구성된 의존성과 lazy evaluation, 동적 subgraph 관리로 SwiftUI 의 반응성을 떠받치는지 보여 준다.