Compose 기초 - Compose 프레임의 3단계 최적화

Compose 프레임의 3단계 (원문)

💡개념이 헷갈리거나 제가 잘 모르거나 많은 사람들이 잘 모를 것 같은 것 위주로 정리

Compose 프레임에서의 3단계

Android 뷰 시스템의 프레임에서 실행되는 3단계

  • 1단계 측정
  • 2단계 레이아웃
  • 3단계 그리기

Compose의 프레임에서 실행되는 3단계

  • 1단계 Composition : Composable들을 실행하고 UI설명을 만든다.
  • 2단계 Layout : 레이아웃 트리에 있는 각 노드를 2D 좌표로 측정하고 배치한다.
  • 3단계 Drawing : UI가 캔버스에 그려진다.

steps

상태 읽기에 따라 Composition, Layout, Drawing 단계를 건너뛴다.

Composition 단계 건너뛰기(=Smart Recomposition)

@Composable 함수 안에서의 상태 읽기는 Composition과 이후 단계에 영향을 미친다.

  1. 상태 값이 변경되면 Recomposer는 이 상태 값을 읽는 모든 @Composable 함수의 재실행을 예약한다.
  2. 입력이 변경되지 않은 경우 런타임에서 @Composable 함수의 일부 또는 모두를 건너뛸 수 있다.
  3. 크기값이나 Composable Tree가 변경되지 않았다면 다음 단계인 Layout 단계를 건너뛸 수 있다.

Layout 단계 건너뛰기

Layout 단계는 MeasurementPlacement라는 두 단계로 구성된다.

Measurement 단계

Layout Composable에 전달된 측정 람다 @Composable @UiComposable () -> Unit, Modifier.layout {...}으로 전달된 람다 MeasureScope.(Measurable, Constraints) -> MeasureResult 등을 실행한다.

Placement 단계

layout()의 배치 블록, Modifier.offset { … }로 전달된 람다Density.() -> IntOffset 등을 실행한다.

var offsetX by remember { mutableStateOf(8.dp) }

Text(
    text = "Hello",
        modifier = Modifier.offset {
        IntOffset(offsetX.roundToPx(), 0)
    }
)
  1. 상태 값이 변경되면 Compose UI는 Layout단계를 예약한다.
  2. Placement 단계의 상태 읽기로 인해 Measurement 단계를 다시 시작시킬 수 있다.
  3. 크기와 위치가 변경되지 않았다면 다음 단계인 Drawing 단계를 건너뛸 수 있다.

Drawing 단계 건너뛰기

Canvas Composable, Modifier.drawBehind(), Modifier.drawWithContent()에서 사용하는 상태값이 변경되면 Drawing 단계가 재실행 된다.

var color by remember { mutableStateOf(Color.Red) }

Canvas(modifier = modifier) {
    drawRect(color)
}

steps

람다{…}를 써서 프레임 단계 최적화 하기

최적화 전 Composition 단계부터 재시작

  1. Composition 단계에서 상태 읽기를 시도한다.
  2. 값이 바뀔 때마다 Composition 단계부터 재시작된다.
// 최적화되지 않은 코드
Box {
    val listState = rememberLazyListState()
    Image(
        Modifier.offset(
            // Composition 단계에서 listState.firstVisibleItemScrollOffet 값을 읽는다.
            // 따라서 사용자가 스크롤할 때마다 listState.firstVisibleItemScrollOffset값이 변경되면서 Composition단계부터 재실행된다.
            with(LocalDensity.current) {
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )
    LazyColumn(state = listState)
}

최적화 후 Layout 단계부터 재시작

  1. 람다{...}를 사용해 Layout 단계에서 상태읽기를 시도하도록 만든다.
  2. 값이 바뀔 때마다 Layout 단계부터 재시작 되도록 만들 수 있다.
// 최적화가 된 코드
Box {
    val listState = rememberLazyListState()
    Image(
        Modifier.offset {
            // Layout 단계에서 listState.firstVisibleItemScrollOffet 값을 읽는다.
            // 따라서 사용자가 스크롤할 때마다 listState.firstVisibleItemScrollOffset값이 변경되면서 Layout단계부터 재실행된다.
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )
    LazyColumn(state = listState)
}

람다를 사용하면 추가 비용이 발생한다. 하지만 상태 읽기를 레이아웃 단계로 제한하는 이점이 람다 비용보다 더 크다.

상태 읽기를 최소화 해서 프레임 단계 최적화 하기

최적화 전 Composition 단계부터 재시작

  1. Layout 단계에서 상태 읽기를 시도한다.
  2. 값이 바뀔 때마다 Composition 단계부터 재시작된다. 이 과정을 Recomposition Loop라고 부른다.
// 최적화되지 않은 코드
Box {
    Log.e("BSSCCO", "Box")

    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                Log.e("BSSCCO", "onSizeChanged : $size")
                // Layout 단계에서 onSizeChanged의 콜백람다가 호출되고, imageHeightPx값이 0에서 size로 재설정된다.
                imageHeightPx = size.height
            }
    )

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

위 코드에서 Recomposition Loop는 Layout 단계에서 .onSizeChanged {...}가 호출되면서 한 번 Recomposition 되는 것으로 끝나는 단순한 루프다. phases-recomposition-loop

최적화 후 Recomposition 없음.

// 최적화 된 코드
// Box 대신에 Column을 사용했다.
Column {
    Log.e("BSSCCO", "Box")

    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            // .onSizeChanged {...}를 제거했다.
    )

    Text(
        text = "I'm below the image",
    )
}

좀 더 복잡한 Layout을 작성해야 하는 경우엔 Custom Layout 가이드를 참고한다.