Jetpack Compose 단계

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

컴포지션은 Compose 이해, 상태 및 Jetpack Compose를 비롯한 여러 Compose 문서에 설명되어 있습니다.

프레임의 세 단계

Compose에는 세 개의 주요 단계가 있습니다.

  1. 컴포지션: 표시할 UI입니다. Compose는 구성 가능한 함수를 실행하고 UI 설명을 만듭니다.
  2. 레이아웃: UI를 배치할 위치입니다. 이 단계는 측정과 배치라는 두 단계로 구성됩니다. 레이아웃 요소는 레이아웃 트리에 있는 각 노드의 레이아웃 요소 및 모든 하위 요소를 2D 좌표로 측정하고 배치합니다.
  3. 그리기: UI를 렌더링하는 방법입니다. UI 요소는 일반적으로 기기 화면인 캔버스에 그려집니다.
Compose가 데이터를 UI (순서, 데이터, 컴포지션, 레이아웃, 그리기, UI)로 변환하는 3단계의 이미지입니다.
그림 1. Compose가 데이터를 UI로 변환하는 3단계

이러한 단계의 순서는 일반적으로 동일하여, 데이터가 컴포지션에서 레이아웃, 그리기의 순서로 한 방향으로 이동하여 프레임을 생성할 수 있습니다(단방향 데이터 흐름이라고도 함). BoxWithConstraints, LazyColumn, LazyRow는 중요한 예외로, 하위 요소의 컴포지션이 상위 요소의 레이아웃 단계에 따라 달라집니다.

이러한 세 단계는 사실상 모든 프레임에서 발생한다고 가정해도 무방하지만 Compose는 성능을 위해 모든 단계에서 같은 입력 데이터로 같은 결과를 계산하는 반복 작업을 피합니다. Compose는 이전 결과를 재사용할 수 있으면 구성 가능한 함수 실행을 건너뛰고 Compose UI는 꼭 필요한 경우가 아니라면 전체 트리를 다시 배치하거나 다시 그리는 작업을 하지 않습니다. Compose는 UI를 업데이트하는 데 필요한 최소한의 작업만 실행합니다. 이러한 최적화가 가능한 이유는 Compose가 여러 단계 내에서 상태 읽기를 추적하기 때문입니다.

단계 이해하기

이 섹션에서는 컴포저블에서 세 가지 Compose 단계가 실행되는 방식을 자세히 설명합니다.

음악작품

컴포지션 단계에서 Compose 런타임은 구성 가능한 함수를 실행하고 UI를 나타내는 트리 구조를 출력합니다. 이 UI 트리는 다음 동영상과 같이 다음 단계에 필요한 모든 정보가 포함된 레이아웃 노드로 구성됩니다.

그림 2. 컴포지션 단계에서 생성된 UI를 나타내는 트리입니다.

코드 및 UI 트리의 하위 섹션은 다음과 같습니다.

5개의 컴포저블과 그 결과로 생성되는 UI 트리가 있는 코드 스니펫과 상위 노드에서 분기되는 하위 노드가 있음.
그림 3. 상응하는 코드가 있는 UI 트리의 하위 섹션

이 예에서 코드의 각 구성 가능한 함수는 UI 트리의 단일 레이아웃 노드에 매핑됩니다. 더 복잡한 예에서는 컴포저블이 로직과 제어 흐름을 포함할 수 있으며 다양한 상태에 따라 다른 트리를 생성할 수 있습니다.

레이아웃

레이아웃 단계에서 Compose는 컴포지션 단계에서 생성된 UI 트리를 입력으로 사용합니다. 레이아웃 노드 모음에는 2D 공간에서 각 노드의 크기와 위치를 결정하는 데 필요한 모든 정보가 포함됩니다.

그림 4. 레이아웃 단계 중에 UI 트리에서 각 레이아웃 노드의 측정 및 배치

레이아웃 단계에서 트리는 다음 3단계 알고리즘을 사용하여 순회합니다.

  1. 하위 요소 측정: 노드는 하위 요소가 있는 경우 하위 요소를 측정합니다.
  2. 자체 크기 결정: 이러한 측정값에 따라 노드가 자체 크기를 결정합니다.
  3. 하위 요소 배치: 각 하위 노드는 노드의 자체 위치를 기준으로 배치됩니다.

이 단계가 끝나면 각 레이아웃 노드에는 다음이 포함됩니다.

  • 할당된 너비높이
  • 그려야 하는 x, y 좌표

이전 섹션에서 설명한 UI 트리를 떠올려 보세요.

5개의 컴포저블과 그 결과로 생성되는 UI 트리가 포함된 코드 스니펫(상위 노드에서 분기되는 하위 노드 포함)

이 트리의 경우 알고리즘은 다음과 같이 작동합니다.

  1. Row는 하위 요소 ImageColumn를 측정합니다.
  2. Image가 측정됩니다. 하위 요소가 없으므로 자체 크기를 결정하고 크기를 다시 Row에 보고합니다.
  3. 다음으로 Column가 측정됩니다. 자체 하위 요소 (두 개의 Text 컴포저블)를 먼저 측정합니다.
  4. 첫 번째 Text가 측정됩니다. 하위 요소가 없으므로 자체 크기를 결정하고 크기를 다시 Column에 보고합니다.
    1. 두 번째 Text가 측정됩니다. 하위 요소가 없으므로 자체 크기를 결정하고 Column에 다시 보고합니다.
  5. Column는 하위 측정값을 사용하여 자체 크기를 결정합니다. 최대 하위 너비와 하위 요소 높이의 합계를 사용합니다.
  6. Column는 자기 자신을 기준으로 하위 요소를 배치하여 하위 요소를 세로로 아래에 배치합니다.
  7. Row는 하위 측정값을 사용하여 자체 크기를 결정합니다. 최대 하위 요소 높이와 하위 요소 너비의 합계를 사용합니다. 그런 다음 하위 요소를 배치합니다.

각 노드는 한 번만 방문되었습니다. Compose 런타임은 모든 노드를 측정하고 배치하기 위해 UI 트리를 한 번만 통과하면 되므로 성능이 향상됩니다. 트리의 노드 수가 증가하면 노드를 순회하는 데 소요되는 시간이 선형 방식으로 증가합니다. 반대로 각 노드를 여러 번 방문하면 순회 시간이 기하급수적으로 증가합니다.

그리기

그리기 단계에서 트리는 위에서 아래로 다시 순회하고 각 노드는 차례로 화면에 자신을 그립니다.

그림 5. 그리기 단계에서는 화면에 픽셀을 그립니다.

이전 예를 사용하여 트리 콘텐츠는 다음과 같이 그려집니다.

  1. Row는 배경 색상과 같이 포함될 수 있는 모든 콘텐츠를 그립니다.
  2. Image가 자신을 그립니다.
  3. Column가 자신을 그립니다.
  4. 첫 번째 및 두 번째 Text는 각각 자신을 그립니다.

그림 6. UI 트리 및 그려진 표현

상태 읽기

위에 나열된 단계 중 하나에서 스냅샷 상태 값을 읽으면 Compose는 값을 읽을 때 실행하던 작업을 자동으로 추적합니다. 이 추적을 통해 Compose는 상태 값이 변경될 때 리더를 다시 실행할 수 있고 이를 바탕으로 Compose에서 상태를 추적할 수 있습니다.

상태는 일반적으로 mutableStateOf()를 사용하여 생성되고 다음 두 가지 방법 중 하나로 액세스합니다. value 속성에 직접 액세스하거나 또는 Kotlin 속성 위임을 사용하는 것입니다. 자세한 내용은 컴포저블의 상태를 참고하세요. 이 가이드에서는 '상태 읽기'가 이와 동등한 액세스 메서드 중 하나를 나타냅니다.

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

속성 위임 내부에서 'getter' 함수와 'setter' 함수는 상태의 value에 액세스하고 이를 업데이트하는 데 사용됩니다. 이러한 getter 함수와 setter 함수는 속성을 값으로 참조할 때만 호출되고 속성이 만들어질 때는 호출되지 않습니다. 따라서 위의 두 메서드는 동일합니다.

읽기 상태가 변경될 때 다시 실행될 수 있는 각 코드 블록은 다시 시작 범위입니다. Compose는 상태 값 변경사항을 추적하고 여러 단계에서 범위를 다시 시작합니다.

단계적 상태 읽기

위에서 언급한 바와 같이 Compose에는 세 개의 주요 단계가 있고 Compose는 각 단계 내에서 읽은 상태를 추적합니다. 이를 통해 Compose는 영향을 받는 각 UI 요소에서 작업을 실행해야 하는 특정 단계만 알릴 수 있습니다.

각 단계를 살펴보고 단계 내에서 상태 값을 읽을 때 어떤 일이 일어나는지 살펴보겠습니다.

1단계: 컴포지션

@Composable 함수나 람다 블록 내의 상태 읽기는 컴포지션 및 잠재적으로 이후 단계에 영향을 미칩니다. 상태 값이 변경되면 Recomposer는 이 상태 값을 읽는 모든 구성 가능한 함수의 재실행을 예약합니다. 입력이 변경되지 않은 경우 런타임에서 구성 가능한 함수의 일부 또는 모두를 건너뛸 수 있습니다. 자세한 내용은 입력이 변경되지 않은 경우 건너뛰기를 참고하세요.

컴포지션 결과에 따라 Compose UI는 레이아웃 단계와 그리기 단계를 실행합니다. 콘텐츠가 동일하게 유지되고 크기와 레이아웃이 변경되지 않으면 이러한 단계를 건너뛸 수 있습니다.

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

2단계: 레이아웃

레이아웃 단계는 측정배치라는 두 단계로 구성됩니다. 측정 단계에서는 Layout 컴포저블에 전달된 측정 람다와 LayoutModifier 인터페이스의 MeasureScope.measure 메서드 등을 실행합니다. 배치 단계에서는 layout 함수의 배치 블록과 Modifier.offset { … }의 람다 블록 등을 실행합니다.

이러한 각 단계의 상태 읽기는 레이아웃에 영향을 미치고 그리기 단계에도 영향을 줄 수 있습니다. 상태 값이 변경되면 Compose UI는 레이아웃 단계를 예약합니다. 크기나 위치가 변경된 경우 그리기 단계도 실행합니다.

자세하게 설명하자면 측정 단계와 배치 단계의 다시 시작 범위는 별개이므로 배치 단계의 상태 읽기가 그 전 측정 단계를 다시 호출하지 않습니다. 그러나 이 두 단계는 서로 관련된 경우가 많으므로 배치 단계의 상태 읽기가 측정 단계에 속하는 다른 다시 시작 범위에 영향을 미칠 수 있습니다.

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

3단계: 그리기

그리기 코드 중 상태 읽기는 그리기 단계에 영향을 미칩니다. 일반적인 예로는 Canvas(), Modifier.drawBehind, Modifier.drawWithContent가 있습니다. 상태 값이 변경되면 Compose UI는 그리기 단계만 실행합니다.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

상태 읽기 최적화

Compose는 현지화된 상태 읽기 추적을 실행하므로 적절한 단계에서 각 상태를 읽어 실행되는 작업량을 최소화할 수 있습니다.

예를 살펴보겠습니다. 오프셋 수정자로 최종 레이아웃 위치를 오프셋하여 사용자가 스크롤할 때 시차 효과가 발생하는 Image()가 있습니다.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) {
        // ...
    }
}

이 코드는 작동하지만 최적화된 성능을 내지는 않습니다. 작성된 대로 코드는 firstVisibleItemScrollOffset 상태 값을 읽고 그 값을 Modifier.offset(offset: Dp) 함수에 전달합니다. 사용자가 스크롤하면 firstVisibleItemScrollOffset 값이 변경됩니다. 알고 있듯이 Compose는 모든 상태 읽기를 추적하므로 읽기 코드를 다시 시작(다시 호출)할 수 있습니다. 이 예에서는 Box의 콘텐츠입니다.

이 예는 컴포지션 단계 내에서 읽는 상태의 예입니다. 이는 반드시 나쁜 것은 아니며 사실 리컴포지션의 기초로, 데이터 변경사항이 새 UI를 내보낼 수 있도록 합니다.

그러나 이 예에서는 모든 스크롤 이벤트로 인해 전체 구성 가능한 콘텐츠가 재평가된 후 측정되고 배치되며 최종적으로 그려지기 때문에 바람직하지는 않습니다. Google에서는 표시하는 항목이 변경되지 않고 표시되는 위치만 변경되었더라도 모든 스크롤에서 Compose 단계를 트리거합니다. 상태 읽기를 최적화하여 레이아웃 단계만 다시 트리거할 수 있습니다.

다음과 같이 사용할 수 있는 다른 버전의 오프셋 수정자도 있습니다. Modifier.offset(offset: Density.() -> IntOffset)

이 버전은 람다 매개변수를 사용하며 여기서 결과 오프셋은 람다 블록에서 반환됩니다. 이를 사용하도록 코드를 업데이트해보겠습니다.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) {
        // ...
    }
}

이 코드가 성능이 더 뛰어난 이유는 무엇일까요? 수정자에 제공하는 람다 블록은 레이아웃 단계(특히 레이아웃 단계의 배치 단계)에서 호출됩니다. 즉, firstVisibleItemScrollOffset 상태를 컴포지션 중에 더 이상 읽지 않습니다. Compose는 상태를 읽을 때 추적하므로 이러한 변경은 firstVisibleItemScrollOffset 값이 변경되면 Compose가 레이아웃 단계와 그리기 단계만 다시 시작하면 된다는 의미입니다.

이 예에서는 다양한 오프셋 수정자를 사용하여 결과 코드를 최적화할 수 있지만 상태 읽기를 가능한 가장 낮은 단계로 현지화하여 Compose에서 최소한의 작업을 실행할 수 있도록 한다는 일반적인 개념도 사실입니다.

물론 컴포지션 단계에서 상태를 읽는 것이 꼭 필요한 경우도 많습니다. 그렇더라도 상태 변경을 필터링하여 리컴포지션 수를 최소화할 수 있는 경우도 있습니다. 자세한 내용은 derivedStateOf: 상태 객체 하나 이상을 다른 상태로 변환을 참고하세요.

리컴포지션 루프(순환 단계 종속 항목)

앞서 Compose의 단계는 항상 같은 순서로 호출되고 같은 프레임에 있는 동안에는 뒤로 이동할 방법이 없다고 설명했습니다. 그렇더라도 앱이 다른 프레임에서 컴포지션 루프에 들어갈 수 없는 것은 아닙니다. 다음 예를 살펴보세요.

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

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

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

여기 좋지 않게 구현된 세로 열은 상단에 이미지가 있고 그 아래 텍스트가 있습니다. Modifier.onSizeChanged()를 사용하여 확인된 이미지 크기를 파악하고 텍스트에서 Modifier.padding()을 사용하여 이미지를 아래로 이동합니다. Px에서 다시 Dp로의 부자연스러운 변환은 이미 코드에 문제가 있음을 나타냅니다.

이 예에서 문제는 단일 프레임 내에서 '최종' 레이아웃에 도달하지 않는다는 점입니다. 이 코드에서는 발생하는 여러 프레임(불필요한 작업을 실행함)에 의존하므로 사용자의 화면에서 UI가 이리저리 이동합니다.

각 프레임을 단계별로 살펴보며 어떤 일이 일어나는지 알아보겠습니다.

첫 번째 프레임의 컴포지션 단계에서 imageHeightPx의 값은 0이고 텍스트에는 Modifier.padding(top = 0)이 제공됩니다. 그러면 레이아웃 단계가 이어지고 onSizeChanged 수정자의 콜백이 호출됩니다. 이때 imageHeightPx가 이미지의 실제 높이로 업데이트됩니다. Compose는 다음 프레임의 리컴포지션을 예약합니다. 그리기 단계에서는 텍스트가 0 패딩으로 렌더링됩니다. 값 변경사항이 아직 반영되지 않았기 때문입니다.

그런 다음 Compose는 imageHeightPx의 값 변경으로 예약된 두 번째 프레임을 시작합니다. Box 콘텐츠 블록에서 상태를 읽고 컴포지션 단계에서 상태를 호출합니다. 이때 텍스트에는 이미지 높이와 일치하는 패딩이 제공됩니다. 레이아웃 단계에서 코드는 imageHeightPx의 값을 다시 설정하지만 예약된 리컴포지션은 없습니다. 값이 동일하게 유지되기 때문입니다.

최종적으로 텍스트에 원하는 패딩을 확보하지만 패딩 값을 다른 단계에 다시 전달하려고 추가 프레임을 쓰는 것은 바람직하지 않으며 이렇게 할 경우 결과적으로 중복되는 콘텐츠가 있는 프레임이 생성됩니다.

이 예는 부자연스럽게 보일 수 있지만 다음과 같은 일반적인 패턴에 유의하세요.

  • Modifier.onSizeChanged(), onGloballyPositioned() 또는 기타 레이아웃 작업
  • 일부 상태 업데이트
  • 이 상태를 레이아웃 수정자의 입력으로 사용(padding(), height() 또는 유사한 형식)
  • 반복될 수 있음

위 샘플의 해결책은 적절한 레이아웃 프리미티브를 사용하는 것입니다. 위 예는 간단한 Column()으로 구현할 수 있지만 맞춤 레이아웃을 작성해야 하는 맞춤이 필요한 좀 더 복잡한 예가 있을 수 있습니다. 자세한 내용은 맞춤 레이아웃 가이드를 참고하세요.

여기서 일반적인 원칙은 서로에 관해 측정하고 배치해야 하는 여러 UI 요소의 단일 소스 저장소를 보유하는 것입니다. 적절한 레이아웃 프리미티브를 사용하거나 맞춤 레이아웃을 만들면 최소 공유 상위 요소가 여러 요소 간의 관계를 조정할 수 있는 정보 소스 역할을 합니다. 동적 상태를 도입하면 이 원칙이 깨집니다.