본문 바로가기
비전공자를 위한 Flutter/Flutter 심화과정

Flutter Tree 트리 구조 및 화면 렌더링 과정 알아보기

by 밍잔 2022. 5. 4.

플러터에는 UI 렌더링을 위한 3개의 트리가 있는데요. 왜 3개의 트리를 만들게 됐는지, 각각의 트리는 어떤 역할을 하는지 알아보겠습니다. 요약하면 [위젯 트리 > 엘리먼트 트리 > 렌더 트리] 의 순서로 UI를 렌더링 하네요. 이 포스팅은 Flutter docs의 원문을 참고하여 만들었으니, 너무 요약되어 이해가 안 되는 내용은 포스팅 아래 docs 링크를 눌러 확인해보시길 바랍니다! 

 

 

2016년 플러터 레이아웃 설명 동영상

 

<Flutter docs 참고>

 

Flutter architectural overview

A high-level overview of the architecture of Flutter, including the core principles and concepts that form its design.

docs.flutter.dev

 

Inside Flutter

Learn about Flutter's inner workings from one of the founding engineers.

docs.flutter.dev

 


2가지 레이어 살펴보기

 

플러터의 UI를 위한 2개 레이어를 먼저 살펴보겠습니다.

 

 

렌더링 레이어(Rendering Layer)는 레이아웃을 다루기 위한 추상 레이어입니다. 이 레이어에서는 렌더링 가능한 객체 트리를 만들 수 있어요. 이 트리가 객체를 다양하게 확장하고, 변경 사항이 생기면 자동으로 레이아웃을 업데이트하게 도와줍니다. 렌더 오브젝트 구조(RenderObject Hierachy)는 플러터 위젯 라이브러리에서 레이아웃과 페인팅 백엔드를 구현하기 위해 사용됩니다. 여러분이 렌더 오브젝트 구조와 소통할 시간은 오로지 레이아웃 이슈 뿐일 거예요.

 

 

위젯 레이어(Widget Layer)는 컴포지션 추상 레이어입니다. 컴포지션은 새로 만든 클래스를 다른 클래스 안에서 생성/사용한 걸 의미합니다. 렌더링 레이어에 안에 있는 렌더 오브젝트가 1:1 매칭된 클래스들이 위젯 레이어에 있죠. 여러분은 이 위젯 레이어에서 재사용할 수 있는 클래스 조합을 정의할 수 있습니다. 

 


위젯 빌드 과정 짚고 넘어가기

 

이 프레임워크는 각 위젯마다 1:1 관계의 렌더링 가능한 객체를 전부 매칭시킵니다. 그리고 렌더 오브젝트 트리를 위젯 트리와 똑같은 구조로 렌더링 가능한 객체들로 채워 넣죠. 위젯 build 함수는 부작용이 없어야 합니다. 아무때나 build를 요청해도 위젯은 그 전에 뭘 리턴했던 간에, 새로운 위젯 트리를 반환하기 때문이죠.

 

 

빌드 메소드가 불러지면 플러터 프레임워크가 렌더 오브젝트 트리에 매칭 시키는 작업을 합니다. 플러터는 상태가 변경되면 빌드 메소드를 실행하기 때문에 매 프레임(1초에 60회 화면 그리기)마다 UI 일부를 새로 만들 수 있습니다. 그렇기 때문에 빌드 메소드는 빠르게 리턴되어야 하고, 무거운 작업은 웬만하면 비동기 작업으로 해야합니다.

 


위젯 => 엘리먼트 빌드 과정 이해하기

 
이런 코드를 작성했다고 해봅시다.
Container(
  color: Colors.blue,
  child: Row(
    children: [
      Image.network('https://www.example.com/1.png'),
      const Text('A'),
    ],
  ),
);

 

플러터가 이 위젯을 렌더링하기 위해 build 메소드를 부르겠죠. 현재 state를 반영한 UI 서브트리를 리턴할 거구요. 이 프로세스를 하는 동안 빌드 메소드는 위젯 프로퍼티에 따라 필요한 새로운 위젯을 만들어 냅니다. 예를 들어 Contiainer 위젯에는 color 프로퍼티가 있는데요. color 프로퍼티가 null이 아닌 경우에만 ColoredBox 위젯을 생성하게 되어 있습니다.

 

if (color != null)
  current = ColoredBox(color: color!, child: current);

 

이처럼 이미지나 텍스트 위젯도 사실은 RawImage, RichText라는 하위 위젯이 구성되어 있죠. 예제 코드를 위젯 트리로 그려보면 아래와 같은 그림이 됩니다.

 

 

빌드를 하는 동안 플러터는 위젯 트리를 1:1 대응하도록 Element 트리로 바꿉니다. 하나의 엘리먼트가 하나의 위젯을 기반으로. 원래 있던 위치와 동일하게 구현하죠. 그리고 엘리먼트에는 2가지 기본 타입이 있습니다.

 

  • ComponentElement(컴포넌트 엘리먼트) : 다른 엘리먼트의 호스트
  • RenderObjectElement(렌더 오브젝트 엘리먼트) : 레이아웃 또는 페인팅하는 엘리먼트

 

 

RenderObjectElement는 위젯과 렌더 오브젝트의 사이에 있는 요소입니다. RenderObjectElement는 위젯 트리 안의 BuildContext를 참조합니다.(Theme.of(context)처럼 사용하며 build 메소드에서 매개변수로 제공됩니다.) 위젯 트리 안에서 context를 통해 위젯 내용 등의 변화를 감지하면 새로운 위젯 오브젝트 셋을 리턴하죠. 그렇다고 전부다 다시 빌드되는 건 아닙니다. 왜냐하면 부모/자식 노드간의 관계를 포함해서 ComponentElement는 변하지 않기 때문입니다. 

 

그래서 엘리먼트 트리는 프레임마다 지속되기 때문에 필요한 부분만 캐싱해놓고 그 외의 위젯 구조를 완전히 폐기하는 것처럼 중요한 퍼포먼스를 담당할 수 있죠. 변경된 위젯만 바꿔줌으로써 플러터는 필요한 요소 트리의 일부만 빌드합니다.

 

 


레이아웃과 렌더링 이해하기

하나의 위젯만 덩그러니 있는 어플리케이션은 아마 없을 겁니다. 그래서 모든 UI 프레임워크에게 중요한 능력이 스크린에 렌더링하기 전에 효과적으로 위젯 구조를 레이아웃으로 만들고, 각 엘리먼트의 사이즈와 포지션을 정의하는 능력이죠.

 

렌더 트리안에 있는 노드의 모든 기본 클래스는 RenderObject입니다. 렌더 오브젝트는 레이아웃과 페인팅을 위한 추상화 모델이죠. 

 

각 렌더 오브젝트는 부모 오브젝트를 다 알고 있지만, 자식 노드들에 대해서는 어떻게 참조할지, 사이즈가 어떤지 등등 잘 모릅니다. 이런 문제들을 해결하기 위해 렌더 오브젝트가 렌더링 중에 있을 법한 일들에 대해 충분한 추상화 케이스들을 제공합니다. 플러터는 빌드할 때 엘리먼트 트리에 있는 각 렌더 오브젝트를 상속하는 객체를 만들거나 업데이트 합니다.

 

 

 

Render Tree에서 RenderParagraph = 텍스트 렌더링. RenderImage = 이미지 렌더링. RenderTransform = 자식노드 사이즈 변형.

 

대부분의 플러터 위젯은 RenderBox 서브클래스를 상속하는 2차원 고정된 사이즈의 렌더 오브젝트에 의해 렌더링됩니다. RenderBox는 제약 모델의 기초를 제공하여, 렌더링할 각 위젯의 최소 및 최대 너비와 높이를 설정합니다.

 

레이아웃을 그리기 위해 가장 먼저 플러터는 부모 노드에서 자식 노드로 사이즈 제약을 전달합니다. 크기를 결정할 때 자식 노드는 부모가 정한 사이즈 제약을 지켜야 하죠. 자식 노드는 그 제약 조건 아래에 설정된 사이즈를 다시 부모 노드에 응답합니다.

 

 

위젯을 화면에 렌더링할 때 사이즈를 결정하는 순서

 

트리의 마지막 노드까지 이런 작업을 거치고 나면, 모든 오브젝트의 사이즈가 정의되어 paint 메소드를 실행할 준비가 됩니다.

 

box 제약 모델은 O(n) 복잡도로 레이아웃을 그리는 굉장히 강력한 모델입니다:

  • 부모는 자식 오브젝트의 사이즈를 자신과 동일한 값으로 설정합니다. 예를 들어 앱의 최상위 렌더링 개체는 자식을 화면 크기로 제한합니다. (자식 위젯들은 그 공간을 어떻게 사용할지 선택할 수 있습니다. 지시된 제약 조건 내에서 렌더링하려는 것을 Center 위젯을 통해 중앙에 배치할 수 있구요.)
  • 부모 노드가 자식 노드에게 값을 알려주기는 하지만, 자식 노드는 이를 초과할 수 있는 유연성을 가지고 있어요. 예를 들어 텍스트 위젯의 텍스트 내용이 길어지는 경우, 원래대로라면 가로로만 늘어나야 하지만, 한 줄이 늘어남으로써 세로값이 늘어나게 되는 거죠.

아래 예제와 같이 LayoutBuilder 위젯으로 제약 조건을 전달함으로써 자식 오브젝트를 어떻게 렌더링할지 정할 수도 있습니다 : 

 

Widget build(BuildContext context) {
  return LayoutBuilder(
    builder: (context, constraints) {
      if (constraints.maxWidth < 600) {
        return const OneColumnLayout();
      } else {
        return const TwoColumnLayout();
      }
    },
  );
}

 

렌더 오브젝트들의 최상위 계층은 모든 렌더 트리의 아웃풋을 나타내는 RenderView 입니다. 기기가 렌더링을 위해 새로운 프레임을 요구할 때, 루트에 있는 RenderView 객체의 일부인 렌더 트리의 compositeFrame() 메소드가 호출됩니다. 이 메소드는 화면을 업데이트하는 SceneBuilder를 생성하는 트리거 역할을 합니다. 화면이 완성되면, 렌더뷰 오브젝트는 구성된 화면을 dart:ui 라이브러리에 있는 Window.render() 메소드로 전달하고, GPU가 이를 렌더링 하죠. 

 


 

 

플러터의 위젯들은 다른 위젯들이 모여서 구성되고, 그 다른 위젯들 또한 베이직한 다른 위젯들로 구성되죠. 예를 들어 다른 위젯의 프로퍼티로 제공되는 패딩은 사실 Padding 위젯입니다. 결과적으로 UI로 빌드되는 건 많은 위젯들의 구성인거죠. 

 

위젯을 구축하는 작업은 기본 렌더 트리에서 노드를 생성하는 위젯인 RenderObjectWidgets에서 끝납니다. 렌더 트리는 레이아웃 중에 계산되고, 페인팅 및 히트 테스트(hit test) 중에 사용되는 UI 지오메트리를 저장하는 데이터 구조입니다. 우리 같은 개발자는 렌더 객체를 직접 다루지 않고, 위젯을 사용하여 렌더 트리를 조작하죠.

 


 

트리 수술 (엘리먼트 재사용)

 

엘리먼트를 재사용하는 건 성능에 매우 중요합니다. 왜냐하면 엘리먼트는 하나하나가 렌더 오브젝트와 statefulWidget을 위한 state를 가진 크리티컬한 데이터 조각이기 때문이죠. 프레임워크가 엘리먼트를 재사용 가능한 경우, UI의 로직 부분에 대한 state가 보존되고, 이전에 계산된 레이아웃 정보를 재사용할 수 있어서 전체 하위 트리를 다시 만드는 걸 피할 수 있습니다.

 

GlobalKey를 사용해서 위젯을 다시 빌드하지 않고 재사용하는 트리 변형이 가능하죠. 각 글로벌 키들은 유니크하게 스레드 해시테이블에 따로 등록됩니다. 글로벌 키를 사용하면 위젯 트리 안에서 어느 위치든 자유롭게 위젯을 이동시킬 수 있죠. 새로운 엘리먼트를 그 자리에 빌드하는 것보다, 프레임워크가 해시테이블을 체크해서 변경된 위젯 서브트리에게 새로운 위치에 있는 부모를 다시 지정해주는 게 좋은 거죠.

 

다시 부모가 지정된 렌더 오브젝트는 레이아웃 정보를 유지할 수 있어요. 왜냐하면 레이아웃 제약은 렌더 트리에서 부모가 자식에게 내려주기 때문이죠. 새로운 부모는 자식이 바뀌었으니 레이아웃을 더티 상태로 마킹합니다. 하지만, 새 부모가 자식에게 이전 부모로부터 받은 동일한 레이아웃 제약 조건을 전달하면 자식은 계산이 필요 없이 레이아웃을 바로 그릴 수 있습니다.

 


 

플러터의 트리가 어떻게 구성되는지 알아봤습니다. 다음에는 상수 요소로 어떻게 최적화 할 수 있는지 알아보겠습니다. 아래 링크를 눌러주세요.

 

https://mingzan.dev/266

 

 

댓글