원문: SwiftUI's diffing algorithm — rensbr.eu 저자: Rens Breur 발행: 2022년 8월 18일
SwiftUI 를 다른 프레임워크들과 구분 짓는 특징 중 하나는 정적 타입 시스템 이다. view 들이 타입으로 구분되기 때문에 view 의 diffing 이 빠르고 모호하지 않다. 이 글은 SwiftUI 의 diffing 메커니즘이 어떻게 동작하는지, 타이핑이 그 효율성을 어떻게 만들어 주는지 살펴본다.
필자는 터미널에서 돌아가는 SwiftUI 클론인 SwiftTUI 를 만들었는데, 이 과정에서 SwiftUI 의 diffing algorithm 을 깊이 이해할 필요가 있었다.
Diffing 과 declarative UI
declarative UI 프레임워크는 상태 변화를 따라가려고 diffing 이 필요하다.
view 계층 전체를 갈아치우지 않으려면 어떤 자식 view 가 제거됐고 어떤 자식 view 가 새로 추가됐는지 판단해야 한다. 어떤 view 에 대해 state 를 버릴지, 새로 만들지 알아야 하기 때문이다.
프레임워크는 container 를 제대로 갱신하려고 추가와 제거를 식별해야 한다. stack 은 내용이 바뀌면 다시 레이아웃을 잡고, list 는 보이는 영역의 변화에만 반응해야 한다.
view 의 diffing
핵심 난제는 효율성과 모호함이다. 다음 상황을 보자.
이전:
→ List
→ Label("One")
→ Label("Two")
이후:
→ List
→ Label("Two")
→ Label("Three")
여러 해석이 가능하다. 첫 번째 라벨이 지워지고 세 번째가 삽입됐을 수도 있고, 두 라벨의 텍스트가 모두 바뀌었을 수도 있다. React 는 이 선택을 개발자에게 맡기지만, SwiftUI 는 다른 방식으로 해결한다.
대부분의 변경에 대해서는 SwiftUI 는 이런 알고리즘이 필요 없다. 모호함을 피하려고 view 에 identifier 를 붙일 필요도 없다.
View 하나인가 여럿인가
View 프로토콜의 설명은 이렇다. "앱 UI 의 일부를 표현하고, view 를 구성하는 데 쓸 modifier 를 제공하는 타입."
하지만 이 정의는 오해를 부른다. ForEach 같은 view 는 여러 view 를 묶고, EmptyView 는 UI 요소가 전혀 없다. view builder 는 조건문으로 동적 컨텐츠도 지원한다.
Composed view 와 primitive view
View 프로토콜은 body 하나만 요구한다.
protocol View {
associatedtype Body: View
var body: Body { get }
}이 재귀 구조는 어느 지점에서 primitive view — 다른 view 들의 조합으로 정의되지 않는 view — 로 종료돼야 한다. composed view 는 다른 view 를 조합하고 state 도 가질 수 있는 반면, primitive view 는 빌딩 블록을 제공한다.
Diffing 은 보통 composed view 에서 시작한다. state 가 바뀌어 body 를 다시 평가해야 할 때 말이다.
Primitive view 의 분류
SwiftUI 의 primitive view 는 네 가지로 나뉜다.
- Unary view: 화면에 표시되는 단일 요소. shape, color, control, label 등.
- Structural view: 0개 이상의 view 를 하나의 view 로 묶는다.
ForEach,EmptyView,TupleView,_ConditionalView등. - Container view: displayable 들의 보임·레이아웃을 결정해 관리한다.
HStack,VStack,List,LazyVStack등. - Modifier: 아래에 있는 모든 displayable 각각에 효과를 적용한다.
.border,.padding,.frame등의 modifier 로 생성된다.
Runtime view graph
SwiftUI 는 계층을 따라가는 runtime view graph 를 유지한다. 이 구조에는 View 인스턴스 참조와 관련 state 가 저장된다.
다음 예제를 보자.
struct MyView: View {
@State var showSecret = false
var body: some View {
VStack {
Group {
Button("Show secret") { showSecret.toggle() }
if showSecret {
Text("Secret")
} else {
Color.red
}
}
.padding()
}
}
}view graph 는 분류별로 다른 색을 입힌 노드들을 가진다. composed 노드 (body 참조 포함), primitive 노드, container 노드, modifier 노드로 구분된다.
view graph 의 동역학
view graph 의 구조는 제한된 방식으로만 바뀐다.
_ConditionalView가 true/false content 사이를 전환Optional<View>가 content 를 표시/숨김ForEach가 children 을 갱신 (타입은 그대로)AnyView가 내부 content 의 타입을 바꿈
네 경우 모두, view subgraph 전체가 view graph 노드 아래에서 붙거나 떨어질 뿐이다.
modifier 를 추가하거나 제거할 수는 없고, view 를 다른 부모 아래로 옮길 수도 없다.
Layout tree
view graph 의 모든 view 가 layout 에 참여하지는 않는다. structural view 는 displayable 이 없어서 layout tree 에 나타나지 않는다. modifier 는 아래에 있는 unary view 의 모든 displayable 의 부모가 된다.
container view 가 UIView 나 CALayer 를 만들 수도 있지만, HStack·VStack 처럼 자기 view 없이 layout 에만 참여하는 경우도 있다.
Diffing algorithm
SwiftUI 는 정적 타입을 이용해 효율적인 diffing 을 한다. View 프로토콜을 아래처럼 확장한다고 가정해 보자.
protocol View {
associatedtype Body: View
var body: Body { get }
var displayables: [Displayable] { get }
func update(using view: Self, offset: Int)
}displayables computed property 는 view 가 담고 있는 모든 displayable 을 배열로 돌려준다. update 함수는 같은 타입의 두 view 를 비교해 변경점을 찾는다.
composed view 에는 기본 구현이 body 에 위임하도록 쓸 수 있다.
extension View {
var displayables: [Displayable] { body.displayables }
func update(using view: Self, offset: Int) {
body.update(using: view.body, offset: offset)
}
}body 가 Never 인 primitive view 는 별도 처리가 필요하다.
extension Never: View {
var body: some View {
fatalError()
}
}구현 예시
ViewBuilder
@resultBuilder
struct ViewBuilder {
static func buildBlock<Content: View>(_ content: Content) -> Content {
content
}
}Text (unary view)
struct Text: View {
let text: String
init(_ text: String) {
self.text = text
}
var body: Never { fatalError() }
}
extension Text {
var displayables: [Displayable] {
[TextDisplayable(text: text)]
}
}
struct TextDisplayable: Displayable {
let text: String
}
extension Text {
func update(using view: Text, offset: Int) {
if self.text != view.text {
print("Changed displayable at \(offset)")
}
}
}TupleView (structural view)
struct TupleView<C0: View, C1: View>: View {
let content: (C0, C1)
var body: Never { fatalError() }
}
extension ViewBuilder {
static func buildBlock<C0: View, C1: View>(_ c0: C0, _ c1: C1) -> TupleView<C0, C1> {
TupleView(content: (c0, c1))
}
}
extension TupleView {
var displayables: [Displayable] {
content.0.displayables + content.1.displayables
}
}
extension TupleView {
func update(using view: TupleView<C0, C1>, offset: Int) {
content.0.update(using: view.content.0, offset: offset)
content.1.update(using: view.content.1, offset: offset + view.content.0.displayables.count)
}
}_ConditionalView (structural view)
struct _ConditionalView<TrueContent: View, FalseContent: View>: View {
enum ConditionalContent {
case first(TrueContent)
case second(FalseContent)
}
let content: ConditionalContent
var body: Never { fatalError() }
}
extension ViewBuilder {
static func buildEither<TrueContent: View, FalseContent: View>(first: TrueContent) -> _ConditionalView<TrueContent, FalseContent> {
_ConditionalView(content: .first(first))
}
static func buildEither<TrueContent: View, FalseContent: View>(second: FalseContent) -> _ConditionalView<TrueContent, FalseContent> {
_ConditionalView(content: .second(second))
}
}
extension _ConditionalView {
var displayables: [Displayable] {
switch content {
case .first(let content):
return content.displayables
case .second(let content):
return content.displayables
}
}
}
extension _ConditionalView {
func update(using view: _ConditionalView<TrueContent, FalseContent>, offset: Int) {
switch (content, view.content) {
case (.first(let oldValue), .first(let newValue)):
oldValue.update(using: newValue, offset: offset)
case (.second(let oldValue), .second(let newValue)):
oldValue.update(using: newValue, offset: offset)
case (.first(let oldValue), .second(let newValue)):
for i in 0 ..< oldValue.displayables.count {
print("Removed displayable at \(offset + i)")
}
for i in 0 ..< newValue.displayables.count {
print("Inserted displayable at \(offset + i)")
}
case (.second(let oldValue), .first(let newValue)):
for i in 0 ..< oldValue.displayables.count {
print("Removed displayable at \(offset + i)")
}
for i in 0 ..< newValue.displayables.count {
print("Inserted displayable at \(offset + i)")
}
}
}
}실제 예제
Test Case 1: 조건부 교체
struct TestList: View {
var value: Bool
@ViewBuilder
var body: some View {
Text("One")
if (value) {
Text("Two")
} else {
Text("Three")
}
}
}
let view1 = TestList(value: true)
print(view1.body.displayables)
// Output: [TextDisplayable(text: "One"), TextDisplayable(text: "Two")]
let view2 = TestList(value: false)
view1.body.update(using: view2.body, offset: 0)
// Output: Removed displayable at 1
// Inserted displayable at 1Test Case 2: Text 내용 변경
struct OtherTestList: View {
var value: Bool
@ViewBuilder
var body: some View {
Text("One")
Text(value ? "Two" : "Three")
}
}
let view1 = OtherTestList(value: true)
print(view1.body.displayables)
// Output: [TextDisplayable(text: "One"), TextDisplayable(text: "Two")]
let view2 = OtherTestList(value: false)
view1.body.update(using: view2.body, offset: 0)
// Output: Changed displayable at 1알고리즘은 view 교체와 content 변경을 정확히 구분한다. 글 초반에 나온 모호함 문제가 풀린다.
변경을 실제로 적용하기
update 함수는 변경 감지까지는 보여 주지만 view graph 노드 참조나 container 인식이 없다. 완전한 구현은 다음을 추가로 해 줘야 한다.
- view graph 노드 참조를
update에 전달 - view graph 노드가 container 참조를 유지
- view 가 삽입·제거되면 노드를 만들거나 지움
- displayable offset 변화를 container 에 알림
- unary view 노드가 displayable 을 보관해 갱신 시 활용
- modifier 가 바뀌면 displayable 을 갱신
ForEach 와 identifier
다른 structural view 와 달리 ForEach 는 타입만으로 모호함을 피할 수 없다. ForEach 가 받는 데이터의 타입만으로는 어떻게 변했는지 알 수 없기 때문이다.
따라서 다른 structural view 처럼 모호함을 없앨 수가 없다.
그래서 identifiable 한 데이터와 shortest edit script 알고리즘이 필요하다. Swift 표준 라이브러리에 이런 diffing 알고리즘이 들어 있다.
lazy 한 평가와 view 크기
List 같은 lazy container 는 흥미로운 문제를 만든다. lazy 하더라도 view 개수와 삽입·제거 offset 은 알아야 한다.
많은 composed view 의 크기는 정적으로 결정된다. TupleView<(Text, Text, Button)> 의 크기는 항상 3 이다. 이런 body 를 가진 view 도 크기가 정적이다.
하지만 어떤 view 는 크기가 동적이다. _ConditionalView<EmptyView, Text> 는 0 이나 1 이다. 동적 크기 body 는 크기를 알려면 평가해 봐야 한다.
View 프로토콜에 static size 변수를 넣어 두면, 정적 크기 view 는 body 평가를 미룰 수 있다.
struct ContentView: some View {
var body: some View {
List {
ForEach(0 ..< 1000, id: \.self) { i in
ListItems(i)
}
}
}
}ListItems 의 body 타입이 정적이라면 화면에 보이는 view 만 평가되면 된다. 조건문이 들어 있다면 모든 body 가 즉시 평가돼야 한다.
결론
정적 view 타입이 SwiftUI 의 효율적이고 모호하지 않은 diffing 을 가능하게 한다. 타입 정보 덕분에 대부분의 경우 identifier 나 복잡한 알고리즘 없이도 view 변화를 판별할 수 있다.
프레임워크는 Swift 의 타입 시스템을 활용해 우아함을 얻는다. view 는 부모 사이를 이동할 수 없고, modifier 를 추가하거나 제거할 수도 없다. 이런 제약은 모두 이 설계에서 나온다.
SwiftUI 는 closed-source 이고 내부가 문서화되지 않아서 마법처럼 느껴진다. 하지만 구체적인 view 예제를 하나씩 뜯어 보면 원리의 상당 부분을 알아낼 수 있다.
더 깊이 파고 싶다면 필자의 SwiftTUI 소스를 GitHub 에서 참고할 수 있다.