LODY/정리

애플 플랫폼 백과사전 / SwiftUI 의 diffing algorithm

SwiftUI 의 diffing algorithm

원문: 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 이 필요하다.

Update loop

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 가 저장된다.

View graph

다음 예제를 보자.

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 의 구조는 제한된 방식으로만 바뀐다.

  1. _ConditionalView 가 true/false content 사이를 전환
  2. Optional<View> 가 content 를 표시/숨김
  3. ForEach 가 children 을 갱신 (타입은 그대로)
  4. AnyView 가 내부 content 의 타입을 바꿈

네 경우 모두, view subgraph 전체가 view graph 노드 아래에서 붙거나 떨어질 뿐이다.

modifier 를 추가하거나 제거할 수는 없고, view 를 다른 부모 아래로 옮길 수도 없다.


Layout tree

view graph 의 모든 view 가 layout 에 참여하지는 않는다. structural view 는 displayable 이 없어서 layout tree 에 나타나지 않는다. modifier 는 아래에 있는 unary view 의 모든 displayable 의 부모가 된다.

Layout tree

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 1

Test 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 에서 참고할 수 있다.