플러터(Flutter) 렌더링 메커니즘

Kyeonghwan Kong
18 min readDec 3, 2023

--

Flutter 렌더링 메커니즘에 대한 내용을 공유합니다. Flutter 문서와 알리바바 클라우드 커뮤니티 블로그에 있는 “Exploration of the Flutter Rendering Mechanism from Architecture to Source Code”를 기반으로 설명합니다. 알리바바에서 소개된 내용은 Google TechTalks에 소개된 “Flutter’s Rendering” 영상과 여러 자료를 기반으로 작성됐습니다. 그렇기에 Flutter 렌더링 메커니즘을 이해하기에 최적의 자료라 생각합니다.

렌더링 메커니즘 이해에 대한 중요성

Flutter는 UI 렌더링에 특화된 프레임워크입니다. Flutter 개발자로서, 렌더링 메커니즘을 이해하는 것은 다음과 같은 이점이 있을 수 있습니다.

  • 성능 최적화에 따른 사용자 경험 개선
  • 문제 해결 능력 강화

Flutter의 렌더링 메커니즘을 이해함으로써, 개발자는 애플리케이션의 성능을 향상하는 방법을 파악할 수 있습니다. 예를 들어, 불필요한 빌드와 레이아웃 계산을 피하고, 효율적인 Widget 트리 관리를 통해 애플리케이션의 프레임 속도를 높일 수 있습니다.

렌더링 메커니즘의 이해는 퍼포먼스 이슈나 UI 버그를 분석하고 해결하는 데 필수적입니다. 특히 복잡한 UI 구조에서 발생할 수 있는 문제를 효과적으로 파악하고 대응할 수 있습니다.

렌더링 아키텍처 분석

Flutter 렌더링 파이프라인 (출처: docs.flutter.dev)

Flutter 공식 문서에는 위와 같은 흐름의 다이어그램에 표시된 것처럼 렌더링 흐름에 대한 간단한 파이프라인이 있습니다. 이 흐름은 아주 단순하게 표현됐습니다. 앞으로 이 7단계에 대한 프로세스와, 각각의 상세한 메커니즘을 소개합니다. 위 프로세스를 구체적으로 나타내면 다음과 같은 아키텍처를 갖게 됩니다.

렌더링 아키텍처 (출처: alibabacloud blog)

아키텍처 디자인

UI 스레드와 GPU 스레드로 분리되어 실행되는 것을 알 수 있습니다. 이 두 스레드가 Flutter 렌더링 작업을 하게 됩니다. 먼저 UI 스레드의 각 단계는 다음과 같습니다.

  • 애니메이션 (Animation)
  • 빌드 (Build)
  • 레이아웃 (Layout)
  • 페인트 (Paint)
  • 제출 (Submit)

UI 스레드는 5단계의 프로세스가 포함됩니다. 이 프로세스들은 특히 Dart 코드가 실행되는 단계로 Flutter 프레임워크 코드와 개발자가 작성한 코드가 실행되는 단계입니다. 이 과정에서 Widget 트리, Element 트리, RenderObject 트리가 생성되고 최종적으로 Layer 트리를 생성하게 됩니다. Layer 트리는 합성 프로세스에서 GPU 스레드로 제출(Submit)하게 됩니다. 공식 문서에서는 이 과정을 합성(Composition)이라고 하지만, 합성하는 과정이라고 보기 어렵습니다. Layer 트리를 GPU 스레드에 제출하는 과정만 있기 때문에, 제출 프로세스로 부르는게 더 적절합니다.

GPU 스레드는 래스터(Rasterization)와 합성(Composition) 프로세스가 있습니다. UI 스레드에서 전달받은 Layer 트리를 갖고 있는데, Layer 트리에는 ‘어떻게’ 그릴지에 대한 정보를 최적화한 데이터를 가지고 있으며, 래스터 과정과 합성을 통해 픽셀로 변환하고 각 Layer 를 오버레이(Overlay) 하는 과정을 거칩니다. 그리고 Flutter의 Skia와 같은 엔진을 통해 GL, Vulkan과 같은 GPU API를 활용하여 디스플레이에 픽셀이 그려지게 됩니다.

프로세스 디자인

렌더링은 Vsync 신호에 의해 동작합니다. Vsync란 주기적으로 렌더링 신호를 보내는 메커니즘을 말합니다. 이제 Vsync 프로세스를 Android 기준으로 설명하겠습니다.

Flutter는 먼저 Android 시스템에 Vsync 신호를 받을 콜백을 등록하게 됩니다. 그리고 신호를 기다립니다. 그리고 신호를 수신하면 Flutter는 C++ 엔진 및 Java 엔진을 통해 Dart 프레임워크에서 렌더링 프로세스를 진행합니다. 그리고 UI 스레드의 각 프로세스를 수행합니다. 그리고 Layer 트리를 생성하고 GPU 스레드에서 Layer 트리를 통해 래스터 과정과 합성을 거칩니다. 요약하면 다음과 같습니다.

이제 이 과정들을 4개의 시퀀셜 다이어그램으로 나누어 살펴보겠습니다.

  1. Vsync 신호에 대한 콜백을 등록하고 대기
  2. Vsync 신호를 수신하면, Flutter 엔진을 통해 Dart 코드 실행
  3. Dart 코드는 UI 스레드 렌더링 로직이며, 이 과정에서 Layer 트리 생성을 한다. 그리고 GPU 스레드에 제출한다.
  4. GPU 스레드가 Layer 트리를 전달받으면 래스터 과정과 합성과정을 거쳐 디스플레이에 표시하게 된다.

1. Vsync 신호에 대한 콜백을 등록하고 대기

Vsync 신호를 등록하는 과정에 대한 시퀀셜 다이어그램 (출처: alibabacloud blog)

Flutter 엔진이 시작되면 Android 시스템 Choreographer에 Vsync 신호를 기다리는 콜백이 등록되고 신호를 수신합니다. Choreographer는 Android에서 Vsync 신호를 관리하는 클래스 입니다.

Vsync는 보통 운영체제와 하드웨어 디스플레이 리프레쉬 사이클에 의해 관리되며 일정한 주기로 화면을 갱신하도록 신호를 보내는데, 이 신호 주기에 문제가 있거나 주기에 맞춰 그리기 작업을 완료하지 못하면 프레임이 떨어지거나 끊기는 현상이 발생하게 됩니다.

2. Vsync 신호를 수신하면, Flutter 엔진을 통해 Dart 코드 실행

Vsync 수신 후 Dart 실행 이전까지의 시퀀셜 다이어그램 (출처: alibabacloud blog)

Flutter가 Vsync 신호를 수신하게 되면, Flutter에 등록된VsyncWaiter::fireCallback() 콜백이 호출됩니다. 그리고 Animator::BeginFrame()을 시작으로 Window::BeginFrame() 함수가 호출됩니다.

window.cc 인스턴스는 기본 엔진과 Dart 사이의 중요한 다리 역할을 합니다. 입력 이벤트, 렌더링 작업, 접근성 작업 등 대부분의 플랫폼 관련 작업을 수행합니다.

3. Dart 코드는 UI 스레드 렌더링 로직이며, 이 과정에서 Layer 트리 생성을 한다. 그리고 GPU 스레드에 제출한다.

UI 스레드 시퀀셜 다이어그램 (출처: alibabacloud blog)

그런 다음 Window::BeginFrame()가 호출되고 RenderBinding 클래스에서 RenderBinding::drawFrame() 함수가 호출됩니다. 이 함수는 Widget 트리의 더티 노드, 즉 다시 그려야 하는 노드를 중계하고 다시 그립니다. 이 부분은 뒤에서 좀 더 자세히 설명합니다.

4. GPU 스레드가 Layer 트리를 전달받으면 래스터 과정과 합성과정을 거쳐 디스플레이에 표시하게 된다.

GPU 스레드의 작업에 대한 시퀀셜 다이어그램 (출처: alibabacloud blog)

UI 스레드의 렌더링 과정을 거쳐 그리기 지침을 Layer 트리에 저장합니다. 그런 다음 GPU 스레드는 Layer 트리를 가지고 래스터 과정과 합성을 거쳐 화면에 표시합니다.

마지막으로 GPU 스레드는 GPU API를 통해 화면에 데이터를 표시하고 다음 Vsync 신호를 수신하도록 Animator::RequestFrame()을 호출합니다. 이런 방식으로 UI를 지속해서 업데이트할 수 있습니다. 최종적으로 아래와 같은 사이클이 이뤄지게 됩니다.

렌더링 사이클 (출처: alibabacloud blog)

렌더링 상세 메커니즘

앞서 설명한 시퀀셜 다이어그램을 통해 각 렌더링 파이프라인 단계에서 UI 스레드와 GPU 스레드의 흐름을 자세히 파악해보고 상세 메커니즘을 확인하겠습니다.

  • Animation
  • Build
  • Layout
  • Compositing Bits
  • Paint
  • Submit (Compositing)
  • Rasterization and Compositing

애니메이션 (Animation)

이 프로세스는 handleBeginFrame()에서 transientCallbacks 콜백 메소드에 의해 트리거 됩니다. 만약 애니메이션이 없으면 콜백은 null을 반환합니다. 애니메이션이 있는 경우 애니메이션 Ticker.tick() Widget을 트리거하여 다음 프레임의 값을 업데이트 하도록 콜백합니다.

Animation 단계에서의 시퀀셜 다이어그램 (출처: alibabacloud blog)
void handleBeginFrame(Duration rawTimeStamp) {
...
try {
// TRANSIENT FRAME CALLBACKS
Timeline.startSync('Animate', arguments: timelineWhitelistArguments);
_schedulerPhase = SchedulerPhase.transientCallbacks;
final Map<int, _FrameCallbackEntry> callbacks = _transientCallbacks;
_transientCallbacks = <int, _FrameCallbackEntry>{};
callbacks.forEach((int id, _FrameCallbackEntry callbackEntry) {
if (! _removedIds.contains(id))
_invokeFrameCallback(callbackEntry.callback, _currentFrameTimeStamp, callbackEntry.debugStack);
});
...
} finally {
...
}
}

코드를 보겠습니다. handleBeginFrame() 에서 transientCallbacks 을 찾아 콜백하는 코드입니다. 이 콜백은 특정 이벤트에 생성되고 사라지도록 구현되어 있으며, if (! _removedIds.contains(id)) 코드를 통해 삭제되지 않은 경우에 invoke하게 됩니다.

이 콜백외에도 중요한 콜백이 있습니다. transientCallbacks 을 포함하여 아래 3가지 콜백 함수를 기억하면 좋습니다.

  • persistentCallbacks
  • transientCallbacks
  • postFrameCallbacks

먼저 persistentCallbacks는 매 프레임 일관되게 호출되는 콜백이며 Vsync에 직접적인 영향을 받습니다. 주로 렌더링과 관련된 작업을 수행하게 됩니다. transientCallbacks 와 다르게 거의 영구적으로 사용되는 콜백입니다.

transientCallbacks은 앞서 말씀드렸듯이, 애니메이션과 같은 특정 조건이나 이벤트에 의해 일시적으로 추가되고, 실행된 후에는 제거되는 콜백입니다. 특정상황에서만 필요하며, 일시적으로 프레임 처리 과정에 영향을 줍니다. persistentCallbacks 는 다르게 일시적으로 추가되고 Ticker에 의해 프레임을 처리하므로 Vsync에 직접적인 관계는 없습니다.

postFrameCallbacks는 UI 스레드의 작업이 완료된 후에 실행되며 개발자가 화면 업데이트 후 필요한 추가적인 작업을 수행합니다. 예를 들어, UI 업데이트 후에 특정 데이터를 로드하거나 다음 프레임의 준비 작업 등을 할 수 있습니다.

이 3개의 콜백은 렌더링 메커니즘의 심장과도 같으며 주기적으로 렌더링을 시작하게 해줍니다.

다시 돌아가서 이후 프로세스를 설명하면,handleBeginFrame() 이 종료되고 handleDrawFrame()이 호출되어 다음 콜백을 트리거합니다. 그리고 WidgetBinder::drawFrame() 함수가 호출되며 렌더링 작업을 수행합니다. 이후 BuildeOwner.buildScope() 를 호출하여 Widget의 빌드를 수행합니다.

빌드 (Build)

이 프로세스는 handleDrawFrame() 을 통해 트리거 됩니다. 그리고 BuildOwner::buildScope() 가 호출되는데, 이 함수는 다음 두 시점에서 트리거 됩니다.

  • Widget 트리 구축
  • Widget 트리 업데이트

트리의 구축은 scheduleAttatchRootWidget() 에 의해 구축됩니다. 이 함수는 runApp() 이 실행되면 호출되는 함수이며, 앱 실행 시 Vsync 신호를 받아 동작하게 됩니다. 그리고 작성된 트리는 다시 작성하지 않고 더티 영역만 업데이트 하게 됩니다.

Build 단계에서의 시퀀셜 다이어그램 (출처: alibabacloud blog)

이 과정에서 총 3가지 트리가 생성됩니다. 먼저 개발자가 정의하는 build() 함수를 통해 Widget 트리가 형성됩니다. Widget 트리는 Widget의 구조와 레이아웃등 구성을 정의하지만, 실제 화면에 어떻게 그려질지에 대한 정보는 포함하지 않습니다.

Element 트리는 Widget에서 createElement() 함수로 형성되며 각각의 Element 는 각 Widget의 인스턴스에 해당하게 됩니다. Widget의 상태를 관리하고, Widget 트리의 변화를 추적하는 역할을 합니다. 또한, Widget보다 더 긴 수명주기를 갖으며 Widget의 생명주기를 관리합니다.

다음은 RenderObject 트리입니다. 이 트리는 Element 트리에서 파생되며 실제 화면에 그려지는 방식을 결정하게 됩니다. Build 단계에서는 아무런 정보가 존재하지 않지만, Layout과 Paint 프로세스를 통해 각각 Widget의 크기와 위치를 결정하고, 시각적 표현을 결정하게 됩니다.

레이아웃 (Layout)

이 프로세스는 PipelineOwner::flushLayout() 을 통해 트리거 됩니다. Widget의 크기와 위치를 결정하고 그 정보를 RenderObject 트리에 저장하여 어디에 얼만큼의 크기로 위치해야 하는지 결정하게 됩니다.

레이아웃 프로세스 (출처: alibabacloud blog)

RenderObject는 UI 가 어떻게 표시되어야 하는지 결정하는 프로토콜이나 메커니즘을 제공합니다. 이런 RenderObject는 RenderBox를 상속하여 구현되며, RenderBox에는 다양한 레이아웃 알고리즘의 하위 클래스가 있습니다.

  • RenderFlex
  • RenderStack 등

Sizing 메커니즘에 대해 알아보겠습니다. 먼저 깊이 우선 탐색을 통해 하위 노드를 모두 순회합니다. 상위 노드는 하위 노드에 크기에 대한 제약사항을 전달하고 하위 노드는 제약사항을 가지고 크기를 계산하여 상위 노드에 전달하게 됩니다. 제약사항은 BoxConstraint 및 SliverConstraints와 같은 클래스를 이용해 하위 노드에 전달됩니다.

추가로 LayoutBoundary 개념을 짚고 넘어가겠습니다. LayoutBoundary는 레이아웃의 재계산 범위를 제한하는 데 사용됩니다. LayoutBoundary 내의 Widget 크기가 변경되어도, 이 변경이 부모 노드에 영향을 주지 않도록 합니다. 이를 통해 레이아웃 재계산이 필요한 범위를 제한하고 성능을 최적화합니다.

비트 합성 (Compositing Bits)

이 프로세스는 PipelineOwner::flushCompositingBits() 를 통해 트리거됩니다. 레이아웃 프로세스가 종료되고 페인트 프로세스 전에 실행됩니다. Flutter 공식 문서에는 분리가 되어있지 않지만, 이 프로세스를 통해 RenderObject를 다시 그려야 하는지 여부를 확인하고 RenderObject의 needCopmositing 을 업데이트 합니다. 이 값이 true면 다시 그려야 합니다.

페인트 (Paint)

이 프로세스는 PipeOwner.flushPaint() 를 통해 트리거 됩니다. 이 과정에서 그리기 작업량을 줄이고 성능을 향상시키기 위해 UI를 여러 그래픽 Layer로 나누고 각 Layer를 어떻게 그려야 하는지에 대한 지침을 결정하게 됩니다.

그리고 이 프로세스에서 ReanderObject 트리를 기반으로 Layer 트리가 생성됩니다. 그리고 각 RenderObject는 실제로 화면에 그려질 방식을 정하게 됩니다. 색상, 형태, 텍스처 등의 시각적 속성을 포함합니다. 그리고 RenderObject는 각각의 그리기 명령을 Layer 트리에 추가합니다. 이 Layer 트리는 그리기 명령의 집합으로 최종적으로 화면에 표시될 내용을 포함합니다.

페인트 프로세스 (출처: alibabacloud blog)

페인트 프로세스도 깊이 우선 탐색을 합니다. 레이아웃 프로세스와 마찬가지로 위 아래로 깊게 탐색되며 노드를 재귀적으로 순회합니다. 순회 과정에서 저장될 각 하위 노드의 Layer 와 페인트 명령이 결정됩니다.

RepaintBoundary 메커니즘은 개발자에게 그래픽 Layer 분할 기능을 제공합니다. 개발자는 이 기능을 사용하여 페에지의 어느 부분을 다시 그릴 필요가 없는지 결정할 수 있습니다. 이렇게 하면 전체 페이지의 전반적인 성능이 향상됩니다. 경계를 다시 그리면 최종 Layer 트리 구조가 변경되게 됩니다.

그림과 같이 바운더리를 통해 Layer 가 분리되어 자체 Layer 를 갖게 됩니다. 자체 Layer 를 갖는다는 것은 독립적으로 렌더링 될 수 있다는 것을 의미합니다.

제출 (Submit)

이 프로세스는 renderView.compositeFrame() 을 통해 트리거 됩니다. 공식적으로는 합성이라고 하지만, 이 단계는 Layer 트리를 GPU 스레드에 제출하는 역할을 하기 때문에 제출(Submit) 프로세스로 부르는 것이 더 적절하다고 생각합니다.

void compositeFrame() {
Timeline.startSync('Compositing', arguments: timelineArgumentsIndicatingLandmarkEvent);
try {
final ui.SceneBuilder builder = ui.SceneBuilder();
final ui.Scene scene = layer.buildScene(builder);
if (automaticSystemUiAdjustment)
_updateSystemChrome();
_window.render(scene);
scene.dispose();
assert(() {
if (debugRepaintRainbowEnabled || debugRepaintTextRainbowEnabled)
debugCurrentRepaintColor = debugCurrentRepaintColor.withHue((debugCurrentRepaintColor.hue + 2.0) % 360.0);
return true;
}());
} finally {
Timeline.finishSync();
}
}

위 코드는 compositeFrame() 의 실제 코드입니다. SceneBuilder를 통해 각 Layer의 Scene을 빌드하고, _window.render() 함수를 통해 GPU 스레드에 제출하게 됩니다.

Rasterization and Compositing

GPU 스레드의 작업에 대한 시퀀셜 다이어그램 (출처: alibabacloud blog)

렌더링 지침이 포함된 Layer 트리를 통해 래스터 및 합성을 수행하게 됩니다. 래스터는 그리기 명령을 픽셀 데이터 변환하는 것을 말합니다. 합성은 각 Layer를 오버레이 하는 것을 말합니다.

합성에 대해 예로 설명하자면, 투명도를 갖는 여러 Layer 를 특정 위치에 표현해야할 때, 합성 과정을 통해 각 Layer 들이 겹쳐치고 표현될 수 있도록 합니다.

래스터 전략은 여러가지가 존재합니다. Flutter는 동기식 래스터화 전략을 채택하고 주로 직접 래스터화를 사용하고 있습니다. 동기식 래스터화는 단일 스레드로 동작하며 이 과정에 순서를 보장하는 전략입니다.

위에 그림에서 눈 여겨볼 함수들은 다음과 같습니다.

  • Rasterizer::DoDraw()ScopedFrame::Raster() 를 통해 래스터 과정의 핵심 구현 함수를 호출합니다.
  • LayerTree::Preroll() : 그리기 준비를 합니다.
  • LayerTree:Paint() : 중첩 모드에서 다양한 Layer의 그리기 방법을 호출합니다.
  • SkCanvas::Flush() : 데이터를 GPU로 플러시 합니다.
  • AndroidContextGL::SwapBuffers() : 표시를 위해 프레임 버퍼를 모니터에 제출합니다.

--

--