[Flutter] 이 코드.. 화면에 어떻게 그려질까? 렌더링 원리 - 1. 트리

Broccolism·2021년 12월 5일
28
post-thumbnail

GetX에서 onInit과 onReady의 차이가 대체 뭘까? 1프레임 뒤에 실행된다는게 대체 무슨 의미지?
... 에 대한 답을 찾기 위해 플러터 내부 렌더링 원리까지 가버린 후기.

이번 포스팅에서는 플러터의 위젯 렌더링 파이프라인 3단계 중 2단계까지의 내용을 다룹니다.


발단: 모두가 아는 build 함수

플러터 stateful widget의 build 함수는 이럴 때 호출된다.

  • initState 를 호출한 다음
  • didUpdateWidget 을 호출한 다음
  • setState 호출한 다음
  • 현재 State 객체의 의존성이 달라졌을 때
  • deactivate 함수를 호출한 다음 State 를 트리의 다른 곳에 다시 삽입할 때

가끔 공식 문서나 스택오버플로우의 답변을 읽다보면 tree 라는 단어가 등장한다. 개발자가 아는 트리는 노드와 엣지로 이루어진 그 트리인데, 그동안 플러터에도 대충 트리가 있나보다~~ 하고 넘겼다. 그리고 이렇게 대충 넘긴게 업보가 되어 공식 문서를 샅샅히 뒤지게 되었다.

정확히 무슨 트리를, 왜 사용하나?

플러터는 초당 60프레임을 보여준다. 초당 최대 60번 화면이 바뀐다는 뜻이다. 120fps까지 지원하는 기기에서는 초당 120프레임까지 보장한다. 이런 성능을 내려면 당연히 특별한 렌더링 방법이 있을 것이다. 플러터는 렌더링 성능을 내기 위해 트리 3개를 사용한다.

전체 단계는 이렇다. 아래 그림은 유저의 행동부터 GPU에게 컨트롤이 넘어갈 때까지, high - low 순서대로 어떤 일이 일어나는지 순서대로 나타낸 것이다.

플러터는 build 단계에서 위젯 트리(widget tree)와 엘리먼트 트리(element tree)를 만들고, layout 단계에서 렌더 트리(render tree)를 만든 다음 paint 단계에서 가장 마지막에 만든 렌더 트리를 써먹는다. 앞서 만든 위젯 트리, 엘리먼트 트리는 모두 렌더 트리를 위한 추진력을 얻기 위함 만들기 위해서 생성하는 트리다. 그리고 엘리먼트 트리 역시 위젯 트리를 바탕으로 만들어진다.

트리가 많아서 헷갈릴텐데, 지금은 그냥 트리를 3개나 만드는구나~ 하고 넘어가도 된다.

그렇다면 왜 트리를 3개나 만드는걸까? 그냥 곧바로 코드를 어떻게 잘 변환해서 스크린의 픽셀에다가 적당한 색을 입히면 되는거 아닐까? 그냥 자료구조 개념 써먹으려고 이렇게 어렵게 만든건가? -> 당연히 아니다. 트리를 3개나 쓰는 이유는 렌더링 성능을 보장하기 위해서다. 한번 화면을 그린 다음에는 전체 화면을 여러번 재랜더링 하는게 아니라 꼭 필요한 부분만 다시 그리기 위해서다.

그러면 이제 어떻게 "꼭 필요한 부분만 다시" 그리는지 위 그림의 순서를 따라가면서 좀 더 구체적으로 알아보자. 트리에 대한 얘기도 더 자세히 할 것이다.

플러터의 렌더링 파이프라인은 build-layout-paint pipeline이라고 한다. 이번 포스팅에서는 그 중 앞쪽 2단계를 자세히 살펴볼 것이다.

1. Build Phase

어라 그림에는 Build가 3번인데요.

1번은 유저의 입력, 즉 버튼을 누르거나 텍스트를 입력하는 등 새로운 이벤트가 일어났을 때를 의미한다. 이런 이벤트가 일어난 다음에는 두번째로 애니메이션을 실행하고, 그 다음부터 build 단계가 시작된다. 이번 시리즈에서 관심있는 내용은 내가 적은 코드가 어떤 과정을 통해 '화면에 보여지는지' 이기 때문에 build 단계부터 시작한다.

그동안 우리는 build 함수를 이렇게 적었다.


Widget build(BuildContext context) {
  return Container(
    color: Colors.blue,
    child: Row(
      children: [
        Image.network('https://www.example.com/1.png'),
        const Text('A'),
      ],
    ),
  );
}

이 함수는 BuildContext context를 받고 Widget 을 리턴한다. 이렇게 리턴한 위젯은 도대체 어디로 가는걸까? 여기서 받는 context는 도대체 뭘까??

build 함수의 실행 결과는 widget tree

첫번째 질문 -build 함수가 리턴한 위젯은 도대체 어디로 가는걸까?- 에 대한 대답은 바로 '위젯 트리'이다. 우리가 지금까지 적었던 플러터 코드는 이런 구조를 갖고 있다.

프로그램의 첫 시작점인 main 함수에서 runApp을 실행시킨다. 이 때 runApp의 파라미터로 Widget 타입 변수인 MyApp 클래스의 인스턴스다. 그리고 이 인스턴스의 build 함수에서는 MaterialApp을 리턴하고, 이 MaterialApp에게는 HomeScreen의 인스턴스가 주어진다. 그리고 HomeScreen에서도 같은 일이 일어난다. build 함수에서 위젯을 리턴하고, 또 그 위젯 안에 다른 위젯이 겹겹이 들어있다.

build 함수에서 리턴한 위젯은 위젯 트리로 들어간다고 했다. 이 위젯 트리의 루트 노드는 MyApp이다. 가장 처음 runApp이 받은 위젯이 MyApp이기 때문이다.

플러터 전체에서 사용하는 위젯 트리는 1개뿐이고, 각 build 메소드의 리턴 결과는 build를 실행할 당시에 있던 노드의 subtree로 들어간다. MyApp.build 메소드를 실행하고 나면 MaterialApp 노드가 child로 생기고, MaterialApp.build 메소드를 실행하고 나면 HomeScreen 노드가 child로 생긴다. 이 과정을 반복하면서 만들어진 위젯 트리의 전체 구조는 다음과 같다.

위젯을 만들 때 넘겨주던 파라미터의 이름이 child였듯이, 상위 위젯에게 넘겨준 하위 위젯은 상위 위젯 노드의 child 노드가 되는 것이다. Row처럼 하위 위젯을 여러개 가질 수 있는 위젯은 child 노드를 여러개 갖는 노드가 된다.

플러터 2.0 업데이트에서 추가된 widget inspector에서 보여주는게 바로 이 위젯트리다.

내가 코드로 적은거 말고 다른 위젯이 위젯 트리에..?

다시 이 코드를 보자.


Widget build(BuildContext context) {
  return Container(
    color: Colors.blue,
    child: Row(
      children: [
        Image.network('https://www.example.com/1.png'),
        const Text('A'),
      ],
    ),
  );
}

코드에 보이는 위젯은 ContainerRow 2개뿐이다. 그런데 플러터가 이 build 메소드를 실행한 다음에 만드는 subtree는 이렇게 생겼다.

트리를 자세히 보면 코드에 적지 않은 ColoredBox 노드가 ContainerRow 사이에 생긴걸 알 수 있다. 그 이유는 컨테이너에 color: Colors.blue 옵션을 줬기 때문이다. 코드 편집기를 켜고 컨테이너 위젯 안으로 들어가보면 build 메소드에 이런 코드가 있는걸 발견할 수 있다.

Widget build(BuildContext context) {
  Widget? current = child; // 예제 코드 기준: Row
  ...
  if (color != null)
    current = ColoredBox(color: color!, child: current);
    // 이제 current는 ColoredBox(color: color!, child: Row)가 되었다.
  ...
  return current!;
}

컨테이너의 build 메소드의 역할은 전체 위젯 트리에서 Container를 루트 노드로하는 subtree를 리턴하는 것이다. 만약 코드상에서 컨테이너의 color 파라미터를 포함해서 다른 모든 파라미터가 null이었다면(child 제외) 곧바로 child 파라미터로 넘겨준 위젯을 리턴했을 것이다. 즉, 위의 예제 코드로 치면 Row가 child 노드로 들어갔을 것이다. 하지만 예제 코드에서는 컨테이너의 color 파라미터가 null이 아니다. 그래서 컨테이너의 build 메소드 코드에 적힌대로 Container 노드의 child가 ColoredBox가 되었고, 이 ColoredBox 노드의 child 노드가 Row가 되었다.

이처럼 같은 위젯 하나를 그리더라도 플러터 내부적으로 사용하는 추가적인 위젯이 더 필요할 수 있다. 같은 이유로 위의 예시에서 RawImage, RichText 노드가 추가되었다. 실제로 컨테이너 위젯의 build 메소드 안에 들어가보면 컨테이너 하나를 그리기 위해 추가하는 위젯 종류가 다양함을 알 수 있다. 아래는 그 예시이다.

if (constraints != null)
  current = ConstrainedBox(constraints: constraints!, child: current);
  // 컨테이너에 constraints 옵션을 주면 플러터는
  // ConstrainedBox 위젯을 트리에 삽입한다.

if (margin != null)
  current = Padding(padding: margin!, child: current);
  // 컨테이너에 주던 margin 옵션은 결국 Padding으로 바뀌어서
  // 위젯 트리에 들어가게 된다.
  // 만약 컨테이너에 padding, margin 옵션을 모두 주었다면
  // 결국에는 Padding 2개로 만든 셈이 된다.

위젯 트리가 시키는대로 하는 엘리먼트 트리

위젯은 설명서다. "나는 color, padding, alignment가 적용된 컨테이너를 그리고 싶어" 라는 내용을 담은 주문서라고 보면 된다. 플러터는 위젯이라는 주문서를 보고 실제로 그려질 instance를 만드는데, 그게 바로 엘리먼트(element)이다. Element 클래스 내부에 적혀있는 주석에서도 이 사실을 알 수 있다.


abstract class Element extends DiagnosticableTree implements BuildContext {
  ...
  /// The configuration for this element.
  
  Widget get widget => _widget!;
  Widget? _widget;
  ...

우리가 아는 '플러터의 lifecycle'은 위젯이 아닌 엘리먼트가 관리한다. mount, update, performRebuild 등의 메소드는 모두 Element 클래스의 메소드다.

엘리먼트 트리의 노드와 위젯 트리의 노드는 모두 일대일 대응이 된다. 아래 그림을 보자.

엘리먼트 트리의 루트 노드인 ComponentElement는 위젯 트리의 루트 노드인 Container와 대응되고, 그 아래의 RenderObjectElementColoredBox에 대응되는 식이다.

엘리먼트 트리를 잘 살펴보면 2가지 종류만 있다는 것을 알 수 있다. ComponentElement는 다른 엘리먼트의 host 역할을 한다. 플러터가 실제로 화면을 그릴 때 이 종류의 엘리먼트는 참고하지 않는다. 플러터가 보는 부분은 RenderObjectElement이다. 이 엘리먼트가 렌더링의 다음 단계인 layout, paint 단계에 직접 참여하는 엘리먼트다.

context의 정체

모든 위젯은 widget.buildContext를 통해 자신과 연결된 엘리먼트에 접근할 수 있다. buildContext 안에는 자신이 위젯 트리에서 어떤 노드에 위치하는지 알려주는 정보가 있기 때문이다. 이게 바로 우리가 build 함수에서 주구장창 넘겨주던 BuildContext context 이다. 다이얼로그를 띄울 때에도 반드시 필요하던 그 context가 맞다.

showDialog(
  context: context,
  builder: (BuildContext context) {
    return AlertDialog(
  ...
);

생각해보면 다이얼로그를 띄운다는건 현재 렌더링 된 화면 위에 다이얼로그의 배경과 다이얼로그 UI를 추가로 그린다는 뜻이다. 그렇다면 '현재 렌더링 된 화면'이 트리에서 어디에 위치하는지 알아내야 한다. '어디에 위치하는지'에 대한 정보를 context가 갖고 있으니 당연히 context가 필요했던 것이다!

2. Layout Phase

이제 거의 다 왔다. 다시 렌더링 얘기로 돌아가보자.

엘리먼트 트리에서 사족을 없앤 렌더 트리

지금껏 했던 얘기를 정리해보자. 개발자는 위젯 코드를 작성한다. 플러터는 위젯 주문서를 보고 엘리먼트를 만든다. 이 엘리먼트에는 2가지 종류가 있다. 그 중 host 역할을 하는 엘리먼트말고 다른 엘리먼트가 직접적으로 렌더링에 관여한다.

플러터는 마지막으로 트리를 하나 더 만든다. 바로 렌더 트리(render tree)다. 이 트리는 엘리먼트 트리를 보고 만든다.

이 트리에는 렌더링에 반드시 필요한 내용만 담는다. 위젯 트리, 엘리먼트 트리와 비교해보면 노드 개수가 확실히 줄어들었다. 렌더 트리의 노드가 될 수 있는건 엘리먼트 트리의 노드 중 RenderObjectElement 노드를 바탕으로 만들어진 노드 뿐이기 때문이다.

위젯 트리에는 수많은 종류의 노드가 있었고, 엘리먼트 트리에는 2가지 노드만 존재할 수 있었다. 렌더 트리에서는 다시 노드의 종류가 다양해진다. 대신 모든 노드는 abstract 클래스인 RenderObject 클래스를 implement한 클래스로 이루어진다. RenderObject 클래스에는 parentData, constarints 등의 멤버 변수가 있고 visitChildren, layout 등의 메소드가 있다. 이 멤버 변수와 메소드를 언급한 이유는 바로 다음 단계인 layout 단계와 관련이 있기 때문이다.

layout

레이아웃 단계는 렌더 트리를 보면서 최종적으로 위젯의 geometry, 즉 위젯이 어느 위치에 어떤 사이즈로 그려질지, 회전은 어느정도로 되어야 하는지 등을 결정하는 단계다. Expended, Flexible 등의 위젯을 생각해보면 왜 이 작업이 끝자락에 있는지 감이 올 것이다. 가로, 세로 길이를 굳이 정하지 않은 위젯에 대한 크기 정보를 결정하기 위해 parent나 child를 봐야한다는 점도 그 이유 중 하나다.

레이아웃 단계는 아주 심플하다. 루트 노드부터 DFS로 leaf 노드까지 내려간다. 이 때 위에서 아래로 이동하는 정보는 제약 조건(constraint)에 대한 정보밖에 없다. 예를 들어 size, position 등이 있다.

parent 노드로부터 제약 조건을 받은 child 노드는 해당 정보를 바탕으로 자신의 geometry를 결정한다. 이렇게 결정한 자신의 geometry를 다시 위쪽으로 올려보낸다. 즉, 아래에서 위로 이동하는 정보는 geometry에 대한 정보뿐이다. 이 작업을 모든 노드에 대해 완료하면 드디어 레이아웃 작업이 끝났고, paint 단계에 들어갈 준비가 되었다고 말할 수 있다.

플러터의 레이아웃 작업은 1 프레임당 1번 일어난다. 플러터는 초당 60프레임을 기본으로 지원한다고 했으니, 필요하다면 초당 60번의 레이아웃 작업이 일어난다고 볼 수 있다.

정리

플러터가 위젯을 렌더링하는 순서 (1/2)

  1. 위젯 트리 만들기: build 함수를 마구 호출한다.
  2. 엘리먼트 트리 만들기: 위젯 트리를 보고 만든다.
  3. 렌더 트리 만들기: 엘리먼트 트리를 보고 만든다.
  4. 레이아웃 작업: 1 프레임마다 렌더 트리를 DFS로 1번 훑는다.
  5. 페인트: 나머지 작업은 다음 포스팅에...

그리고...

엘리먼트 트리는 중간다리 역할

엘리먼트는 위젯과 렌더 오브젝트를 모두 참조한다. 위젯이 다른 위젯으로 변했다면 그 변화를 엘리먼트가 감지해서 렌더 오브젝트를 바꿀지말지 결정한다.

만약 위젯이 stateful widget이라면 그 위젯의 state 변화 역시 엘리먼트가 감지한다. StatefulElement가 그 역할을 한다.

/// An [Element] that uses a [StatefulWidget] as its configuration.
class StatefulElement extends ComponentElement {

위젯은 immutable, 나머지는 mutable

플러터의 모든 위젯은 immutable하다. 즉, 런타임 도중에 내용을 수정할 수 없다. 만약 이미 렌더링된 위젯을 다른 모습으로 바꾸고 싶다면 플러터는 위젯 트리에서 그 위젯에 해당하는 노드를 삭제하고, 새로운 위젯 노드를 끼워넣는다.

이렇게 위젯이 교체되면 플러터는 2가지 행동을 할 수 있다.
1. 오래된 주문서(이전 위젯)로 만들었던 렌더 오브젝트의 내용을 새 주문서(새 위젯) 내용에 맞게 고치기
2. 오래된 주문서로 만들었던 렌더 오브젝트를 버리고, 같은 위치에 새 주문서로 만든 새 렌더 오브젝트 끼워넣기

앞서 말했듯이 렌더 오브젝트가 크기나 위치 등 렌더링에 직접적으로 필요한 정보를 담고 있다. 이 정보는 꽤 무거운 정보이기 때문에 주문서가 완전히 다른 위젯으로 바뀌지 않는 한 새로 만들지 않는다. 대신 안에 있는 내용을 바꾼다.

바꾸는 작업은 위젯과 렌더 오브젝트 중간에 있는 엘리먼트가 수행한다. 엘리먼트가 위젯이 완전히 다른 위젯으로 변하지 않고 일부 내용만 바뀌었다는 사실을 감지하면, updateRenderObject 메소드를 호출해서 렌더 오브젝트의 내용을 바꾼다. 렌더 오브젝트는 mutable하기 때문에 런타임 도중에 수정할 수 있다.

여기서 잠깐, 플러터는 기존 위젯이 "완전히 다른 위젯으로 바뀌지 않았"다는 사실을 어떻게 알아챌까? Widget.canUpdate 함수가 이를 알려준다.

static bool canUpdate(Widget oldWidget, Widget newWidget) {
  return oldWidget.runtimeType == newWidget.runtimeType
    && oldWidget.key == newWidget.key;
  }

즉, 기존 위젯의 runtimeType이 새 위젯과 다르거나 기존 위젯의 key가 새 위젯과 다르면 플러터는 해당 위젯이 완전히 다른 위젯으로 바뀌었다고 판단한다. 만약 이렇게 완전히 다른 위젯으로 바뀌었다면, 기존 렌더 오브젝트 자체를 삭제하고 다시 새로운 렌더 오브젝트를 만든다.


All images except for those with my velog link & contents: https://docs.flutter.dev/resources/architectural-overview

profile
코드도 적고 그림도 그리고 글도 씁니다. 넓고 얕은 경험을 쌓고 있습니다.

8개의 댓글

comment-user-thumbnail
2022년 1월 28일

미쳤다... 이 글 너무 좋네요 정리 감사합니다!

1개의 답글
comment-user-thumbnail
2022년 7월 6일

와 진짜 이해가 안되던 부분이었는데 정말 자세하게 설명해주셔서 단번에 이해를 했네요.....
근래에 본 글 중 가장 원리를 쉽게 설명한 글입니다. 정말 감사드립니다.
p.s. 이 댓글 남기고 싶어서 velog 가입까지 했습니다.

1개의 답글
comment-user-thumbnail
2023년 2월 21일

너무 좋은 글입니다. 감사합니다!

1개의 답글
comment-user-thumbnail
2024년 1월 27일

좋은 글 감사합니다. 공부하는데 도움이 많이 되었습니다!

답글 달기
comment-user-thumbnail
2024년 1월 30일

좋은 글 감사합니다. 너무 편하게 읽으며 공부했어요:)

답글 달기