Embedder
Engine
Framework
Flutter는 반응형&선언형 프레임워크이며, 개발자가 application state와 interface state를 매핑하면, 프레임워크는 application state가 변경되었을 때, runtime에 interface state를 업데이트 한다.
기존 UI프레임워크에서는 interface state의 초기값이 한 번 표시된 다음, 이벤트에 응답(response)하여 runtime에 user code에 인해 별도로 업데이트 되었다.
문제는 application이 복잡해질수록 개발자는 state의 변화가 UI 전체적으로 어떻게 진행되는지 인지해야 했다.
위 그림에서 state값은 여러 곳에서 변경 가능 (color box, hue slider, radio button 등)
사용자가 UI로 값을 변경할 때, 변경사항은 다른 모든 구역에도 반영되어야하며 최악의 경우 아주 작은 user interface 하나의 변경만으로도 넓은 범위의 관련 없는 코드에 영향을 줄 수 있다.
한 가지 해결법으로 MVC를 들고 올 수 있다. Model View Controller는 소프트웨어가 서비스하는 방식에 대한 패턴으로 controller를 통해 model에 데이터 변경을 요청하고, model에서는 새로운 state를 controller를 통해 view에 전달한다. 하지만 UI요소들의 생성과 변경하는 과정은 여전히 별개의 두 단계이기에 문제가 될 수 있다.
Flutter는 위와는 다른 방식으로 문제해결에 접근하며, user interface와 state를 분리하는 방식으로 해결하였다. React-style API들로 UI 설명(description)만 만들고 프레임워크는 하나의 구성을 사용하여 user interface를 생성 혹은 업데이트 한다.
Flutter에서 widget(React의 component)은 불변의 class들로 객체로 이루어진 트리를 구성하기 위해 사용된다. 이러한 widget들은 레이아웃을 위한 각각의 객체 트리를 관리 및 컴포지팅(compositing)하기 위해 사용된다. Flutter는 코어에서 다양한 방식으로 객체 트리를 lower-level 객체트리로 효율적이게 변환시키고, 변경점을 전파(propagate)시킨다.
Widget은 user interface를 build 메서드를 이용하여 선언하는데, 이 메서드는 state를 UI로 바꿔주는 함수다.
UI = build(state)
build 메서드는 실행속도가 빠르고, 프레임워크에 의해 불렸을 때(frame이 render 될때마다 불리는 빈도수) 부작용이 없도록 설계되었다.
이런 접근은 언어의 runtime의 특정 특징에 의존적(특히 객체의 빠른 인스턴스화와 삭제)이며, Dart는 알맞게도 이러한 특징을 갖는다.
Flutter는 화면을 구성하는 하나의 단위로 widget을 강조한다. Widget은 Flutter app의 user interface의 기본적인 building block이며, 각 Widget은 user interface의 일부로서 불변적으로 선언된다.
Widget은 구성을 기반으로한 계층을 형성한다. 각 Widget은 부모를 갖고, 부모로부터 context를 제공받을 수 있다. 이 구조는 root widget(flutter app의 중심(host)이 되는 container로 보통 MaterialApp(Android) 혹은 CupertinoApp(iOS))까지 연결된다.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('My Home Page'),
),
body: Center(
child: Builder(
builder: (context) {
return Column(
children: [
const Text('Hello World'),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
print('Click!');
},
child: const Text('A button'),
),
],
);
},
),
),
),
);
}
}
위의 코드에서 모든 인스턴스화된 클래스들은 widget이다.
App은 user interface를 event(user interaction 등)가 발생 시, 프레임워크에 widget의 계층을 다른 widget으로 변경하도록 알려서 업데이트한다. 프레임워크는 이후 new, old widget을 비교하여 효율적으로 변경된 user interface를 업데이트한다.
Flutter는 시스템이 제공하는 UI control을 사용하기 보다 자체적으로 구현된 UI control을 사용한다. 예시로 시스템이 제공하는 iOS Switch control 혹은 Android Switch control 보다는 dart로 구현된 Switch control을 사용한다.
이러한 접근의 장점
Widget들은 다양한 작고, 단일목적의 Widget들로 구성되며, 조합되면 강력한 효과를 보인다. Design concept들을 최소한으로 유지하여 어휘(vocabulary)가 최대한 다양하도록 한다.
예시로,
Class의 계층은 각각 하나의 작업을 잘 수행하는 작고 구성 가능한 widget에 초점을 두어 가능한 조합의 최대화를 위해 얕고 넓다. Core 기능들은 추상적이며, padding, alignment같은 기본적인 기능들마저 core에 내장하지 않고 별도의 component들로 분리 구현된다. (이러한 부분은 padding과 같은 기능이 모든 레이아웃 구성 요소의 공통 core에 내장된 전통적인 API와는 대조됨) 예시로, widget을 가운데에 배치하려면, Align property를 이용하기보단, Center widget으로 감싸면 된다.
padding, alignment, rows, columns, grids 등 다양한 widget이 존재한다. 이런 layout widget들은 화면에 보여지지 않고, 다른 widget의 layout을 control 하는 것이 목적이다. Flutter는 또한 utility widget을 추가하여 이러한 구성 접근 방식(compositional approach)에 이용한다.
예시로, Container는 자주 사용되는 widget으로 layout, painting, positioning, sizing 등 다양한 기능을 담당하는 widget으로 구성된다. 특히 Container는 LimitedBox, ConstrainedBox, Align, Padding, DecoratedBox, Transform widget들로 구성되며, Container widget을 구현한 코드를 보면 알 수 있다. 이처럼 Flutter의 대표적인 특징 중 하나로 widget이 어떻게 구현되었는지 코드를 타고 들어가 볼 수 있고, customized effect를 구현하기 위해 일반적인 방식처럼 Container의 Subclass를 구현하기보단 새로운 방식의 다양한 widget을 조합하여 구현할 수 있다.
Widget의 시각적 표현(UI)을 지정할 땐, build 함수를 오버라이딩하여 새로운 element 트리를 return한다. 이 트리는 user interface의 widget 부분을 보다 구체적으로 나타낸다. 예시로 toolbar widget은 text의 horizontal layout과 다양한 button들을 return 하는 build 함수를 갖는다. 프레임워크는 재귀적으로 각 widget에 build 함수를 호출하여 tree 전체가 구체적인 렌더링 가능한 객체(concrete renderable objects)들로 구성될 때까지 반복한다.
Widget의 build함수는 부작용이 없어야한다. 어떠한 함수의 build 요청이 왔을 때, widget은 이전의 widget이 반환한 것과 관계없이 새로운 widget tree를 반환한다(이 때 통합할 새 구성이 있는 경우에만 새로운 트리를 반환하면 되며, 구성이 동일한 경우 동일한 widget을 반환 가능). 여기서 프레임워크는 render object tree를 기반으로 호출해야하는 build 메서드를 결정하는 어려운 작업을 대신 해준다.
#linear-reconciliation
각각의 렌더된 프레임에서 Flutter는 state가 변경된 widget의 build 메서드를 호출하여 UI부분을 재생성한다. 그러므로 build 메서드 호출 시 빠르게 반환되어야 하며, 무거운 계산 작업은 비동기 방식으로 수행되어 build 메서드에 의해 사용되는 state의 일부로 저장되어야한다.
비교적 단순한 접근이지만, 이러한 자동화된 비교는 매우 효과적이고 고성능, 대화형(interactive) App이 가능하도록 한다. 또한 build 함수의 설계로 user interface의 state를 업데이트하는 복잡한 작업 대신 widget이 무엇으로 만들어졌는지 선언하는데 초점을 두어 코드를 단순화한다.
프레임워크는 대표적인 두 가지 widget의 class를 지님 (stateful, stateless)
많은 widget들은 변경가능한 state를 갖지 않는다. 이들은 시간이 지남에 따라 변경되는 요소(icon, label등)들을 갖지 않는다. 이러한 widget들은 StatelessWidget의 subclass이다.
그러나 user interaction이나 다른 요인에 의해 widget의 고유한 특성을 변경가능한 경우 이를 widget이 stateful하다고 한다. 예시로 widget이 사용자에 의해 버튼이 눌림에 따라 숫자를 증가시키는 counter를 갖는다 했을 때, counter가 갖는 값은 widget의 state가 된다. 값이 변경될 때, widget은 UI의 일부분(값이 보여지는 부분)을 업데이트하기 위해 rebuild되어야 한다. 이러한 widget들은 StatefulWidget의 subclass이며, widget 자체는 불변이기에 변경가능한 state는 State의 subclass인 별도의 class에 저장한다. StatefulWidget은 build 메서드를 갖지 않으며, user interface는 별도의 State 객체에 의해 만들어진다.
State 객체를 변경하는 경우(counter를 증가시킴 등) 반드시 setState()를 호출하여 프레임워크가 State의 build 메서드를 재 호출하여 user interface를 업데이트 할 수 있도록 알린다.
각각의 state 객체와 widget객체를 갖는 것은 다른 widget들이 stateless, stateful widget을 state 손실에 대한 걱정없이 동일한 방식으로 처리하도록 한다. Child가 state를 보존할 수 있도록 기다리는 대신 Parent는 새로운 Child 인스턴스를 자유롭게 생성하여 child의 state를 잃지 않도록 한다. 필요한 상황에 따라 프레임워크가 존재하는 state의 조회, 재사용 등의 작업을 진행한다.
타 Class와 마찬가지로 widget 내부의 constructor를 이용해서 data를 초기화 할 수 있으며, build 메서드는 child widget이 필요로하는 data값으로 인스턴스화가 가능함을 알 수 있다.
@override
Widget build(BuildContext context) {
return ContentWidget(importantState);
}
하지만 트리가 깊어질수록 state 정보를 위 아래 트리 계층에 전달하는 것은 보다 어렵다. 따라서 위 2종류 외에 3번째 종류의 widget type인 InheritedWidget이 등장한다. InheritedWidget은 공유 조상(shared ancestor)로부터 data를 쉽게 조회해오는 방법을 제공한다. InheritedWidget을 사용하여 아래 예시 widget tree의 공유 조상을 묶는 state widget을 생성할 수 있다.
ExamWidget 객체, GradeWidget 객체가 StudentState의 data를 필요로 할 때, 다음과 같이 접근할 수 있다.
final studentState = StudentState.of(context);
of 호출은 build context(현재 widget의 위치정보)를 가지고, StudentState type에 맞는 제일 가까운 조상을 트리에서 찾아 반환해준다. InheritedWidget은 updateShouldNotify 메서드를 제공하는데 이는 Flutter로 하여금 state 변화가 child widget의 rebuild 발생여부를 결정하게 해준다.
Flutter는 shared state를 확장성 있게 하기 위해서 InheritedWidget를 사용한다. (App 전체에 사용되는 color, style 유형과 같은 속성이 포함된 App의 시각적 테마(visual theme)가 shared state에 해당). MaterialApp의 build 메서드는 트리가 build될 때 theme을 넣기 위해 사용되며, tree의 계층이 깊어지면 widget의 of 메서드를 사용하여 관련된 theme data를 조회할 수 있다. (아래 예시처럼 공통 Theme widget을 둬서 쉽게 접근 가능하도록 함)
Container(
color: Theme.of(context).secondaryHeaderColor,
child: Text(
'Text with a background color',
style: Theme.of(context).textTheme.headline6,
),
);
이런 접근방식은 page routing 등을 제공하는 Navigator에서도 사용되며, orientation, dimensions, brightness등의 screen metrics를 제공하는 MediaQuery에서도 사용된다.
Application의 규모가 커지며 stateful widget의 생성 및 사용에 있어 더욱 향상된 state management 접근 방식은 매력적으로 변한다. 많은 Flutter app들은 InheritedWidget 주변에 wrapper를 제공하는 provider와 같은 utility package들을 사용한다. Flutter의 layered architecture는 또한 state의 변화를 UI에 반영하는 방식에 대한 다른 접근으로 flutter_hooks package를 제공하기도 한다.
rendering pipeline에 대한 소개를 다룸
Flutter는 cross-platform 프레임워크임에도 single-platform 프레임워크와 맞먹는 성능을 낼 수 있을까?
기존 Android App들이 어떻게 작동하는지 알아보자. Drawing을 위해 먼저 Android 프레임워크의 Java코드를 호출한다. Android 시스템 라이브러리들이 canvas 객체에 drawing 할 수 있는 컴포넌트들을 제공하며, 이를 Android는 Skia를 이용해 render하게 된다. (Skia는 디바이스에 drawing을 위해 CPU혹은 GPU를 호출하는 C/C++ 코드로 구현된 그래픽 엔진)
cross-platform 프레임워크들은 보통 abstraction layer를 native Android, iOS UI라이브러리들 위에 생성하여, 각 플랫폼간 표현의 불일치를 해결하려한다. App의 코드는 보통 UI를 위해 Java기반의 Android와 Objective-C기반의 iOS 시스템 라이브러리들과 호환가능한 Javascript를 사용한다. 그러나 이런 부분은 상당한 오버헤드를 발생시키며 특히, UI와 App 로직간 상호작용에 있어서 비효율적이다.
Flutter의 경우 시스템 UI widget을 호환하여 사용하는 대신 자체적인 widget을 사용하여 이러한 추상화를 최소화 한다. Flutter의 시각적 부분을 paint하는 dart code가 native code로 바로 컴파일 되고, Skia 그래픽 엔진을 렌더링에 사용한다. Flutter는 또한 자체적으로 Skia를 엔진의 일부로 사용하기에 디바이스의 Android가 버전이 아니어도 App은 최신 버전의 성능을 낼 수 있다. 동일하게 iOS, Windows, macOS와 같은 다른 native 플랫폼에서도 해당된다.
Flutter의 data pipeline
Container(
color: Colors.blue,
child: Row(
children: [
Image.network('https://www.example.com/1.png'),
const Text('A'),
],
),
);
Flutter가 위와 같이 정의 된 화면일부(fragment)를 렌더링 할 때, build 메서드를 호출하며, 이는 현재 app state값에 따라 UI를 render 할 수 있는 widget subtree를 반환한다. 이 과정에서 build 메서드는 새로운 widget을 필요에 따라 추가적으로 사용할 수 있다.
위 코드의 경우 Container는 color, child 속성(properties)이 있다. 이 때, 아래 Container의 소스코드를 보면, color != null인 경우 ColoredBox를 사용하는 것을 볼 수 있다.
if (color != null)
current = ColoredBox(color: color!, child: current);
마찬가지로 Image와 Text widget도 각 RawImage, RichText를 build process중 추가될 수 있다.
위 코드와 같은 경우 widget 트리 계층은 다음과 같이 구성된다.
따라서 Flutter inspector와 같은 debug tool로 tree를 보았을 때, 본인이 구현한 코드보다 훨씬 깊은 트리 구조가 보일 수 있다.
Build 단계에서 Flutter는 코드로 표현된 widget을 각 widget에 대해 하나의 element로 구성된 element 트리로 변환한다. 각 element는 트리 계층의 지정된 위치에 있는 widget의 특정 인스턴스를 나타낸다.
Element는 두 가지 기본 유형이 있다.
Widget의 element는 트리에서 widget의 위치를 표시하는 Buildcontext를 통해 참조될 수 있다. 이는 Theme.of(context)와 같이 함수 호출에서 context에 해당되며, build 메서드로는 인자(parameter)로 전달된다.
Widget는 트리 노드의 부모/자식 간 관계를 포함하여 불변이기에 Text('A') -> Text('B')와 같이 widget 트리가 변경되어야하는 경우 새로운 widget 객체들이 반환되어야 한다. 그러나 이는 기저에 있는 모든 것들이 rebuild 되어야하는건 아니다. Element 트리는 frame간에 지속되기에 성능에 중요한 역할을 수행하며, Flutter가 기존의 표현을 caching 하는동안 widget 계층 구조가 완전히 삭제 가능한 것처럼 동작하게 한다. 따라서 Flutter는 변경된 widget만 통해 rebuild해야 하는 element 트리 부분만 재구성할 수 있다.
App이 단일 widget만 사용 할 일은 거의 없을 것이기에, 그 어떤 UI프레임워크도 widget 계층구조를 각 element의 size, position을 명확히하고 렌더링 하기전에 효율적으로 배치하는 것은 중요할 것이다.
Render 트리 각 노드의 기본 class는 RenderObject이며, layout과 painting을 위한 추상화 모델을 정의한다. 각 RenderObject는 부모는 잘 아나 자식들에 대해서는 그들의 제약조건이나 찾는 방법만 알고있다. 이는 RenderObject가 충분한 추상화를 할 수 있게하여 다양한 방식으로 사용 되도록 한다.
Build 과정에서 Flutter는 element 트리에서 각 RenderObjectElement에 대해 RenderObject를 상속받는 객체를 생성 혹은 업데이트한다. (Element Tree -> Render Tree)
대부분의 Flutter widget들은 2차원공간 고정값(fixed size 2D Cartesian space)의 RenderObject를 표현하는 RenderBox의 subclass를 상속받는 객체에 의해 렌더링된다. RenderBox는 상자 제약 모델(box constraint model)을 따르기에, widget의 렌더링을 위한 너비(width)와 높이(height)의 최소값과 최대값을 지정해준다.
Layout을 수행하기 위해 Flutter는 render 트리를 깊이우선탐색(depth-first traversal)하며 크기제약사항(size constraints)을 부모에서 자식으로 전달한다. 정해진 크기에 따라 자식은 반드시 부모가 제공해준 제약사항을 준수한다. 자식은 부모 객체가 정해준 크기제약사항 내의 자신의 크기를 부모 객체에 전달하여 응답한다.
트리를 한바퀴(single walk through) 돌고나면, 모든 객체는 부모의 제약사항에 맞게 크기가 정의되어 paint 메서드를 호출했을 때 그려질 준비가 된다.
상자 제약 모델은 객체들을 O(n) 시간복잡도에 배치할 수 있는 좋은 방법이다.
이 모델은 자식 객체가 자신의 내용(content)을 렌더하기위해 사용할 수 있는 공간을 알아야 하는 경우에도 작동한다. LayoutBuilder widget을 이용하여 자식 객체는 받아온 제약사항으로 공간을 어떻게 사용할지 정할 수 있다.
#Understanding constraints
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 600) {
return const OneColumnLayout();
} else {
return const TwoColumnLayout();
}
},
);
}
RenderObject의 root에는 render 트리 전체를 표현하는 RenderView가 있다. 플랫폼이 새로운 프레임 렌더링을 요구할 때(예시로 vsync 혹은 텍스쳐 압축해제/업로드 완료), render 트리의 root인 RenderView 객체의 일부인 compositeFrame 메서드가 호출된다. 이는 SceneBuilder를 생성하여 화면의 업데이트를 발생시킨다. 화면이 완성되면 RenderView 객체는 합성된 화면(composited scene)을 dart:ui의 Window.render 메서드에 보내고 이는 GPU가 렌더하도록 제어권을 넘긴다.
#Flutter rendering pipeline