지난 글 이 코드.. 화면에 어떻게 그려질까? 렌더링 원리 - 1. 트리에서 플러터의 렌더링 파이프라인에서 사용하는 '3가지 트리'를 위주로 레이아웃 단계까지 살펴봤다. 이번에는 레이아웃 단계 자체를 좀 더 자세히 알아보자.
레이아웃 단계는 우리가 코드로 적은 각 위젯이 어떤 위치에, 어떤 크기로 그려질지 결정하는 단계다. 지난 포스팅에서 살펴봤듯이, 플러터에서는 위젯을그리기 위해 트리를 3개 사용한다. 그 중 레이아웃 단계에서는 엘리먼트 트리(element tree)를 사용해 위젯의 크기와 위치를 결정한다. 이 글에서 나오는 모든 트리는 엘리먼트 트리라고 보면 된다.
플러터의 가장 큰 특징은 "aggresive composability"이다. 직역하면 "공격적인 구성성"이라고 나온다. 다른 프레임워크에 비해 여러 위젯을 결합할 일이 많기 때문에 이렇게 표현한다. 새로운 위젯을 만들기 위해 기존에 있던 위젯 여러개를 결합해야하고, 그 재료가 되는 위젯도 이미 다른 기본 위젯 여러개로 만들어진 것이다.
예를 들어, 플러터에서 어떤 위젯의 padding을 주려면 그 위젯에 있는 padding
이라는 속성을 사용하는게 아니라 Padding
이라는 또다른 위젯을 사용해야 한다. 이렇듯 유저가 보는 화면은 수많은 위젯으로 이루어진다.
그런데 이렇게 위젯이 많아져도 될까? 다른 프레임워크에서 이런 방식을 사용하지 않은데는 이유가 있을 것이다. 자칫 잘못하면 렌더링 성능에 영향을 줄 수 있다. 따라서 플러터는 조금 다른 방식으로 위젯 렌더링을 관리한다.
플러터의 렌더링 방식은 "Simple is Fast"라는 생각에 기반한다. 쉽게 설명할 수 있고, 이해하기에 복잡하지 않은 알고리즘이 실제로 성능도 좋을 것이라는 생각이다. 플러터 렌더링 과정에 대한 강연에서 "처음에 이렇게 간단한 알고리즘만으로 충분할지 의문이 들었지만, 실제로 잘 동작한다는걸 깨달았다."는 말도 나왔다. 이제 그 알고리즘을 좀 더 자세히 살펴보자.
위젯의 크기와 위치를 결정하는 레이아웃 단계는 이 3문장으로 요약된다.
레이아웃 단계에서는 엘리먼트 트리를 "사용"한다고 했다. 위젯의 크기와 위치를 결정하기 위해서 엘리먼트 트리에 있는 모든 노드를 방문해야하기 때문이다. 플러터는 DFS로 각 노드를 방문하면서 위에 적힌 3가지 일을 수행한다.
먼저 트리의 루트부터 하나씩 노드를 타고 내려간다. 이 때, 각 위젯에게 주어진 constraints도 함께 이동한다. constraints에는 여러가지 종류가 있는데, 가장 대표적인건 BoxConstraint
다. Container
나 ConstrainedBox
에 constraints
파라미터로 넘겨줬던 그 객체다.
BoxConstraint
는 아주 간단한 멤버 변수로 구성된다. Box를 그리기 위한 제한 조건이기 때문에 가로, 세로 길이의 최솟값, 최댓값 4가지로만 이루어진다.
const BoxConstraints({
this.minWidth = 0.0,
this.maxWidth = double.infinity,
this.minHeight = 0.0,
this.maxHeight = double.infinity,
}) ...
parent에서 child 노드로 갈 때 이 제한 조건을 넘겨준다. 이걸 넘겨주는데에는 다 이유가 있다. 바로 child 위젯의 크기를 알기 위해서다. 그러니까 parent가 "가로가 minWidth부터 maxWidth, 세로가 minHeight부터 maxHeight까지 되는 범위 내에서 니가 알아서 크기를 정하렴"하고 제한 조건을 내려보내면, child가 "그럼 난 이만큼 커질래!"하고 정확한 수치를 올려보내주는 것이다. 이렇게 child가 자신의 크기를 올려보내주는 단계가 2단계, 바로 다음 단계다.
트리의 리프 노드부터 하나씩 차례대로 자신의 parent를 방문하며 올라온다. 이 때, child는 자신이 받았던 제한 조건을 만족하는 범위 내에서 자신의 정확한 크기를 결정하고, 이를 parent에게 알려준다. 따라서 "Sizes go up"이 이루어진다.
자신의 child로부터 크기를 받은 parent 노드는, 이제 각 child가 정확히 어디에 위치할지 정할 수 있다. 반대로 말하면 child의 정확한 사이즈를 모른다면 위치를 정해줄 수 없다는 뜻도 된다. 생각해보면 당연한 얘기다. 예를 들어 Row
안에 Container
위젯이 3개 있다고 해보자. Row
입장에서는 각 컨테이너의 크기가 얼마인지 모르면 컨테이너들이 정확히 어디에 위치할지 알 수 없다. Row
는 children을 서로 겹치지 않도록 하고 좌우로 나란히 보여주는 위젯이기 때문이다.
좀 더 구체적으로 살펴보자. 아래는 플러터 공식 문서에 있는 예시다.
아래 말하는 'Widget'은 연노란색으로 칠해진 부분, 즉 Column
이다. first child는 파란색, second child는 초록색 위젯이다.
Widget: Hey parent, what are my constraints?
Parent: You must be from 80 to 300 pixels wide, and 30 to 85 tall.
Widget: Hmmm, since I want to have 5 pixels of padding, then my children can have at most 290 pixels of width and 75 pixels of height.
이런식으로 자신이 내려받은 constraint를 그대로 주는게 아니라 자신의 크기도 고려해서 child로 내려보낸다. 여기서는 모든 방향으로 들어가야하는 Padding
5픽셀을 각각 가로, 세로에서 제외한 제한 조건을 알려줬다.
Widget: Hey first child, You must be from 0 to 290 pixels wide, and 0 to 75 tall.
First child: OK, then I wish to be 290 pixels wide, and 20 pixels tall.
Widget: Hmmm, since I want to put my second child below the first one, this leaves only 55 pixels of height for my second child.
Widget: Hey second child, You must be from 0 to 290 wide, and 0 to 55 tall.
Second child: OK, I wish to be 140 pixels wide, and 30 pixels tall.
이제 모든 child 위젯의 크기를 정확히 알고 있으니 Widget은 각 child의 위치를 정할 수 있다.
Widget: “Very well. My first child has position x: 5 and y: 5, and my second child has x: 80 and y: 25.”
그리고 자신의 children이 했던 것처럼, Widget도 자신의 parent에게 크기를 알려준다.
Widget: “Hey parent, I’ve decided that my size is going to be 300 pixels wide, and 60 pixels tall.”
대부분의 위젯은 child를 하나만 갖는다. 그래서 자기 차례가 되면 크기를 결정하고, 그 크기를 parent 노드에 알려주면 된다. 그렇다면 child를 여러개 가질 수 있는 위젯은 어떤 과정으로 자신의 크기를 결정할까?
위에서 본 예시에서는 Widget이 자신의 child 노드를 방문할 때 first child, second child 순서로 방문했다. 이 순서는 실제로 어떻게 정해지는지 알아보자.
parent는 child 위젯의 크기가 유연한(flexible)지에 따라 방문 순서를 정한다. Inflexible widget부터 방문한 다음, 남는 공간에 flexible widget을 배치한다.
위 그림처럼 Row
안에 Container
와 Flexible
이 2개씩 있는 상황을 생각해보자.
컨테이너(Container)는 flexible 위젯이 아니기 때문에 분홍색, 초록색 박스에 먼저 찾아갈 것이다. 각 컨테이너는 다음과 같은 constraint를 받는다.
BoxConstraint(
minWidth: 0.0,
maxWidth: +Inf,
minHeight: 0.0,
maxHeight: (Row가 받은 maxHeight)
)
그 다음, 각 컨테이너가 Row에 자신의 사이즈를 알려준다. 물론 컨테이너도 child를 갖고 있다면 그 child에게 자신이 받은 제한 조건을 넘겨주고 사이즈를 되돌려받았을 것이다. 이렇게 받은 사이즈로 Row 위젯의 남는 공간을 계산한다.
Row 위젯에서 '남는 공간'은 '가로 방향으로 남는 공간'이기 때문에 플러터는 이를 계산한다. 단, Row는 아직 자기 자신의 전체 가로 길이를 모른다. 아직 나머지 Flexible 위젯의 가로 길이를 모르기 때문이다. 지금 알고 있는건 컨테이너 위젯 2개의 가로 길이뿐이다.
그래서 그림처럼 각 컨테이너의 가로가 각각 30, 20이라면 '남는 공간'은 다음과 같이 계산된다.
(남는 가로 길이) = (Row가 자신의 parent에게 받은 maxWidth) - (30 + 20)
이 단계에서 우리가 코드로 넘겨준 flex
값을 사용한다.
먼저, 2단계에서 구한 (남는 가로 길이)를 전체 flex
값의 합으로 나눈다. 그러면 flex: 1
에 해당하는 가로 길이가 나온다. 그리고 그 값을 사용해 각 child의 flex
값의 비율대로 최대 가로 길이를 정해주면 된다.
따라서 각 Flexible 위젯에는 이런 제한 조건이 내려간다. 앞 단계에서 컨테이너가 받은 제한조건 중 minWidth
, maxWidth
만 바뀐 조건이다.
BoxConstraint(
minWidth: (flex 값) * (flex가 1일 때의 가로 길이),
maxWidth: (flex 값) * (flex가 1일 때의 가로 길이),
minHeight: 0.0,
maxHeight: (Row가 받은 maxHeight)
)
만약 Row에게 주어진 최대 가로 길이가 100이었다고 해보자. 그러면 (남는 가로 길이)는 100 - (30 + 20) = 50이 된다. Flexible 위젯의 flex
값은 총 2 + 3 = 5 이기 때문에, flex: 1
에 해당하는 가로 길이는 50 / 5 = 10이 된다. 파란색 Flexible 위젯은 flex: 2
, 노란색 Flexible 위젯은 flex: 3
을 넘겨줬기 때문에 각각 가로 길이가 20, 30으로 제한된다.
지금까지는 이해를 돕기 위해 마치 모든 child 위젯이 Row 안에서 배치된 것처럼 생긴 그림을 예로 들었지만, 사실 지금까지 Row 위젯은 각 child 위젯이 어떤 위치에 있는지 모르는 상태였다. 그러니까 이런 상태였던거다.
바로 직전 단계에서 각 child의 가로 길이를 정확히 알게 되었으니 이제서야 Row는 자신의 child를 배치할 수 있다. 즉, DFS 과정에서 자신의 모든 child가 사이즈를 return한 후에야 child의 위치 좌표를 정확히 알 수 있다.
여담) 이 때, 배치하는 곳은 Row 자신의 좌표계를 기준으로 한다. 공식 문서에는 이걸 'After the child returns from layout, the parent decides the child’s position in the parent’s coordinate system.' 라고 적어놓았는데, 이 대목을 보면 그동안 한번쯤 써봤을
localToGlobal
메소드가 왜 이런 이름을 갖게 되었는지 짐작이 갈 것이다. 위젯마다 각자의 local coordinate system을 갖고 있고 이걸 global하게 바꿔주는 메소드가localToGlobal
인 것이다.
동영상에 나왔던 여담) 이는 웹의 방식과 반대다. 웹에서는 오브젝트의 사이즈를 자신이 스크린에서 어디에 위치하는지에 따라 정한다고 한다.
이제 모든 children의 위치와 크기를 알았으니, Row 위젯이 return할 차례다. Flexible 위젯이 있었기 때문에 Row의 가로 길이는 제한 조건으로 주어졌던 maxWidth
값이 된다.
높이는 어떨까? 먼저 children의 세로 길이 중 가장 긴 값을 찾는다. 그 값과 Row 자신에게 주어졌던 minHeight
을 비교해서 더 큰 값을 높이로 갖게 된다. 단, 이 값이 자신에게 주어졌던 maxHeight
을 넘지 않는다면 말이다. 만약 그 값이 maxHeight
보다 크다면 Row 위젯의 높이는 maxHeight
이 될 것이고, children의 내용물은 잘려서 보일 것이다.
알고리즘을 살펴보았으니 이제 장단점을 비교할 차례다.
Flutter aims for linear performance for initial layout, and sublinear layout performance in the common case of subsequently updating an existing layout.
최초로 프레임을 그릴 때는 O(N), 한번 그려진 레이아웃을 업데이트 할 때에는 O(√N)만큼의 속도를 보장한다. => DFS니까 O(N)이 되는 것 같다. (아마)
위젯은 parent에 의해 주어진 제약 조건 내에서만 자체 크기를 결정할 수 있다. 즉, 위젯이 항상 자신이 원하는 크기를 가질 수 있는건 아니다.
자신의 parent가 위젯의 위치를 결정하기 때문에 위젯은 화면에서 자신의 위치를 알 수도 없고 결정할 수도 없다.
parent의 크기와 위치도 parent의 parent에 따라 달라지기 때문에 트리 전체를 고려하지 않고 위젯의 크기와 위치를 정확하게 정의할 수 없다.
child가 parent와 다른 크기를 원하는 경우, parent가 이를 정렬할 수 있는 충분한 정보를 가지고 있지 않으면 child의 크기가 무시될 수 있다.
사실 위의 한계점 모두 공식 문서에 나와있는 내용인데, 첫번째 항목을 제외하고는 크게 와닿지 않는다. 이를 다룬 공식 문서 페이지 자체를 나중에 다시 자세히 살펴봐야겠다.
좋은글 감사합니다.
글을 읽다가 궁금한 점이 있어서 질문드립니다.
이 글 상단에 [레이아웃 단계는 우리가 코드로 적은 각 위젯이 어떤 위치에, 어떤 크기로 그려질지 결정하는 단계다. 지난 포스팅에서 살펴봤듯이, 플러터에서는 위젯을그리기 위해 트리를 3개 사용한다. 그 중 레이아웃 단계에서는 엘리먼트 트리(element tree)를 사용해 위젯의 크기와 위치를 결정한다.]라고 말씀하셨는데요. 그러면 렌더트리는 어떻게되는건가요?
이전 블로그 글에서는 [Layout
레이아웃 단계는 렌더 트리를 보면서 최종적으로 위젯의 geometry, 즉 위젯이 어느 위치에 어떤 사이즈로 그려질지, 회전은 어느정도로 되어야 하는지 등을 결정하는 단계다.] 라고 쓰여져 있는데.. 이글에서는 엘리먼트 트리를 사용한다고 되어 있어서 혼동이 됩니다. ㅠ.ㅠ