본문 바로가기
Flutter

[Flutter] A pragmatic guide to BuildContext in Flutter(요약,번역)

by 아마도개발자 2024. 1. 27.

 

플러터가 실제로 화면을 그려내는 과정에 대한 궁금함이 있던 차에 좋은 아티클을 발견했다.

 

Daria Orlova라는 사람이 작성한 A pragmatic guide to BuildContext in Flutter라는 글로, flutter framework를 이해하는데 많은 도움이 된 것 같아 글을 소개하는 의미로 요약,번역을 해보았다.

 

더 자세한 정보를 얻기 위해서는 꼭 원문을 읽어 보는 것을 추천한다.

 

 

 

 

A pragmatic guide to BuildContext in Flutter | Codemagic Blog

Understanding BuildContext is crucial to leveling up your Flutter development game. In this guide, Daria Orlova explains everything you need to know about it

blog.codemagic.io

https://blog.codemagic.io/a-pragmatic-guide-to-buildcontext-in-flutter/?utm_source=medium&utm_medium=referral&utm_campaign=flutter_distribution

 

 

플러터는 어떻게 동작하는가

 

한 가지 알아야할 것은 Flutter는 UI를 Native Component로 'translate'하지 않는다는 것이다. Flutter는 Skia Graphics Engine을 통해 캔버스위에 직접 그림을 그린다(추후 Impeller Graphics Engine으로 바뀔 예정). 덕분에 Flutter위젯은 플러터 개발자들이 UI를 만드는 것에 더욱 높은 자유도를 부여하고, 플랫폼과 UI API로 부터 독립적으로 남을 수 있게 해준다. 또한, 이것은 성능적으로 많은 이점이 있다.

 

이러한 선택의 자유와 함께, 플러터는 widget tree라는 고유의 UI building ststem을 사용한다. 즉, 플러터 개발자들이 캔버스에 무언가를 그릴 때는 widget을 사용해야 한다는 것이다. 이 widget이라는 것은 무엇일까?

 

위젯이란

플러터에서 프로젝트를 생성하면, main.dart에 아래와 같이 코드가 생성된다.
void main() {
	runApp(const MyApp());
}
 

이 스니펫으로 부터 다음과 같은 발견을 할 수 있다.

 

  1.  main함수는 dart코드의 엔트리 포인트이다.
  2.  runApp이 앱을 실행하기 위해 Flutter runtime을 설정한다.
  3.  MyApp객체를 runApp함수에 넘겨준다.

 

위 코드에서 우리는 widget을 처음 접하게 되었다. 위젯이란 Flutter에서 UI의 기본 구성 요소이다. 플러터로 앱을 개발할 때, 우리는 아주 아주 많은 위젯들을 생성해야 한다. 원하는 UI를 만들기 위해서 이 것들을 다양한 방법으로 조합하여 하고, 그 조합의 형태를 `widget tree`라고 한다.

 

기본적으로, Flutter App의 모든 것이 widget tree로 표현된다. 또한, runApp함수에 넘겨주는 그 것이 Flutter app widget tree의 root가 될 것이다. 

 

여러가지 widget을 사용하며, StatelessWidget을 상속받는 간단한 예제를 살펴보자.

import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      home: StaticHomePage(),
    ),
  );
}

class StaticHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Container(color: Colors.red, height: 100, width: 500),
        Text('Hello Flutter'),
        Container(color: Colors.blue, height: 100, width: 500),
      ],
    );
  }
}

 

  1.  우리는 StatelessWidget을 상속받는 StaticHomePage 클래스를 생성했다.
  2.  StatelessWidget의 interface를 따르기 위해, 우리는 build라는 하나의 메서드를 override해야 한다.

build메서드를 자세히 살펴보자. 이 메서드는 Widget타입을 반환한다. 즉, 빌드안에 작성된 위젯이 이 메서드를 통해 widget tree에 삽입되는 것이다. 

위젯이란 하나의 추상 클래스이다. 그리고 이 클래스는 모든 widget타입의 베이스이다. Stateless, Stateful, Inherited 등 모든 위젯이 Widget클래스를 상속받는다.

 

ex)

abstract class StatelessWidget extends Widget {
  /// Initializes [key] for subclasses.
  const StatelessWidget({ super.key });

 

따라서 build메서드는 모든 종류의 widget type이 사용될 수 있다. 

 

 

그래서 build메소드는 무엇인가

 

build메소드는 몇 가지 조건 아래서 Flutter framework 스스로부터 호출된다. 

 

  1.  widget을 widget tree에 처음으로 삽입했을 때
  2.  의존하는 InheritedWidget의 parameter가 바뀌었을 때

이 메소드는 모든 프레임에서 호출되는데, 1초에 약 60~120번 호출될 수 있다. 그렇기 때문에 이 메소드를 가능한 효율적으로 유지해야 하는 것이 성능에 큰 영향을 미친다. network calls, database reads, JSON serialization과 같은 사이드 이펙트가 없어야하고, 다른 문제없이 실행되도록 유지하여야 한다.  

 

다시 이전의 예제 코드로 돌아가보자. 우리는 코드를 작성하여 화면에 그림을 그렸다. 이것은 Flutter magic이 아니라, 논리적이면서도 아름다운 기술로 만들어 낸 것이다. `Everything is a widget`은 사실이 아니다. 주로 위젯을 다루는 것은 사실이지만, 이것 외에도 개발 시에 고려해야할 부분들이 많이 있다.

 

 

Very accurate meme by Cagatay [https://twitter.com/ulusoyapps]

 

 

실제로 Flutter에서 그림을 그리는 RendorObject

우리가 만든 widget tree를 UI로 시각화 해보면 아래 이미지와 같다. 

StaticHomePage의 위젯 트리

 

실제로 그림을 그리는 부분을 다루어 보자. Flutter는 어떻게 Text위젯을 그릴 수 있는 것일까? 
// text.dart

class Text extends StatelessWidget { 

	Widget build(BuildContext context) {
		...
		return RichText(...);
	}
}

 

우선, Text위젯도 StatelessWidget을 상속받은 것을 확인 할 수 있다. 또, build메소드가 RichText라는 위젯을 반환해준다.
 
// basic.dart

class RichText extends MultiChildRenderObjectWidget {

	@override
	RenderParagraph createRenderObject(BuildContext context) { }

	@override
  void updateRenderObject(BuildContext context, RenderParagraph renderObject) { }
}
이 RichText에서 주목할 점은 MultiChildRenderObjectWidget을 상속받는 다는 것과 build메소드가 없고 오직 createRenderObject와 updateRenderObject만 가지고 있다는 것이다. 
Container에서도 같은 케이스를 발견할 수 있다. Container는 ColoredBox, ConstraintsBox등을 반환하며 이 위젯들에서도 build메소드를 발견할 수 없다.
// ColoredBox

class ColoredBox extends SingleChildRenderObjectWidget {
  /// Creates a widget that paints its area with the specified [Color].
  const ColoredBox({ required this.color, super.child, super.key });

  /// The color to paint the background area with.
  final Color color;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return _RenderColoredBox(color: color);
  }

  @override
  void updateRenderObject(BuildContext context, RenderObject renderObject) {
    (renderObject as _RenderColoredBox).color = color;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<Color>('color', color));
  }
}

 

다시 StaticHomePage의 위젯 트리의 widget tree이미지를 확인해보자.
StatelessWidget을 상속받아 Column, Container, Text 등 framework에서 제공 받은 위젯(이미 디자인이 되어있는) 만을 사용하여 widget tree를 구성했다. 하지만 잘 생각해보면 아직 우리는 widget을 활용하여 widget tree를 구성했을 뿐 실제로 화면에 그림을 그리는 drawing과 관련된 어떠한 작업도 하지 않았다. 이제 우리는 무엇이 실제로 화면에 그림을 그려주는지에 대해 알아보아야 한다.

 

Flutter는 무엇을 그려야할지 어떻게 결정할까 (RenderObject)

`Every widget that can actually be painted and not just composited has to extend a RenderObjectWidget, which, in turn, deals with RenderObjects.` 
실제로 그려질 수 있고 조합되지 않은 모든 위젯은 RenderObjects를
다루는 RenderObjectWidget을 상속받는다

 

 

RenderObject는 크기 계산, translates, 색상 등 모든 drawing로직을 담당한다. 즉, 이름 그대로 렌더링을 담당하는 것이다.

 

우리는 Widget의 2가지 개념을 알아야 한다.

 

1. Composing widgets(위젯의 조합): 렌더링과 관련된 그 어떤 작업도 하지않고, 위젯을 활용하여 더욱 복잡한 뷰를 구성한다. 

2. Rendering widgets(위젯의 렌더링): 다음 3가지 위젯을 통해 최종적으로 RenderOBjectWidget을 상속받는다. 

  • SingleChildRenderObjectWidget: 하나의 자식을 그린다. ex) ColoredBox
  • MultiChildREnderObjectWidget: 여러 자식을 그린다. ex) Column
  • LeafChildRenderObjectWidget: 자식을 그리지 않고 오직 자신만을 그린다. ex) ErrorWidget

이 위젯들이 RenderObject를 통해서 lower-level 렌더링을 실행한다. build메소드를 override하지 않고, createRenderObject와 updateRenderObject를 override한다.

 

플러터로 앱을 개발하는 동안 아무리 많은 widget tree를 만들더라도 결국 RenderObjectWidgets로 귀결된다는 점을 이해하는 것이 중요하다.

 

 

 

 

BuildContext로 연결짓기

 

이제 우리는 Flutter가 실제로 위젯을 그리는 것이 아니라 일부 위젯에 의해 생성된 rendor objects를 렌더링 한다는 것을 알게 배웠다. 또한, build메서드가 초당 60~120회 호출될 수 있다는 것도 알고있다. 그렇다면 build메서드가 호출될 때마다 전체 엡의 전체 widget tree가 다시 렌더링된다면 굉장히 비효율적이라는 점을 생각할 수 있다. 

 

Immutable widgets

immutable widget이라는 의미가 무엇일까? 기술적으로 말하면 모든 위젯의 필드들은 final이어야 한다. 다시 말하면, 위젯이 생성된 뒤로는 아무것도 바꿀 수가 없다는 것을 의미한다. widget과 widget의 필드들, 행위 등 모든 것을 조작할 수 없다.

지금 까지 배운 것을 요약하면, widget들은 immutable하며, build메서드는 1초에 약 100번 호출되고, 100개의 위젯을 생성하면서 실제로 우리가 화면에서 볼 수 있는 것은 RenderObjects를 통해 그려진다. 이제는 이 모든 것들을 합쳐줄 수 있는 무언가가 필요할 것이다.

 

Elements란

 

Elements는 immutable widgetmutable render object를 하나로 묶어주는 역할을 한다. 

Elements의 동작을 이해하기위해 StatelessWidget을 다시 살펴보자.

// framework.dart

abstract class StatelessWidget extends Widget {
  const StatelessWidget({ super.key });

  @override
  StatelessElement createElement() => StatelessElement(this);

  @protected
  Widget build(BuildContext context);
  
  ...

  @immutable
  abstract class Widget {
    const Widget({ this.key });

	  Element createElement();
  }

}

 

StatelessWidget은 2가지 메서드를 가지고 있다. 그 중 하나는 우리가 알고 있는 build이고, 나머지는 createElement메서드 이다. 이것은 StatelessElement 객체를 반환한다. framework.dart 소스 코드를 확인해보면 StatelessElement 클래스의 상단에 `An [Element] that uses a [StatelessWidget] as its configuration.`라는 주석을 볼수 있다. 여기서 configuration이라는 단어가 위젯이 무엇인지를 정확하게 설명한다. 위젯은 단지 configuration(구성)이며, final속성을 가진 가벼운 객체이다. 저렴한 비용으로 생성할 수 있고 버리는데 소모되는 비용이 적다.

 

하지만 아무리 위젯에 소요되는 비용이 적다고 하더라도, 복잡한 widget tree를 그리는 것에는 많은 비용이 소모된다. 이 문제를 해결하기 위해 사용되는 것이 Element이다. 

 

내가 선언한 위젯이 생성되 후, widget tree에 삽입됨과 동시에 createElement메서드를 통해서 Element가 생성된다. 하지만, widget tree가 리빌드 되었을때, framework는 build메서드에서 반환된 위젯이 widget tree의 해당위치에 있는 element를 업데이트 할 수 있는지 체크한다.

체크 방법은 아래 코드에서 확인할 수 있다. 
// framework.dart

@immutable
abstract class Widget {
  const Widget({ this.key });

	Element createElement();

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

 

Widget은 위젯의 runtimeType과 key를 비교하는 canUpdate메서드를 가지고 있고 ,이것을 비교하여 업데이트 가능여부를 판단한다.

 

업데이트 canUpdate 이후에 무슨일이 일어나는지 보려면 Element의 소스코드를 확인하면 된다. 만약 canUpdate가 false를 반환했다면, 두 위젯은 서로 다른 클래스로 부터 생성된 다른 위젯이라는 의미이므로 Elements를 포함한 모든 하위트리가 삭제된다(렌더링 발생x). 반면, true를 반환하면 두 위젯이 같은 클래스로부터 생성되었다는 것을 의미하므로 Element는 Element.update(newWidget)에 대한 링크를 업데이트 한다(렌더링 발생o, 효율적 rebuild).

// framework.dart


abstract class Element {


  Element(Widget widget)
    : assert(widget != null),
      _widget = widget;

	@override
  Widget get widget => _widget!;
  Widget? _widget;

	@mustCallSuper
  void update(covariant Widget newWidget) {
    _widget = newWidget;
  }
}

 

본질적으로 Element는 모든 것을 컨트롤하는 관리자이다. Element는 위젯의 트리 내에서의 위치 파악, render objects와 위젯 간의 연결 및 업데이트, 그리고 라이프사이클 관리 등 다양한 기능들을 수행한다. 

 

다시 RenderObjectWidget에 집중해보자. StatelessWidget이 statelessElement로 확장되는 것과 같이 RenderObjectWidget은 RenderObjectElement로 확장된다. 위젯에 대한 링크를 업데이트 하는 것 외에 renderObject도 업데이트 한다. 하지만 만약 구성에서 어떠한 변화도 일어나지 않았다면 RenderObject는 아무것도 다시 그리지 않고(재렌더링x), 변화가 일어났다면 그 부분을 다시 그린다(재렌더링o). 이런 방법을 통해 Flutter가 뛰어난 퍼포먼스를 유지할 수 있게 된다.

 

지금까지 우리가 알아본 개념들을 widget tree에 적용해보자.

 

 

 

BuildContext in Flutter

다시 Element 클래스를 살펴보자. 

// framework.dart


abstract class Element extends DiagnosticableTree implements BuildContext {
	...
}

 

Element가 결국 BuildCotnext를 implements(특정 인터페이스를 구현하거나 추상클래스를 확장)하는 것을 알 수 있다.

BuildContext는 단지 위젯들의 Element이다. 즉, 트리에서 어디에 위치하는지, 어디에 접근할 수 있는지, 업데이트 할 수 있는지에 대한 context를 위젯에 제공해주는 것이다.

 

// framework.dart
// Some code removed for demo purposes

abstract class BuildContext {

  /// The current configuration of the [Element] that is this [BuildContext].
	Widget get widget;

  RenderObject? findRenderObject();

  T? dependOnInheritedWidgetOfExactType<T extends InheritedWidget>(...);
  InheritedElement? getElementForInheritedWidgetOfExactType<T extends InheritedWidget>();
  T? findAncestorWidgetOfExactType<T extends Widget>();
  T? findRootAncestorStateOfType<T extends State>();
  // ...and so on
}

 

BuildContext의 내부를 살펴보면, widget필드가 있고, ancestors, widgets, elements 등을 포함하는 여러 메서드들이 있는 것을 볼 수 있다. BuildContext를 사용하는 가장 주요한 이유는 다른 위젯의 위치를 찾기 위해서다. 

 

마무리를 위해 StatelessElement로 다시 돌아가보면

// framework.dart
// Some code removed for demo purposes

/// An [Element] that uses a [StatelessWidget] as its configuration.
class StatelessElement extends ComponentElement {
  /// Creates an element that uses the given widget as its configuration.
  StatelessElement(StatelessWidget super.widget);

  @override
  Widget build() => (widget as StatelessWidget).build(this);

  @override
  void update(StatelessWidget newWidget) { ... }
}

 

StatelessElement도 build메서드를 가지고 있는 것을 확인할 수 있다.

element 스스로를 build메서드에 this로 넘겨준다. widget의 경우, 그것은 BuildContext context의 매개변수이다.

 

이제 BuildContext에 대해 여러가지를 배웠다. 이제 가장 중요한 부분으로, 대부분의 BuildContext와 관련된 문제는 BuildContext를 너무 일찍 혹은 너무 늦게 사용할 발생한다. 또는 잘 못된 위치에서 사용했을때도 발생한다.

 

이제 BuildContext를 정확한 시기에, 정확한 위치에서 사용하기 위해 StatelessElement를 예로 들어 라이프사이클을 리뷰해보자. 이 리뷰를 통해 BuildContext가 수행해야 할 작업과 아닌 것을 구별할 수 있게 될 것이다.

 

StatelessElement lifecycle

 

StatelessElement의 라이프사이클은 다음과 같다.

 

  1. 우리가 처음 만들었던 StaticHomePage를 예로 들면, 이것은 StatelessWidget을 상속받는다.
  2. 플러터가 위젯을 처음 렌더링 했을 때(동시에 위젯 트리에 추가됨), framework가 StatelessWIdget의 createElement메서드를 호출하고, 이것은 StatelessElement를 반환한다. 이것은 Element를 상속받고, BuildContext를 impolement한다(따라서 element = context). Element는 라이프사이클 상태를 갖고, 이 시점에서는 initial상태가 된다. 위젯은 Element생성자에 매개변수로 넘겨지고, local field로 저장된다.
  3. framwork는 Element의 mount메서드를 호출한다. 이 단계에서 라이프사이클 상태는 active를 갖는다. 현재 시점이 build메서드가 처음으로 호출되고, element가 BuilcContext에 파라미터로 넘겨진 타이밍이다. 이제 우리는 화면에서 위젯을 확인할 수 있다.
  4. 어떤 시점에서 widget이 리빌드 될 수도있다. framework는 현재 element가 새로운 widget으로 업데이트 될 수 있는지  Widget.canUpdate(oldWidget, newWidget)를 통해 확인한다. 이 작업은 위젯의 runtimeType과 Key속성을 비교하며 이루어진다. 만약 두 속성 모두 같다면(key가 null일 경우에도 같다고 판단), element는 update메서드에 의해 업데이트 된다. 그런 다음 리빌드 되고, 전체 사이클이 다시 시작된다. Element의 widget필드가 성공적으로 B로 바뀌는 것을 확인할 수 있다. old widget은 제거되었지만, element는 동일하게 남아있다.  
  5. 이후, 다시 어떤시점에서 또 다른 리빌드가 일어난다. 이번에는 canUpdate가 false를 반환했다고 생각해보면, 현재 element는 비활성화 된며, 새로운 element가 생성되고 이 새로운 element로 다시 사이클을 반복한다. 이전 element의 라이프사이클 상태는 deactivate메서드에 의해 inactive가 된다. 머지 않아, 이 element가 다시 reclaimed되지 않으면, 라이프사이클 상태가 defunct가 되어 다시는 접근하거나 사용할 수 없게 된다.

 

이렇게 StatelessWidget을 예로들어 위젯의 생성에서부터 실제로 화면이 그려지기까지에 대한 작동원리를 살펴보았다. 사실 원문에 내용이 워낙 방대하고 구체적이기 때문에 더욱 자세한 정보를 얻기 위해서는 꼭 원문을 읽어 보기를 추천한다.(하지만 너무 방대한 감이 있긴 하다)