Coaspe

Flutter - Architecture overview #Rendering and layout 본문

Flutter/근본

Flutter - Architecture overview #Rendering and layout

Coaspe 2023. 5. 28. 23:24

(수정)

Rendering and layout

이번 섹션에서 Flutter가 위젯의 계층을 화면에 그려진 실제 픽셀로 변환하기 위해 수행하는 일련의 단계들인 렌더링 파이프라인에 대해 설명합니다.

Flutter의 Rendering model

Flutter가 크로스 플랫폼 프레임워크라면, 싱글 플랫폼 프레임워크들과 비교적 좋은 성능을 어떻게 발휘할지 궁금할 것입니다.

 

전통적인 안드로이드 앱들이 어떻게 작동하는지 생각해보는 것부터 시작하는 게 좋습니다. 드로잉 할 때, 안드로이드 프레임워크의 Java 코드를 첫번째로 호출 합니다. 안드로이드 시스템 라이브러리들은 Skia로 안드로이드가 렌더하는 Canvas 객체에 스스로 드로잉 하는 컴포넌트를 제공합니다. Skia는 디바이스 위의 드로잉을 하기 위해 CPU나 GPU를 호출하는 C/C++로 만들어진 그래픽스 엔진 입니다.

 

크로스 플랫폼 프레임워크는 보통 기본 안드로이드 및 iOS UI 라이브러리에 추상화 계층을 생성하여 각 플랫폼 표현의 불일치를 완화합니다. 앱 코드는 종종 UI를 디스플레이하기 위해 결국 Java 베이스인 안드로이드나 Objective-C 베이스인 iOS 시스템 라이브러리와 상호작용 해야하는 Javascript 같은 interpreted 언어로 작성됩니다. 이 모든 것은 특히 UI와 앱 로직 사이에 많은 상호 작용이 있는 경우 상당한 오버헤드를 추가할 수 있습니다.

 

반대로, Flutter는 flutter의 위젯 셋으로 시스템 UI 위젯 라이브러리를 우회하므로써 이런 추상화를 최소화 하였습니다. Flutter의 외관을 그리는 Dart 코드는 렌더링을 위해 Skia를 사용하는 native 코드로 컴파일 됩니다. Flutter는 개발자가 새로운 안드로이드 버전으로 폰을 업데이트하지 않았더라도 가장 최신의 향상된 성능으로 앱을 업그레이드 할 수 있게 하면서, 엔진의 일부로 자신의 Skia의 복제를 끼워 넣습니다. iOS, Windoes 또는 macOS 와 같은 다른 네이티프 플렛폼에서의 Flutter도 마찬가지 입니다.

From user input to the GPU

렌더링 파이프라인에서 Flutter가 중요하게 생각하는 원칙은 simple is fast 입니다. Flutter는 다음 다이어그램이 보여주듯이, 데이터가 시스템으로 어떻게 흐르는지 결정하는 간단한 파이프라인을 가지고 있습니다.

각 단계를 자세히 둘러봅시다.

Build: from Widget to Element

위젯의 계층을 보여주는 다음 코드 조각을 살펴봅시다.

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

Flutter가 이 코드를 랜더링 할 때 현재 앱의 상태를 기반으로 랜더링하는 위젯들의 서브 트리를 반환하는 build() 메서드를 호출합니다. 이 과정에서, build() 메서드는 필요하다면 현재의 상태를 기반으로 새로운 위젯을 생성합니다. 예를 들면 위의 코드에서, Containercolorchild 속성을 가집니다. Containersource code에서 볼 수 있듯이, color 속성이 null이 아니라면, color를 나타내는 ColoredBox를 삽입합니다.

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

비슷하게, ImageText 위젯은 build 과정에서 RawImageRichText 같은 자식 위젯을 삽입합니다. 아래 사진처럼, 마지막 위젯 계층은 코드로 나타는 것 보다 깊습니다.

이것은 왜 디버그 툴인 Flutter inspector 같은 Dart DevTools로 트리를 살펴봤을 때, 원래 코드보다 트리의 구조가 훨씬 싶은지 설명해 줍니다.

 

build 단계에서 Flutter는 코드로 표현되어 있는 위젯들을 대응하는 element tree로 변환합니다. 그리고 각 위젯들은 하나의 element를 가집니다. 각 element는 트리 계층에서 주어진 위치에 있는 위젯의 구체적인 인스턴스를 나타냅니다. Element에는 2가지 기본 타입이 있습니다. (element란, 트리에서의 특정 위치에서 위젯의 인스턴스, Widget.createElement로 생성, mount로 트리에 삽입, 이 시점에 화면에 보임, 그리고 업데이트 될 때 프레임워크가 update 호출, 이전의 위젯과 같은 runtimeType과 key를 가지므로 식별 가능, 트리에서 해당 element를 제거하고 싶다면 조상이 deactivateChild 호출, 그러면 해당 element는 deactivate를 호출하며 owner의 inactive elements list로 들어가게 됨, 이 다음 다시 복귀하면 activate 호출, 끝내 복귀하지 못하면 프레임워크에서 unmount 호출, 이 시점에서 element는 사라졌다고 판단)

widget과 element의 차이점

  • ComponentElement, 다른 elements들을 호스트합니다.
  • RenderObjectElement, layout이나 paint 단계에 참여하는 element 입니다.

RenderObjectElement는 위젯 아날로그와 내제된 RenderObject의 중개 역할을 합니다. 이건 나중에 다루겠습니다.

 

위젯을 위한 element는 트리에서 위젯의 위치를 알려주는 BuildContext를 통해 참조할 수 있습니다. 이 BuildContext는 Theme.of(context) 함수 호출에 쓰이는 context와 같은 것이고, build() 메서드의 매개변수로 전달됩니다.

 

위젯들은 불변하기 때문에 노드들 사이의 부모/자식 사이를 포함한 위젯 트리의 모든 변화들은 새로운 위젯 객체들의 셋을 반환하게 합니다. 하지만 그것이 본래 외관을 반드시 다시 빌드해야 한다는 뜻은 아닙니다. Element 트리는 프레임 사이에 지속되고, Flutter가 본래 외관을 캐싱하는 동안 위젯 계층이 완전히 처리가능한 것처럼 행동 할 수 있게 하면서 성능에 매우 중요한 역할을 합니다. 변화한 위젯들만 처리하므로써, Flutter는 재구성이 필요한 element의 부분만을 다시 빌드하면 됩니다.

 

Layout and rendering

하나의 위젯만을 가지는 어플리케이션은 드뭅니다. UI 프레임워크에서 중요한 능력중 하나는 각 element들이 화면에 렌더링 되기 전에 사이즈와 위치를 결정하는 위젯들의 계층을 배열하는 과정을 효율적으로 처리하는 것 입니다.

 

렌더 트리에 있는 모든 노드들의 베이스 클래스는 layout과 painting을 위한 추상 모델을 정의하는 RenderObject 입니다. 위젯들이 고정되지 않는 수의 차원들이나, 데카르트 좌표계로 커밋되는 일은 매우 흔합니다. 각 RenderObject는 스스로의 부모에 대해서는 잘 알지만, 자식에 대해서는 어떻게 방문하는지, 제약이 어떻게 되는지를 제외하면 잘 알지 못합니다.(Constraints go down, Sizes go up) 이는 다양한 케이스를 처리해야하는 RenderObject에게 충분한 추상화를 제공합니다.

 

build 단계에서, Flutter는 Element 트리의 각 RenderObjectElement에 대해 RenderObject을 상속하는 객체를 만들거나 업데이트 합니다. RenderObject는 primitive 입니다:  RenderParagraph는 텍스트를, RenderImage는 이미지를 렌더링하고 RenderTransform는 자식을 painting하기 전에 transformation을 적용합니다.

Flutter의 위젯들은 대부분 2D 직교 좌표계의 RenderObject를 나타내는 RenderBox 서브클래스를 상속하는 객체들에 의해 랜더링 됩니다. RenderBox는 렌더링 될 각 위젯의 너비, 높이의 최소, 최대값을 정하는 box constraint model의 기본을 제공합니다.

 

레이아웃 배치를 수행하기위해, Flutter는 렌더트리를 깊이 우선으로 순회하고 사이즈 제약을 부모에서 자식으로 전파합니다.

사이즈를 결정하는 데 있어서, 자식은 반드시 부모에게서 주어지는 제약을 따라야만 합니다. 자식들은 부모가 만든 제약 안에서 사이즈를 결정하여 그들의 부모들에게 넘겨줍니다.

이 한 번의 트리 탐색의 끝에서, 모든 객체들은 부모의 제약 안에서 정의된 사이즈를 가지고, paint() 메서드로 paint 될 준비를 마칩니다.

 

박스 제한 모델은 O(n)의 시간복잡도로 레이아웃 배치를 할 수 있는 방법으로 매우 강력합니다.

  • 부모는 최대, 최소 제한값을 같은 값으로 설정하므로써, 자식 객체의 사이즈를 사용할 수 있습니다.
  • 부모는 자식의 너비를 받으면서, 높이에 신축성을 부여할 수 있습니다.(높이만 주고, 너비에 신축성을 부여하는 것도 가능). 실제 예로는 flow text가 있는데, 수평 제한은 맞춰야 하지만, 수직으로는 텍스트의 양에 따라 결정됩니다.

이런 모델은 자식 객체가 컨텐츠를 렌더링하는 방법을 결정하기 위해 사용할 수 있는 공간을 알아야하는 경우에도 효과적입니다. LayoutBuilder 위젯을 사용하므로써, 자식 객체는 passed-down 제한을 알 수 있고, 이런 제한 조건을 사용하여 그런 제한을 어떻게 사용할지 결정합니다.

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

constraint와 layout 시스템에 대해 더 자세히 알고 싶다면, Understanding constraints 토픽을 참고하세요.

 

모든 RenderObject의 루트는 렌더 트리의 전체 아웃풋을 나타내는 RenderView 입니다. 플랫폼이 새로운 프레임을 렌더링 해야할 때(예를 들면, vsync 때문에 혹은 texture decompression/upload가 완료 됐을 때), 렌더 트리의 루트에 있는 RenderView 객체의 일부인 compositeFrame() 메서드가 호출됩니다. 이 함수는 scene의 업데이트를 발생시키기위해 SceneBuilder를 생성합니다. scene이 완료되면, RenderView 객체는 결합된 sence을 dart:ui에 존재하며 렌더링을 위한 제어를 GPU로 넘겨주는 Window.render()로 넘겨줍니다.

 

이 글에서는 컴포지션과 레스터라이제이션 단계를 다루지는 않습니다. 해당 링크를 통해 렌더링 파이프라인에 대한 더 많은 정보를 얻을 수 있습니다.

Comments