이동식 저장소

Compose가 UI를 그리는 과정 본문

Primary/Compose

Compose가 UI를 그리는 과정

해스끼 2022. 8. 13. 11:02

Compose는 여러 단계를 거쳐 UI를 그린다. Android View 시스템에서는 measure, layout, drawing 세 단계를 거쳐 UI를 그린다. Compose도 비슷한 방법으로 UI를 그리지만, 맨 앞에 composition이라는 아주 중요한 과정이 추가된다.

UI를 그리는 3단계 (UI phases)

  1. Composition: UI가 무엇을 그릴지 결정한다. Compose는 이 단계에서 composable 함수를 실행하여 UI 트리를 만든다.
  2. Layout: UI 요소들의 크기와 위치를 결정한다. 크기를 결정하는 과정은 measurement, 위치를 결정하는 과정은 placement라고 한다. UI 트리의 노드(=composable)들은 자신과 자식 노드의 크기와 좌표를 결정한다.
  3. Drawing: UI를 실제로 그린다. Canvas에 선과 색, 글자 등을 그린다.

 

이 과정은 보통 composition에서 시작하여 drawing으로 끝나지만, ``BoxWithConstraints``나 ``LazyColumn`` 등은 예외적으로 자식의 composition이 부모의 layout 단계에 영향을 받는다. 예를 들어 ``LazyColumn``에서는 화면에 보이는 요소만 그리는데, 어떤 요소가 화면에 보여야 하는지 결정하려면(composition) 먼저 그 전의 요소들이 배치되어야 한다(layout).

 

일반적으로 매 프레임마다 위의 3단계가 반복된다. 하지만 UI를 최대한 빠르게 업데이트하기 위해 Compose는 같은 결과가 예상되는 입력에 대해서는 위의 과정을 skip한다. 마치 다이나믹 프로그래밍 문제를 풀 때처럼. 1초에 최소 60번, 많으면 120번씩 프레임을 그려야 하므로 건너뛸 수 있는 단계는 최대한 건너뛰는 것이다.

 

Composition 단계가 가장 많이 skip되는데, UI의 구성보다 텍스트 등 UI의 실제 내용이 바뀌는 경우가 대부분이기 때문이다.  그래서 skippable이라고 하면 보통 composition 단계를 건너뛸 수 있다는 의미이다.

 

참고로 composable의 skippability는 매우 중요한 개념이다. 관련해서 다음 글을 참고하자.

 

Compose의 Stability에 관하여

정말 좋은 글이다. 일독을 권한다. Jetpack Compose Stability Explained Have you ever measured the performance of your composable and discovered it is recomposing more code than you expect? “I thought..

thinking-face.tistory.com

 

State

Compose는 ``State``의 값이 어느 단계에서 사용되는지 추적한다. State의 값이 바뀌면 해당 값을 사용한 phase만 다시 실행하면 된다. 참 쉽죠?

 

State 객체는 보통 ``mutableStateOf()`` 함수를 이용해서 만들고, ``by`` 키워드를 이용하여 값에 접근할 수 있다. 물론 ``by`` 없이도 값을 읽을 수 있지만, ``State.value``를 매번 참조하기엔 너무 길기 때문에..

var background: Color by remember { mutableStateOf(Color.White) }
Box(modifier = Modifier.background(color = background)) {
    // ...
}

겉으로는 ``background`` 변수를 직접 조작하는 것처럼 보이지만, 실제로는 ``State``의 getter와 setter 함수가 각각 접근과 변경 기능을 위임받아 수행한다.

 

State 객체의 값이 바뀌었을 때 다시 실행될 수 있는 코드 블럭을 restart scope라고 한다. Compose는 state의 값의 변화를 추적하고, 값이 바뀌었을 때 해당 scope에서 적절한 UI phase를 수행한다.

Phase

지금까지 했던 얘기를 요약하면, Compose는 세 단계에 걸쳐 UI를 그리고, 특정 state가 어떤 단계에서 사용되는지 추적할 수 있다. 따라서 state의 값이 바뀌었을 때 처음부터 모든 단계를 실행하는 대신 해당 state가 사용된 단계부터 다시 실행하면 된다.

 

참고로 state가 어느 phase에서 만들어졌는지는 전혀 상관없다. Compose는 이 값이 어느 phase에 어느 composable에서 사용됐는지만을 추적한다.

 

이제 각 단계에서 어떤 일이 일어나는지 살펴보자.

1. Composition

State 값이 ``@Composable`` 함수 또는 람다식에서 사용됐다면, 이 값이 바뀔 때 composition은 무조건 다시 실행되고, 필요하다면 나머지 두 단계도 실행될 수 있다.

 

값이 바뀌는 즉시 해당 값을 사용하던 모든 composable 함수가 다시 실행되는데, 이때 입력이 그대로인 composable은 다시 실행되지 않을 수도 있다. Compose는 composable의 입력이 같을 때 항상 같은 결과를 반환하다고 여기기 때문에(실제로도 그래야만 한다) 입력이 그대로인 composable을 skip한다.

 

Compose는 composition의 결과에 따라 layout과 drawing 단계를 수행한다. 값이 바뀌었음에도 불구하고 레이아웃의 크기와 위치가 변하지 않은 경우엔 건너뛸 수도 있다.

var padding by remember { mutableStateOf(10.dp) }
// padding 변수는 composition 과정에서 사용되었다.
// padding 변수의 값이 바뀌는 경우 composition 단계가 다시 수행된다. 
// 이것을 recomposition이라고 한다.
Box(modifier = Modifier.padding(padding)) {
    // ...
}

2. Layout

Layout 단계는 각 composable의 크기를 측정하는 measurement와 위치를 결정하는 placement 단계로 나뉜다. Measurement 단계에서는 composable의 크기에 영향을 주는 ``Modifier.padding()`` 등을 실행하며, placement 단계에서는 composable의 위치에 영향을 주는 ``layout`` 함수나 ``Modifier.offset { ... }`` 등을 실행한다.

 

Measurement 또는 placement 단계에서 state의 값이 사용되었다면 값이 바뀔 때 layout 단계가 다시 실행되며, 레이아웃의 크기나 위치가 변했다면 drawing 단계도 다시 실행된다. 

 

사실 measurement와 placement 단계는 서로 독립적이다. 그래서 placement 단계에서 쓰인 값이 measurement를 직접적으로 다시 실행하게 하지는 않지만, 독립적이라기엔 많이 얽혀있는 터라 placement의 수많은 restart scope를 실행하는 도중에 measurement의 scope가 다시 실행될 수도 있다.

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // offsetX 변수는 placement 단계에서 사용된다.
        // 따라서 offsetX의 값이 변하면 layout(정확히는 placement) 단계가 다시 실행된다.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

3. Drawing

Drawing 단계에서는 색깔이나 폰트 등의 값이 사용된다. 값이 바뀌면 draw 단계만이 다시 실행된다.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // color 변수는 무엇을 그릴지(composition)와 어디에 어느 크기로 그릴지(layout) 결정하지 않는다.
    // 단지 어떤 색으로 그려져야 하는지(draw) 결정할 뿐이다.
    // 따라서 color 변수의 값이 바뀌면 draw 단계만 다시 실행된다.
    drawRect(color)
}

요약하면 다음과 같다.

State가 어느 단계에서 사용되는지에 따라 수행되는 단계의 수가 달라진다. Drawing 단계에서 사용된 값은 최대 한 개의 단계(draw)만을 다시 실행하게 하지만, composition 단계에서 사용된 값은 최대 세 개의 단계를 다시 실행하게 할 수 있다.

State 최적화하기 (Case study)

그래서 state를 최대한 나중에 사용하면 좋다. 예를 들어 다음의 코드에서는 ``listState``를 composition 단계에서 참조하였다.

Box {
    // content: @Composable () -> Unit
    val listState = rememberLazyListState()
    Image(
        Modifier.offset(
            with(LocalDensity.current) { // this: Density
                // firstVisibleItemScrollOffset을 composition 단계에서 읽음
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )
    LazyColumn(state = listState)
}

대략 화면을 스크롤할 때 이미지의 위치도 함께 변하는 코드이다. 이 코드는 맞긴 하지만, 최적의 코드는 아니다. ``firstVisibleItemScrollOffset`` 변수가 composition 단계에서 사용되었기 때문이다.

 

물론 ``Modifier.offset()`` 함수는 placement 단계에서 실행되지만, 오프셋 값을 결정하는 ``with`` 함수가 ``Box``의 scope에 속한다. 따라서 ``firstVisibleItemScrollOffset`` 변수의 값이 바뀔 때, 즉 사용자가 ``LazyColumn``을 스크롤할 때 ``Box``의 content 함수가 다시 실행된다. Composition부터 draw까지 전부.

 

하지만 이 코드는 ``Image``의 위치만을 바꾸려 한다. 오프셋 값이 바뀌어도 그려지는 요소는 동일하고, 단지 요소의 위치만 바뀌기 때문에 placement만 다시 실행해도 된다. Layout 단계만 다시 실행되도록 코드를 고쳐 보자.

 

``Box``의 scope에 속하는 ``LocalDensity.current``를 참조한 이유는 ``toDp()`` 함수를 실행하기 위해서였다. ``Int.toDp()``는 ``Density`` 인터페이스에 정의된 확장 함수이기 때문이다. 그런데 ``Modifier.offset(offset: Density.() -> IntOffset)``를 사용하면 ``toDp`` 함수를 실행하지 않아도 된다.

Box {
    val listState = rememberLazyListState()
    Image(
        Modifier.offset {
            // 이제 firstVisibleItemScrollOffset을 layout 단계에서 참조한다.
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )
    LazyColumn(state = listState)
}

이제 오프셋을 layout 단계에서 결정한다. 따라서 ``firstVisibleItemScrollOffset``이 바뀌어도 composition 단계는 실행되지 않는다. 

위의 코드에서는 값 대신 람다식을 매개변수로 전달하였다. 물론 값보다 람다식이 더 무겁긴 하지만, 매 프레임마다 composition을 다시 실행하는 것보단 낫다.

위의 case study에서 본 것처럼 성능을 높이기 위해서는 변수를 최대한 늦게 참조해야 한다. 물론 composition 단계에서 읽어야만 하는 값도 있지만, 값을 나중에 사용해도 되는지 항상 체크하자.

Recomposition 순환 참조

주의: 이 문단에서는 아주 나쁜 코딩 방식을 설명합니다. 따라하지 마시오.

 

UI를 그리는 세 단계는 항상 같은 순서로만 실행되며, 하나의 프레임을 그릴 때 이전 단계로 돌아가는 일은 절대로 발생하지 않는다. 하지만 다른 프레임을 그릴 때는 문제가 생길 수 있다. 다음의 코드를 보자.

Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // 이렇게 하지 마시오
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

``Box`` 안에 ``Image``와 ``Text``가 수직으로 배치되어 있다. ``Column``을 아주 나이브한 방식으로 구현했다고 보면 된다.

 

코드를 잘 보면, ``Image``의 크기를 저장하고 그 크기만큼 ``Text``의 padding을 설정한다. 사실 px 단위를 dp로 변환하는 것부터 잘못됐다. 안드로이드 코드에서는 원칙적으로 화면 크기에 종속적인 px 대신 dp만을 사용하기 때문이다. 뭐 이건 차치하더라도 더 큰 문제가 남아 있다.

이 코드는 UI를 하나의 프레임에 완성하지 못한다.

``Text``의 위치는 ``Image``가 배치된 후에야 결정된다. ``Image``의 위치는 layout(정확히는 placement) 단계에서 결정된다. ``Image``의 위치를 결정한 후 ``Text``를 배치하려고 보니 이미 placement 단계가 끝났네? 위에서 말했다시피 하나의 프레임을 그릴 때 같은 단계를 반복할 수는 없으므로, ``Text``를 배치하기 위해 새로운 프레임을 그려야만 한다. 하나의 레이아웃을 그리기 위해 두 개의 프레임을 그리는 것이다.

 

물론 의도한 대로 이미지 밑에 텍스트가 보여지긴 하지만, 프레임을 1개만 써도 되는 UI를 2개씩이나 써서 완성하게 된다.

위 코드의 문제점을 일반화하면 다음과 같다.

  1. Layout 단계에서 어떤 값이 결정된다.
  2. Layout 단계가 끝난다.
  3. 해당 값을 참조하여 measurement 또는 placement 작업을 수행하려 한다.
  4. 이미 layout 단계는 끝났으므로, 새로운 layout 단계를 수행하기 위해 새 프레임을 그린다.
  5. (무한 반복 가능)

그냥 ``Column`` 쓰자.. 굳이 커스텀 레이아웃을 써야겠다면 차라리 ``Layout``을 쓰자.

참고

 

Jetpack Compose 단계  |  Android Developers

Jetpack Compose 단계 대부분의 다른 UI 도구 키트와 마찬가지로 Compose는 몇 가지 고유한 단계를 통해 프레임을 렌더링합니다. Android 뷰 시스템에는 측정, 레이아웃, 그리기라는 세 가지 주요 단계가

developer.android.com

 

Comments