Flutter

Flutter 렌더링 방식 완벽 해부: Widget부터 픽셀까지, 핵심 원리 파헤치기

드리프트2 2024. 11. 29. 22:24

Flutter 렌더링 방식 완벽 해부: Widget부터 픽셀까지, 핵심 원리 파헤치기

저의 Flutter 개발 경력도 벌써 몇 달이나 되었는데요.

 

간혹 감으로 사용하는 부분(BuildContext나 WidgetsBinding 등)이 있거나, 렌더링 시점에 버그가 발생했을 때 인터넷에서 본 글을 따라 해결하는 경우가 있었는데요.

 

물론 일단 돌아가는 애플리케이션 개발은 가능했지만, 좀 더 자신감을 가지고 개발하고 싶고, Flutter 실력을 한 단계 더 끌어올리고 싶어서 Flutter가 어떻게 렌더링하는지 제대로 알아봤습니다.

 

그 결과를 정리해봤는데요.

 

비슷한 고민을 하는 분들에게 도움이 되길 바랍니다.

 

시작하며: 이 글의 범위

먼저 이 글에서 이야기하는 "렌더링 방식"이 구체적으로 무엇을 가리키는지 말씀드리겠습니다.

 

전제로, Flutter는 크게 다음 세 가지 계층으로 구성되어 있습니다.

 

 

Framework는 우리가 Flutter로 개발할 때 주로 사용하는 부분으로, Widget과 이를 뒷받침하는 메커니즘을 포함하고 있습니다.

 

Engine 부분은 저수준 API의 모음으로 실제로 화면에 그리는 부분 등을 담당하며, 이는 dart:ui라는 패키지로 Framework 쪽에서 참조됩니다.

 

Embedder는 플랫폼(iOS, Android, Web, Desktop 등)별 로직을 포함합니다.

 

이번 글의 대상은 Framework 부분(+Engine과 상호작용하는 부분)이며, 좀 더 구체적으로는

  1. 우리가 평소에 다루는 Widget이 어떻게 픽셀로 변환되는지
  2. 상태가 변경되었을 때 재렌더링은 어떻게 이루어지는지

이 두 가지를 이해하는 것을 목표로 합니다.

대상 독자

  • Flutter로 어느 정도 개발해 본 경험이 있고, Widget이 무엇인지는 대략적으로 이해하고 있는 분
  • Flutter라는 프레임워크가 구체적으로 어떻게 작동하는지 알고 싶어 하는 분

바쁜 사람들을 위한 요약

내용이 꽤 길어질 예정이므로, 먼저 핵심 내용을 요약해서 말씀드리겠습니다.

  • Flutter에는 Framework, Engine, Embedder 세 가지 계층이 있습니다.
  • Framework와 Engine은 Binding을 통해 상호작용합니다.
  • Framework 부분은 Widget, Element, RenderObject 세 가지 요소가 서로 협력하여 렌더링을 처리합니다.
  • Widget은 Configuration(설정)입니다.
  • Element는 Widget의 인스턴스이며, parent와 children, Widget과 RenderObject에 대한 참조를 가지고 Flutter 애플리케이션을 구성하는 핵심 요소입니다.
  • RenderObject는 어떻게 렌더링할지에 대한 책임을 가집니다.
  • 상태가 변경되면 재계산 대상인 dirty라는 플래그가 Element에 붙고, Frame마다(이 간격은 Engine에서 제어합니다) 모아서 재렌더링됩니다.

Framework와 Engine은 어떻게 상호작용할까요?

자, 그럼 이제 본격적으로 내용을 살펴보겠습니다.

 

가장 먼저 실제로 픽셀을 화면에 그려주는 Engine과 우리가 실제로 사용하는 Framework가 어떻게 상호작용하는지, 그 과정을 알아보도록 하겠습니다.

 

Engine과 Framework가 어떻게 상호작용하는지 큰 그림을 보기 위해 다음 그림을 봐주세요.

 

 

여기서는 먼저 제스처(탭 등 화면과 관련된 조작)나 HTTP 요청 등으로 일련의 처리가 시작됩니다.

 

Frame 간격은 Engine에서 제어하며, Frame마다 Engine에서 DrawFrame이라는 함수를 실행하라는 명령을 받습니다.

 

그러면 Framework 계층에서는 후술할 Widget이나 RenderObject를 업데이트하고, 새로운 화면을 다시 계산해서 최종적으로 Engine에 "이렇게 화면을 표시해 주세요!"라는 요청을 보냅니다.

 

이처럼 Framework와 Engine은 반복적으로 상호작용하면서 Flutter 렌더링이 이루어집니다.

Binding에 대해서

그리고 이러한 상호작용은 Binding이라는 인터페이스를 통해 이루어집니다.

 

Framework와 Engine 사이의 데이터 교환은 Binding을 통해서만 가능합니다.

 

Binding에는 다음과 같은 종류가 있습니다.

  • SchedulerBinding: Engine과 Framework가 상호작용하는 Frame 간격 관리 등을 담당합니다.
  • GestureBinding: 제스처(화면 탭 등) 이벤트 전달 역할을 합니다.
  • RendererBinding: Engine과 Render Tree를 연결하는 역할을 합니다.
  • WidgetsBinding: Engine과 Widget을 연결하는 역할을 합니다.

이 외에도 여러 종류가 있지만, 궁금하신 분들은 src 디렉토리 안의 각 하위 디렉토리(animation 등 없는 것도 있습니다)에 있는 binding.dart 파일에서 확인할 수 있습니다.

 

Binding을 직접 사용할 일은 많지 않지만, 가끔 유용한 경우가 있습니다.

 

이때 "Engine과 Framework를 연결하는 것"이라는 점을 알고 있으면 이해하기 더 쉬울 겁니다.

 

예를 들어, 저도 최근에 앱이 백그라운드에서 포그라운드로 전환될 때를 감지하기 위해 WidgetBinding을 사용하여 다음과 같은 코드를 작성했는데요.

 

당시에는 그냥 '주문'처럼 사용했었습니다.

 

지금은 "앱이 열렸는지 여부는 로우 레벨에서만 알 수 있으니까 Engine에서 알려주는 거겠지. 이 메서드는 Widget 안에서 호출하고 싶으니까 WidgetsBindingObserver라는 걸 일부러 준비해서 사용할 수 있도록 한 거겠지."라고 좀 더 확신을 가지고 생각할 수 있게 되었습니다.

class _SomeState extends State<SomeWidget> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();

    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) async {
    if (state == AppLifecycleState.resumed) {
      // 앱이 다시 열릴 때 뭔가 실행
    }
  }
}

여담: WidgetsFlutterBinding에 대해서

참고로 WidgetsFlutterBinding이라는 것도 있는데요.

 

WidgetsFlutterBinding.ensureInitialized()라는 메서드를 사용해 본 분도 계실 겁니다.

 

하지만, 이것은 "Engine과 Framework를 연결하는 것"이라는 역할보다는, 간단히 말해 "모든 Binding을 초기화하는 것"이라는 역할을 합니다.

 

사실, 여러분이 반드시 실행하는 runApp 메서드 안에는 이 WidgetsFlutterBinding을 사용하여 Binding을 초기화한 후 여러 작업을 수행합니다.

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();
}

 

WidgetsFlutterBinding 자체의 코드는 아래와 같이 매우 간단합니다.

class WidgetsFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, ServicesBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {
  static WidgetsBinding ensureInitialized() {
    if (WidgetsBinding._instance == null)
      WidgetsFlutterBinding();
    return WidgetsBinding.instance;
  }
}

 

ensureInitialized라는 함수가 있고, mixin을 이용하여 각 Binding의 initInstances 메서드(즉, 초기화)를 실행합니다.


(참고로 이 코드를 처음 봤을 때 "이게 뭐하는 거지?"라고 생각했는데, Dart의 mixin이라는 기능을 잘 활용한 것이었습니다.

 

이 해설 글(WidgetsFlutterBinding에서 보는 mixin 사용법)이 도움이 되었습니다.)

Framework 내에서 렌더링 책임을 가지는 부분: RenderObject

Engine과 Framework가 어떻게 상호작용하는지 알아보았습니다.

 

다음으로 Framework 내에서 렌더링 책임을 가지는 부분은 어디이며, 무엇을 하는지 구체적으로 살펴보겠습니다.

 

결론부터 말씀드리면, 렌더링 자체에서 중요한 요소는 RenderObject입니다(루트 요소는 RenderView라고 부릅니다).

 

 

이 RenderObject가 구체적으로 무엇을 하는지 더 자세히 이해하기 위해 코드를 읽어보도록 하겠습니다.

 

RenderObject는 abstract class로 정의되어 있으며, 좀 더 구체적인 형태로 확장해서 사용합니다.

 

다양한 메서드가 있지만, 무엇을 하는지 이해하려면 layout 메서드와 paint 메서드를 살펴보는 것이 좋습니다.

/// constraints를 인수로 레이아웃을 계산합니다.
// 이걸 렌더링할 거야!라는 플래그를 설정합니다.
void layout(Constraints constraints, { bool parentUsesSize = false }) {
    ... // 중략
    performLayout();
    markNeedsPaint();
}

 

layout 메서드는 인수로 Constraints를 받아서 이 RenderObject를 렌더링할 레이아웃을 계산합니다.

 

또한, 마지막에 markNeedsPaint라는 함수를 실행하여 이름 그대로 "이 RenderObject를 렌더링한다"는 플래그를 true로 설정합니다.


참고로 Constraints가 무엇인지 궁금하신 분들은 Flutter에서 레이아웃을 구성할 때 매우 중요한 개념이므로 공식 문서인 Understanding constraints를 참고해주세요.

 

결과적으로 다음과 같은 paint 메서드가 실행됩니다.

/// 인수로 지정된 context, offset에 이 RenderObject를 그립니다.
/// 서브클래스에서는 이 메서드를 override하여 어떤 모양을 그릴지 구현합니다.
void paint(PaintingContext context, Offset offset) { }

 

위는 abstract 정의이므로 실제로 구현된 부분에서 무엇을 하는지 살펴보겠습니다.

 

Radio Widget의 paint()가 크기가 작아서 적절해 보이네요.


(엄밀히 말하면 아래에서는 CustomPaint라는 Widget을 사용하여 그리고 있지만, PaintingContext를 직접 사용하는 코드 중에서 적절한 예시를 찾지 못해서 양해 부탁드립니다.)

@override
void paint(Canvas canvas, Size size) {
        paintRadialReaction(canvas: canvas, origin: size.center(Offset.zero));

        final Offset center = (Offset.zero & size).center;

        // Outer circle
        final Paint paint = Paint()
                ..color = Color.lerp(inactiveColor, activeColor, position.value)!
                ..style = PaintingStyle.stroke
                ..strokeWidth = 2.0;
        canvas.drawCircle(center, _kOuterRadius, paint);

        // Inner circle
        if (!position.isDismissed) {
                paint.style = PaintingStyle.fill;
                canvas.drawCircle(center, _kInnerRadius * position.value, paint);
        }
}

 

결과적으로 그려지는 것은 다음과 같은 모습입니다.

 

 

위 코드를 보면 정말 정직하게 drawCircle 등의 메서드를 통해 Canvas를 조작하여 원을 그리는 것을 알 수 있습니다.

 

이처럼 RenderObject라고 불리는 요소가 어떤 레이아웃과 좌표로, 어떤 방식으로 픽셀을 칠할지 지정하는 책임을 가지고 있습니다.

 

하지만, 우리가 Flutter로 개발할 때 이 RenderObject를 직접 사용하는 경우는 거의 없습니다.

 

우리가 다루는 것은 Widget인데요.


그래서 다음으로는 Widget과 RenderObject, 그리고 이 둘을 연결하는 Element에 대해 알아보겠습니다.

 

참고로 이 layout과 paint에 대한 자세한 알고리즘은 이 영상에서 설명하고 있으니 관심 있으시면 봐주세요.

 

https://www.youtube.com/watch?v=UUfXWzp0-DU

 

여담: Canvas란 무엇일까요?

갑자기 "Canvas를 조작하여 그리고 있다"라는 표현이 나와서 "Canvas가 뭐지?"라고 생각하신 분도 있을 겁니다.

 

Flutter는 기본적으로 렌더링 엔진으로 Skia라는 것을 사용하고 있기 때문에, 모바일 등을 타겟으로 하는 경우에는 이것이 사용됩니다.

 

아래 Flutter 공식 채널의 코드 리딩 영상에서는 Skia 코드까지 깊이 있게 설명하고 있으니 관심 있으시면 봐주세요.

 

https://www.youtube.com/watch?v=qXAUNLWdTcw

 

 

Widget, Element, RenderObject

자, 이제 렌더링을 담당하는 것은 RenderObject라고 설명했습니다.


하지만 우리가 Flutter로 개발할 때 RenderObject를 직접 사용하는 경우는 거의 없고, 모든 것을 Widget으로 다룹니다.

 

Everything is a Widget!이죠.


여기에서는 Widget, 그 인스턴스인 Element, 그리고 RenderObject, 각각의 역할과 서로 어떻게 관련되어 있는지 설명하겠습니다.

 

Element가 이 세 가지를 연결하는 중심 역할을 하므로,

  1. 먼저 Element가 무엇인지 설명하고
  2. Widget이 구체적으로 무엇인지
  3. 왜 이 세 가지로 나뉘어 있는지

이 순서로 설명하면 이해하기 쉬울 것 같아서 위 순서대로 이야기하겠습니다.

Element란 무엇일까요?

An instantiation of a [Widget] at a particular location in the tree.

 

 

Element는 Widget을 인스턴스화한 것이라고 Flutter 코드 내 주석에 적혀 있습니다.

 

일반적으로 Flutter에서는 세 가지 트리(Widget tree, Element tree, Render Tree)가 있다고 설명하지만, 실제로 부모와 자식에 대한 참조를 가지고 있는 것은 이 Element뿐입니다.


또한, 이 Element는 Widget과 RenderObject에 대한 참조도 가지고 있기 때문에 실질적으로 이 Element가 Flutter의 중심 역할을 한다고 해도 과언이 아닙니다.

 

Widget, Element, RenderObject의 관계

그림으로 나타내면 다음과 같습니다.

 

앞서 말씀드린 것처럼 Element가 중심에 있고, Element에서 Widget과 RenderObject 각각에 대해 하나의 참조를 가지고 있습니다.

 

이처럼 다른 요소 간의 참조를 갖는 것이 Element의 역할입니다.

 

참고로 우리가 평소 애플리케이션 개발에서 사용하는 것은 Widget이지만, 이것이 어떻게 Element를 생성하는지 간단히 말씀드리자면, Widget을 상속한 클래스가 인스턴스화될 때 Widget.createElement가 실행되어 Element가 생성됩니다.

 

또한, 다음으로 프레임워크가 mount 메서드를 실행하고, 그 안에서 attachRenderObject가 실행되어 RenderObject와 연결됩니다.

여담: BuildContext에 대해서

여기서 Flutter를 개발하면서 반드시 보게 되는 BuildContext에 대해서도 이야기해볼까 합니다.


왜 이 시점에서? 라고 생각하신 분도 있을 텐데요.

 

사실 이 BuildContext는 바로 Element입니다.

 

앞서 Element는 Widget을 인스턴스화한 것이며 부모-자식 관계를 갖는다고 소개했는데요.

 

BuildContext의 목적은 해당 Widget이 Widget Tree에서 어디에 있는지 알 수 있도록 하는 것입니다.

 

(참고로 BuildContext class는 주석에서 A handle to the location of a widget in the widget tree.

라고 설명되어 있습니다.)

 

Element 클래스 정의를 한번 볼까요?

 

implements BuildContext라고 되어 있는 것을 확인할 수 있습니다.

abstract class Element extends DiagnosticableTree implements BuildContext {

 

BuildContext를 implements하는 것은 이 Element뿐이므로 "BuildContext는 사실상 Element!"라고 불러도 무방합니다.

 

참고로 "왜 굳이 BuildContext라고 따로 만들어놨을까?

 

Element 그대로 사용하면 안 되는 걸까?"라고 생각하신 분도 있을 겁니다.

 

사실 저도 처음엔 그렇게 생각했습니다.


이건 제 추측이지만, BuildContext라는 인터페이스로 실제로 개발자가 사용하는 부분을 타입으로 지정함으로써 Element Tree를 실수로 변경하는 등의 위험한 조작을 하지 못하도록 API를 설계한 것이 아닐까 생각합니다.

 

평소 애플리케이션 개발에 필요한 최소한의 API만 노출시킨 것이라고 생각합니다.

 

참고로 "꼭 Element로 조작하고 싶어!"라는 경우에는 실제로는 Element이기 때문에 다음과 같이 간단하게 캐스팅하면 작동합니다.

(context as Element).visitChildren()

 

덧붙여서 공식 BuildContext 설명 영상도 추천합니다.

 

https://www.youtube.com/watch?v=rIaaH87z1-g

 

더 나아가서: Element의 종류

Element에는 몇 가지 종류가 있습니다.

  • RenderObjectElement: 이름 그대로 RenderObject에 대한 참조를 갖는 Element입니다.
    • 여기서 더 나아가 RootRenderObjectElement, LeafRenderObjectElement, SingleChildRenderObjectElement, MultiChildRenderObjectElement 등으로 트리 내 위치나 자식의 유무, 자식 수에 따라 나뉩니다.
  • ComponentElement: 컴포넌트라는 이름처럼 다른 Element를 만드는 Element입니다.
    • 여기서 더 나아가 StatelessWidget, StatefulWidget, ProxyElement로 나뉩니다.

굳이 알 필요는 없는 지식이라고 생각할 수도 있지만, 혹시 Flutter 코드 리딩을 할 경우에는 이런 차이가 있다는 것을 염두에 두면 원하는 부분을 찾기가 더 쉬워질 수 있어서 소개해 드립니다.

Widget이란 무엇일까요?

Flutter 소스 코드를 읽어보면 다음과 같은 주석이 달려 있습니다.

Describes the configuration for an [Element].

 

Element의 설정을 나타낸다고 적혀 있네요.


설정이 뭐냐고 생각하는 분도 있을 수 있으니, Widget 구현을 한번 살펴보겠습니다.

 

먼저, Widget에는 크게 다음 네 가지 종류가 있습니다.

  • ProxyWidget: Proxy라는 이름 그대로 데이터를 전달하기 위한 것입니다. InheritedWidget이나 ParentDataWidget 등이 상속하고 있습니다.
  • RenderObjectWidget: 이름에서 짐작할 수 있듯이 RenderObjectElement 및 RenderObject를 어떻게 생성할지에 대한 설정을 포함하며, 보이는 부분에 직접적으로 관여합니다.
    • 여기서 더 나아가 LeafRenderObjectWidget, SingleChildRenderObjectWidget, MultiChildRenderObjectWidget으로 나뉩니다. (이름에서 대략 짐작하셨겠지만 child를 가지고 있는지, 여러 개 가지고 있는지에 따라 다릅니다.)
  • Components: 우리가 평소 개발할 때 사용하는 StatelessWidget이나 StatefulWidget이 해당됩니다. 다른 기본 Widget을 조합해서 만듭니다.

이번에는 Element와 RenderObject의 관계도 살펴볼 수 있으면 좋겠다고 생각해서 RenderObjectWidget을 살펴보겠습니다.


(여담이지만 Flutter 표준 Widget 코드를 읽어보면 RenderObjectWidget을 상속하지 않아도 paint 메서드를 구현한 경우가 많은데요.

 

이는 CustomPainter라는 Widget을 사용해서 그리는 것입니다.

 

RenderObject를 직접 사용하는 것과 CustomPainter를 사용하는 것을 비교하는 글도 언젠가 써보고 싶네요.)

 

RenderObjectWidget은 다음과 같이 createElement 메서드에서 RenderObjectElement를 반환하고, 어떤 RenderObject를 만들지/어떻게 업데이트할지 지정하는 createRenderObject 및 updateRenderObject라는 메서드를 구현합니다.

abstract class RenderObjectWidget extends Widget {
  const RenderObjectWidget({Key? key}) : super(key: key);

  RenderObjectElement createElement();

  RenderObject createRenderObject(BuildContext context);
  void updateRenderObject(
      BuildContext context, covariant RenderObject renderObject) {}
  void didUnmountRenderObject(covariant RenderObject renderObject) {}
}

 

이 RenderObjectWidget을 상속하는 Widget 중에서 Container 안의 DecoratedBox라는 것을 살펴보겠습니다.

class DecoratedBox extends SingleChildRenderObjectWidget {

  RenderDecoratedBox createRenderObject(BuildContext context) {
    return RenderDecoratedBox(
      decoration: decoration,
      position: position,
      configuration: createLocalImageConfiguration(context),
    );
  }

  void updateRenderObject(BuildContext context, RenderDecoratedBox renderObject) {
    renderObject
      ..decoration = decoration
      ..configuration = createLocalImageConfiguration(context)
      ..position = position;
  }
}

 

이런 식으로 createRenderObject라는 RenderDecoratedBox를 반환하는 함수를 구현하고 있습니다.

 

그리고 이 RenderDecoratedBox는 RenderBox를 상속하고 있고, 다음과 같은 paint 메서드를 구현하고 있습니다.


예시가 조금 아쉽게 느껴지지만 (마음에 드는 것을 찾지 못해서 이해해주세요), Canvas에 어떻게 픽셀을 칠할지 지정하고 있습니다.

void paint(PaintingContext context, Offset offset) {
    _painter ??= _decoration.createBoxPainter(markNeedsPaint);
    final ImageConfiguration filledConfiguration = configuration.copyWith(size: size);
    if (position == DecorationPosition.background) {
      _painter!.paint(context.canvas, offset, filledConfiguration);
    }
    super.paint(context, offset);
    if (position == DecorationPosition.foreground) {
      _painter!.paint(context.canvas, offset, filledConfiguration);
    }
}

 

이런 식으로 Widget은 설정, 구체적으로 어떤 RenderObject를 그릴지 지정하면서 Element를 만들고 있습니다.

 

그리고 RenderObjectElement 쪽에서 mount 메서드 안에서 위와 같은 Widget의 createRenderObject 메서드를 호출합니다.

  void mount(Element? parent, Object? newSlot) {
    super.mount(parent, newSlot);
        // 여기!
    _renderObject = (widget as RenderObjectWidget).createRenderObject(this);     attachRenderObject(newSlot);
    _dirty = false;
  }

 

이 외에도 Widget은 상태나 메서드를 가지고 있는 등, 구체적으로 렌더링할 때 필요한 다양한 정보를 가지고 있습니다.

 

이처럼 어떤 RenderObject를 만들지, 어떤 데이터를 넘겨서 Element를 만들지 등 "설정"을 유지하는 것이 Widget의 역할입니다.

왜 세 가지로 나뉘어 있을까요?

지금까지 Widget, Element, RenderObject라는 세 가지 역할로 Flutter 렌더링이 이루어진다고 설명했는데요.

 

왜 굳이 이렇게 세 가지로 나뉘어 있을까요?

 

이 부분은 어느 정도 추측도 들어가지만 "선언적 UI를 구현하기 위해서, 그리고 구현하면서도 성능 저하를 막기 위해서"라고 생각합니다.

 

예를 들어, 극단적으로 명령적으로 조작한다면 Element에 직접 어떤 새로운 요소를 추가하거나 삭제할지 작성하는 것으로 충분하고 Widget은 불필요합니다.

 

그 경우에는 굳이 Element처럼 무거운 요소를 일일이 삭제할 필요가 없으므로 성능 저하에 대한 우려도 없을 겁니다.

 

하지만 선언적으로 작성하고 싶은 경우에는 Element만으로는 상태가 업데이트되었을 때 해당 Element 이하의 트리를 모두 재계산하고, 렌더링과 관련된 무거운 객체까지 삭제해야 하므로 성능이 저하될 수 있습니다.

 

이를 방지하기 위해 최소한의 업데이트와 재사용을 구현하기 위해 Widget과 Element가 분리되어 있는 것이 아닐까 생각합니다.

 

이 부분은 Google 관계자가 렌더링 방식을 설명하는 영상에서 답변한 내용을 참고해서 작성했는데요.

 

트리가 변경되었을 때 다음과 같이 Element와 RenderObject를 재사용하면서 업데이트합니다.

  • 먼저 전제로 Widget은 Immutable하고 가벼운 객체이므로 일일이 삭제해도 부담이 없습니다.
  • 트리가 변경될 때 새로운 Widget이 만들어지고, Element에서는 canUpdate라는 메서드로 새로운 Widget과 기존 Widget이 같은 것인지 판단합니다 (여기서는 runTimeType과 key를 비교합니다. 그래서 가능한 한 재사용하기 위해 트리 내에서 요소의 위치가 변경될 때 key를 지정하여 재사용할 수 있도록 하는 것이 중요합니다). (보충: 댓글에서 지적해주셨는데요. key를 지정하지 않은 경우에도 "리빌드 전후에 둘 다 null이므로 동일하다"고 판단되므로 "Widget의 타입은 바뀌지 않았지만 Element는 다시 생성하고 싶다"는 경우가 아니면 key를 사용하지 않는 것이 더 적절합니다.)
  • 동일하다고 판단되면 Element의 Widget에 대한 참조가 새로운 것으로 바뀝니다.

이처럼 선언적으로 작성하면서도, 상태 업데이트 시 필요한 최소한의 정보를 Widget에 담고 최대한의 정보를 재사용할 수 있도록 하기 위해 Widget과 Element + RenderObject로 분리되어 있는 것이 아닐까 생각합니다.

 

Element와 RenderObject가 분리된 이유에 대해서는 Flutter 공식 문서인 Inside Flutter에 다음과 같이 적혀 있습니다.

Performance. When the layout changes, we only need to walk the relevant parts of the layout tree. Because of composition, the element tree often has many more nodes that we would have to skip.

Clarity. By making the separation of concerns explicit, we can specialize the widget protocol and the render object protocol to their specific needs, simplifying the APIs and reducing the risk of bugs and the burden of testing.

Type safety. The render object tree can ensure at runtime that children are of the appropriate type, making it more type-safe (e.g. each coordinate system has its own type of render object). Composition widgets can be agnostic of the coordinate system used at layout time (for example, the same widget exposing parts of your app's model can be used both in a box layout and a sliver layout). The element tree would then require tree walks to check types of render objects.

재렌더링 방식

지금까지 Framework 레이어의 렌더링 방식에 대해 대략적으로 살펴보았습니다.


마지막으로 남아있는 궁금증, 상태가 업데이트되었을 때 재렌더링은 어떻게 이루어지는지 알아보겠습니다.

 

먼저 상태는 StatefulWidget에서만 다룰 수 있으므로 해당 정의를 살펴보겠습니다.

abstract class StatefulWidget extends Widget {
  const StatefulWidget({Key? key}) : super(key: key);
  StatefulElement createElement() => StatefulElement(this);
  State createState();   

 

일반 Widget과 다른 부분은 거의 없습니다.

 

다른 점은 StatefulElement를 반환하는 부분, createState라는 State를 반환하는 함수를 반드시 override하도록 한다는 점입니다.

다음으로 State를 살펴보겠습니다.

 

abstract class State<T extends StatefulWidget> with Diagnosticable {
  T get widget => _widget!;
  T? _widget;
  _StateLifecycle _debugLifecycleState = _StateLifecycle.created;
  BuildContext get context {
    return _element!;
  }
  StatefulElement? _element;
  bool get mounted => _element != null;
  Widget build(BuildContext context);

  void initState() {
    assert(_debugLifecycleState == _StateLifecycle.created);
  }
  void didUpdateWidget(covariant T oldWidget) {}
  void setState(VoidCallback fn) {
      final Object? result = fn() as dynamic;
        _element!.markNeedsBuild();
    }
  void didChangeDependencies() {}
}

 

자세히 읽어보면 매우 간단하다는 것을 알 수 있는데요.

 

먼저 State는 Widget이나 Element 등에 대한 참조를 가지고 있거나 Widget을 반환하는 build 메서드를 가지고 있습니다.

 

이는 State를 상속하는 클래스에서 다른 Widget처럼 사용할 수 있도록 하기 위한 것으로 보입니다.

 

그리고 핵심은 setState 함수입니다.

 

아시는 것처럼 StatefulWidget을 상속해서 구현할 때 setState 내에서 상태를 업데이트하는데요.

 

이 함수가 하는 일은 매우 간단합니다.

 

인수로 전달된 함수를 실행하고, _element!.markNeedsBuild(); 라는 "이 Element는 빌드 대상이야!"라는 플래그(Element 안의 dirty라는 속성)를 true로 설정하는 함수를 실행하는 것뿐입니다.

 

눈치채신 분도 있겠지만, 이 element.markNeedsBuild를 직접 호출하여 강제로 다시 빌드할 수 있습니다.

 

그렇다면 왜 이렇게 거의 아무것도 하지 않는 것 같은 함수를 Flutter는 준비해 놓은 것일까요?


이것도 제 생각이지만 "내부 사정을 숨기고, 상태가 업데이트되었을 때만 호출되도록 하기 위해서"가 아닐까 생각합니다.

 

기본적으로 다시 빌드해야 하는 경우는 상태가 변경되었을 때뿐일 테니까요.

 

내부 구조를 함부로 노출하지 않는 좋은 설계라고 느껴집니다.

 

그리고 플래그를 true로 설정하면 재렌더링이 바로 실행되는 것이 아닙니다.


일단 여기에서는 다음에 렌더링할 Element 큐에 담아두고, 다음 Frame이 되었을 때 모아서 다시 빌드됩니다.

 

이것 역시 성능을 고려한 것 같습니다.

 

여기서 맨 앞에서 소개한 framework와 engine의 상호작용 그림을 다시 보겠습니다.

 

이 Frame 간격은 Engine에서 제어하며, SchedulerBiding의 handleDrawFrame이라는 Engine에서 실행되는 메서드에서 다시 WidgetsBinding을 통해 재계산 메서드(drawFrame)가 실행됩니다.

 

BuildOwner의 buildScope라는 메서드가 호출되고, dirty로 표시된 모든 Element에 대해 rebuild()라는 메서드가 호출됩니다.


그 안에서 Element가 연결된 Widget의 build 메서드(우리가 평소에 작성하는 것!)를 실행합니다 (※엄밀히 말하면 ComponentElement의 경우).

 

이렇게 해서 상태가 변경되면 빌드 대상으로 표시 -> Frame마다 모아서 재렌더링이라는 생각보다 간단한 방식으로 구현되어 있습니다.

마무리하며

이상, Flutter 렌더링 방식에 대해 살펴보았습니다.


렌더링 방식을 이해함으로써 Flutter Framework를 사용하는 데 있어서 다양한 요소의 역할과 의미를 이해할 수 있으므로 좀 더 자신감을 가지고 개발할 수 있게 될 것이라고 생각합니다.


실제로 저도 감으로 사용하던 WidgetsBinding이나 BuildContext 등의 의미를 제대로 알게 되었고, 재렌더링 방식도 알게 되어서 어이없는 에러로 헤매는 일 없이 개발할 수 있습니다.

 

또한, 이번 글에서는 개요를 말씀드렸지만, 꼭 Flutter 코드 리딩을 해보는 것을 추천합니다.

 

저도 다른 분들이 비슷한 주제에 대해 쓴 글이나 영상을 많이 봤지만, 실제로 코드를 보면서 어떤 내용의 함수가 실행되는지 제대로 이해함으로써 더 큰 확신을 가질 수 있게 되었습니다.

 

이 글 뒤에 참고한 글/영상 링크와 코드 리딩할 때 참고하면 좋을 만한 부분을 정리해 두었으니 꼭 활용해보세요.