반응형
Notice
Recent Posts
Recent Comments
Link
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

Compose의 단계 본문

Android

Compose의 단계

어리둥절범고래 2025. 2. 9. 12:36
반응형

Compose는 Android 뷰 시스템처럼 측정, 레이아웃, 그리기 단계가 있지만, 그 전에 중요한 컴포지션 단계가 추가됩니다.

 

 

 

프레임의 세 단계

 

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

 

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

Compose에서 데이터를 UI로 변환하는 세 단계

 

Compose는 데이터가 컴포지션에서 레이아웃, 그리기 순서로 이동하는 단방향 데이터 흐름을 따릅니다.

(BoxWithConstraints, LazyColumn, LazyRow는 하위 요소가 상위 요소의 레이아웃에 따라 달라집니다)

Compose는 성능 최적화를 위해 동일한 입력으로 동일한 결과를 계산하는 반복을 피하며, 필요한 경우에만 전체 트리를 다시 그리거나 배치합니다. 이전 결과를 재사용하고, UI 업데이트에 최소한의 작업만 수행하는 이유는 Compose가 상태 읽기를 추적하기 때문입니다.

 

 

 

Composition 컴포지션

 

컴포지션 단계에서 Compose 런타임은 @Composable 함수를 실행하고 UI를 나타내는 트리 구조를 출력합니다.

 

컴포지션 단계에서 생성된 UI를 나타내는 트리

 

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

해당 코드가 포함된 UI 트리의 하위 섹션

 

코드의 각 @Composable 함수는 UI 트리의 단일 레이아웃 노드에 매핑됩니다.

 

 

 

Layout 레이아웃

 

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

레이아웃 단계에서 UI 트리의 각 레이아웃 노드를 측정하고 배치한다.

 

레이아웃 단계에서 트리는 세 가지 알고리즘을 사용해 경로를 탐색합니다.

  • 하위 요소 측정: 노드가 하위 요소를 측정
  • 자체 크기 결정: 이러한 측정치를 기반으로 노드가 자체 크기를 결정
  • 하위 요소 배치: 각 하위 노드는 노드의 자체 위치를 기준으로 배치

이 단계가 끝나면 각 레이아웃 노드는 할당된 width, height와 x, y 좌표를 갖습니다.

 

 

 

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

 

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

Compose 런타임은 UI 트리를 한 번만 통과하여 모든 노드를 측정하고 배치하므로 성능이 향상됩니다. 트리의 노드 수가 많아지면 탐색 시간이 선형적으로 증가하고, 노드를 여러 번 방문하면 탐색 시간이 기하급수적으로 늘어납니다.

 

 

 

Drawing 그리기

 

그리기 단계에서는 트리가 위에서 아래로 다시 탐색되고 각 노드가 차례로 화면에 그려집니다.

 

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

 

  1. Row는 배경 색상과 같은 콘텐츠를 그립니다.
  2. Image가 자체적으로 그립니다.
  3. Column가 자체적으로 그립니다.
  4. 첫 번째와 두 번째 Text는 각각 자체를 그립니다.

 

 

 

상태 읽기

 

상태 값이 바뀌면 UI도 그에 맞춰 갱신됩니다. 이때, 상태 값을 읽을 때 Compose는 어떤 작업을 하고 있는지 추적하여, 상태 값이 변경되었을 때 필요한 부분만 다시 실행하도록 합니다.

상태는 일반적으로 mutableStateOf()로 생성되며, value 속성에 직접 접근하거나 Kotlin 속성 위임을 통해 액세스할 수 있습니다.

// value 속성에 직접 접근하여 상태 읽기
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// 속성 위임을 사용하여 상태 읽기
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

속성 위임에서 'getter'와 'setter' 함수는 상태의 value에 접근하고 업데이트하는 데 사용됩니다. 이 함수들은 속성이 참조될 때만 호출되며, 속성이 생성될 때는 호출되지 않습니다. 따라서 위의 두 메서드는 동일합니다.

상태가 변경되면 다시 실행되는 코드 블록은 다시 시작 범위(restart scope)이며, Compose는 세 단계에서 걸쳐 상태 값 변경 및 restart scope를 추적합니다.

 

 

 

단계적 상태 읽기


Compose에는 세 개의 주요 단계가 있고 Compose는 각 단계 내에서 읽은 상태를 추적합니다.

이를 통해 Compose는 영향을 받는 각 UI 요소에서 작업을 실행해야 하는 특정 단계만 알릴 수 있습니다.


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

 

 

1단계: Composition 컴포지션


@Composable 함수나 람다 블록 내의 상태 읽기는 컴포지션 및 이후 단계에 영향을 미칩니다. 상태 값이 변경되면 Recomposer는 이 상태 값을 읽는 모든 @Composable 함수의 재실행을 예약합니다. 입력이 변경되지 않은 경우 런타임에서 @Composable 함수의 일부 또는 모두를 건너뛸 수 있습니다.

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

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // padding 상태는 컴포지션 단계에서 읽힙니다.
    // 수정자가 생성될 때 이 상태가 읽힙니다.
    // padding의 변경은 리컴포지션을 유발합니다.
    modifier = Modifier.padding(padding)
)

 

 

2단계: Layout 레이아웃


레이아웃 단계는 측정과 배치로 구성됩니다. 측정 단계에서는 Layout 컴포저블의 측정 람다와 LayoutModifier의 MeasureScope.measure 메서드 등이 실행되며, 배치 단계에서는 layout 함수의 배치 블록과 Modifier.offset { … } 람다 블록 등이 실행됩니다.

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

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

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // `offsetX` 상태는 배치 단계에서 읽혀집니다.
        // 오프셋이 계산될 때 레이아웃 단계입니다.
        // `offsetX`를 변경하면 레이아웃이 다시 시작됩니다.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

 

 

3단계: Drawing 그리기


그리기 코드 중 상태 읽기는 그리기 단계에 영향을 미칩니다. 

일반적인 예로는 Canvas(), Modifier.drawBehind, Modifier.drawWithContent가 있습니다. 상태 값이 변경되면 Compose UI는 그리기 단계만 실행합니다.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // 'color' 상태는 그리기 단계에서 읽혀집니다.
    // 캔버스가 렌더링될 때.
    // `color`가 변경되면 그리기가 다시 시작됩니다.
    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()에 전달하기 때문에, 스크롤할 때마다 전체 UI가 리컴포지션됩니다. 그 결과, 불필요한 측정과 배치가 발생해 성능이 저하될 수 있습니다.

이를 방지하려면 상태 값을 레이아웃 단계에서 읽도록 수정해야 합니다. 이를 위해 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가 최소한의 작업만 수행하도록 하는 것입니다. 물론, 일부 경우에는 컴포지션 단계에서 상태를 읽어야 하지만, 이때도 리컴포지션을 최소화하는 방법을 고려해야 합니다.

 

 

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

 

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로 변환하는 과정이 비효율적입니다.

문제는 한 프레임 내에서 최종 레이아웃을 결정하지 못하는 점입니다. 첫 번째 프레임에서는 imageHeightPx가 0이라 패딩도 0이 설정되고, 이후 프레임에서 이미지 크기가 업데이트되며 리컴포지션이 발생해 UI가 불필요하게 여러 프레임에 걸쳐 이동합니다.

이런 반복을 방지하려면 Column() 같은 적절한 레이아웃 프리미티브를 사용해야 합니다. 복잡한 경우 커스텀 레이아웃을 활용하고, 여러 UI 요소의 관계를 조정할 수 있도록 최소한의 상위 요소를 단일 정보 소스로 유지하는 것이 중요합니다.

 

 

 

 

 

 

 

https://developer.android.com/develop/ui/compose/phases?hl=ko#phased-state-reads

반응형

'Android' 카테고리의 다른 글

Compose의 Side-effects  (1) 2025.02.09
Jetpack Compose 이해하기  (0) 2025.02.08
[Android] Android KTX 잘 알아보기  (0) 2023.01.22