원문: 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 는 복잡하다. 기본 템플릿조차 수많은 노드들이 서로 얽혀 있다. 초점을 좁힌 다이어그램으로 봐야 구조가 보인다.
그래프 안에는 ContentView 노드가 있고, 그 body 노드도 있다. attribute graph 는 tree 가 아니고 cycle 도 없다. 노드는 값과 플래그를 가지는데, dirty 여부와 update 상태 같은 플래그가 포함된다. body 노드를 update 해야 할 때 view 의 body 함수가 호출될 수 있지만, 노드 타입마다 update 방식이 다르다.
어떤 노드는 나가는 엣지만 가진다 (예: @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 노드가 있다.
state 가 바뀌면 이 Void 노드가 changed 로 표시되고, 변경이 재귀적으로 전파되면서 모든 자식 노드가 dirty 로 플래그된다.
많은 노드는 lazy 하게 평가된다. 필요할 때만 새 값이 계산된다. 하지만 어떤 노드는 즉시 update 된다. AnimatableFrameAttribute 같은 것들이 그렇다. AttributeGraph 는 graph 를 역방향으로 재귀 순회한다. 노드에 dirty parent 가 있는지 확인하고 (있으면 먼저 update 한 뒤) 자기 값을 계산한다. 값이 바뀌면 자신을 changed 로 표시하고 dirty 플래그를 전파한다.
AnimatableFrameAttribute 가 끝나면 그다음으로 즉시 평가되는 노드는 AppearanceEffect 다. 같은 과정을 거친다. 다음 display cycle 에서 iOS 가 layer 를 렌더링하려 할 때가 되어야 RootDisplayList 가 요청되고 update 된다. 그 결과 실제 CALayer 의 텍스트가 바뀐다.
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 }
}
}state update 는 뒤에 이어지는 모든 노드를 dirty 로 표시한다. body 와 layout 관련 노드들도 포함된다. 각 layout modifier 마다 계산을 맡는 LayoutComputer 노드와 geometry 를 담는 ChildGeometry 노드가 있다. view 전체의 geometry 는 RootGeometry 노드가 담는다.
update 과정에서 SwiftUI 는 view graph 를 역방향으로 따라가며 body 를 다시 평가한다. 계산이 끝나면 노드의 출력을 update 한다. AttributeGraph 는 frame modifier 의 layout computer 가 바뀌었고 padding 은 그대로라는 사실을 인식한다.
이어서 frame 의 layout computer 가 root geometry 와 frame child geometry 를 둘 다 update 한다. 그런 다음 padding layout computer 와 결합해 padding child geometry 를 update 한다. SwiftUI 의 문서화된 layout 동작 그대로다.
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)")
}
}state 가 바뀌면 앞서와 같은 graph update 가 일어난다. 프레임워크가 MyView 의 body 를 다시 평가하는 과정에서 environment 값은 바뀌었지만 MyChildView 의 body 입력은 그대로라는 걸 인식하고, 노드와 엣지를 그에 맞게 마킹한다.
MyView 는 오직 environment 값을 통해서만 MyChildView 를 update 한다.
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 관련 노드가 세 개 있다. preference overlay 용 Delay:My, 값을 담는 $My, overlay content 용 Read:My. state 를 뒤집으면 세 노드가 모두 dirty 로 표시된다.
update 가 중간 노드들을 지나 MyChildView 의 body 에 도달한다. 여기서 중요한 관찰은, 이 body 가 preference 값을 update 하고, 그 값을 overlay content 가 쓴다는 것이다.
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
}
}
}subgraph 구조가 사뭇 다르다. 루트 하나에 자식 하나인 단순 구조가 아니라, value 가 false 인 초기 상태에서도 subgraph 가 두 개 더 존재한다. SwiftUI 는 view 개수가 동적으로 변하는 구간을 별도의 subgraph 로 분리하고, 각 subgraph 안에는 정적인 노드 집합만 둔다. view content 들도 각각 별도의 subgraph 가 된다.
state 가 바뀌면 일반적인 마킹과 update 과정이 AnyViewList 노드에 도달할 때까지 진행된다. 이 노드는 단순히 출력값만 설정하는 게 아니라 특별한 동작을 한다. 새 subgraph 와 노드를 생성한다. 구체적으로는 예전 EmptyView 리스트를 제거하고 텍스트용 새 리스트를 추가한다.
update 는 계속 이어져 DynamicContainer 가 view list 를 이용해 view content 용 노드를 추가한다 (여기서 _makeView 함수가 실행된다). 최종 subgraph 트리는 구조 변화를 반영한다.
한 번의 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 의 반응성을 떠받치는지 보여 준다.