시배's Android

Compose Docs | Jetpack Compose Phases 본문

Android/Compose Docs

Compose Docs | Jetpack Compose Phases

si8ae 2023. 8. 10. 22:51
 

Jetpack Compose 단계  |  Android Developers

Jetpack Compose 단계 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 대부분의 다른 UI 도구 키트와 마찬가지로 Compose는 몇 가지 고유한 단계를 통해 프레임을 렌

developer.android.com

The three phases of a frame 

  • Composition : 어떤 UI를 표시할지 결정합니다. Compose는 구성 가능한 함수를 실행하고 UI의 설명을 생성합니다.
  • Layout : UI를 어디에 배치할지 결정합니다. 이 단계는 측정(measurement)과 배치(placement) 두 단계로 구성됩니다. 레이아웃 요소는 2D 좌표로 자신과 모든 하위 요소를 측정하고 배치합니다. 이는 레이아웃 트리의 각 노드마다 수행됩니다.
  • Draw : 어떻게 렌더링할지 결정합니다. UI 요소는 일반적으로 디바이스 스크린과 같은 캔버스에 그려집니다.

이러한 단계의 순서는 일반적으로 동일하며, 데이터가 구성에서 레이아웃으로 그리고 렌더링으로 한 방향으로 흐르도록 합니다. BoxWithConstraints 및 LazyColumn 및 LazyRow는 부모의 레이아웃 단계에 따라 자식의 구성이 달라지는 주목할만한 예외입니다.

 

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

Phased state reads

Compose에는 가지 주요 단계가 있으며, Compose 단계 내에서 어떤 상태가 읽혔는지를 추적합니다. 이를 통해 Compose UI 영향받는 요소마다 작업을 수행해야 하는 특정 단계만을 알릴 있습니다.

단계 1: Composition

@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

레이아웃 단계는 측정(measurement)과 배치(placement) 두 단계로 구성됩니다. 측정 단계는 Layout composable에 전달된 measure 람다, LayoutModifier 인터페이스의 MeasureScope.measure 메서드 등을 실행합니다. 배치 단계는 레이아웃 함수의 배치 블록, 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: Drawing

그리기 코드 내에서의 상태 읽기는 그리기 단계에 영향을 줍니다. 일반적인 예로는 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)
}

Optimizing state reads

Compose는 지역화된 상태 읽기 추적을 수행하기 때문에 각 상태를 적절한 단계에서 읽음으로써 수행되는 작업 양을 최소화할 수 있습니다.

다음은 예제를 살펴보겠습니다. 여기서는 Image() 사용하여 최종 레이아웃 위치를 오프셋하는 offset modifier 사용합니다. 이로 인해 사용자가 스크롤할 패럴랙스 효과가 발생합니다.

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를 생성할 수 있게 해줍니다.

그러나 이 예제는 비효율적입니다. 왜냐하면 모든 스크롤 이벤트가 전체 구성 가능한 내용을 다시 평가하고, 측정하며, 레이아웃을 배치하고, 마지막으로 그리는 결과로 이어지기 때문입니다. 우리가 표시하는 내용은 변경되지 않았고 위치만 변경되었음에도 불구하고 매 스크롤 이벤트마다 Compose 단계가 트리거됩니다. 우리는 레이아웃 단계만 다시 트리거되도록 상태 읽기를 최적화할 수 있습니다.

Modifier.offset의 또 다른 버전이 있습니다: 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가 레이아웃 및 그리기 단계만 다시 시작하면 된다는 것을 의미합니다.

참고: 람다 매개변수를 사용하는 것이 간단한 값 사용보다 추가 비용이 들 수 있다는 생각이 드실 수 있습니다. 실제로 그렇습니다. 그러나 이 경우 레이아웃 단계로 상태 읽기를 제한하는 이점이 비용을 상쇄시킵니다. firstVisibleItemScrollOffset 값은 스크롤 중 매 프레임마다 변경되며, 상태 읽기를 레이아웃 단계로 미루면 재구성을 전반적으로 피할 수 있습니다.

 

이 예제는 다양한 오프셋 수정자를 사용하여 결과 코드를 최적화하는 데 의존하지만, 일반적인 아이디어는 맞습니다. 가능한 한 낮은 단계로 상태 읽기를 지역화하여 Compose가 최소한의 작업을 수행할 수 있도록 하는 것입니다.

 

물론 구성 단계에서 상태를 읽는 것이 절대적으로 필요한 경우가 많습니다. 그럼에도 불구하고 상태 변경을 필터링하여 재구성 수를 최소화할 수 있는 경우가 있습니다. 이에 대한 자세한 내용은 derivedStateOf: 하나 또는 여러 상태 객체를 다른 상태로 변환하기를 참조하십시오.

 

Recomposition loop (cyclic phase dependency)

우리는 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() }
        )
    )
}

 

여기에서는 (잘못된 방식으로) 이미지가 상단에 있고 아래에 텍스트가 있는 세로 열(column)을 구현했습니다. 이미지의 해결된 크기를 알기 위해 Modifier.onSizeChanged()를 사용하고, 그런 다음 텍스트에 Modifier.padding()을 사용하여 아래로 이동시킵니다. Px에서 Dp로의 비자연스러운 변환이 이미 코드에 문제가 있음을 나타냅니다.

이 예제의 문제점은 하나의 프레임 내에서 "최종" 레이아웃에 도달하지 못한다는 것입니다. 코드는 여러 프레임이 발생하는 것에 의존하며 불필요한 작업을 수행하며 사용자의 화면에서 UI가 이동하게 됩니다.

각 프레임을 따라가며 무엇이 일어나는지 살펴보겠습니다:

첫 번째 프레임의 구성 단계에서 imageHeightPx의 값은 0이고, 텍스트에는 Modifier.padding(top = 0)이 제공됩니다. 그런 다음 레이아웃 단계가 이어지고, onSizeChanged 수정자의 콜백이 호출됩니다. 이때 imageHeightPx가 이미지의 실제 높이로 업데이트됩니다. Compose는 다음 프레임을 위한 재구성을 예약합니다. 그리기 단계에서 값 변경이 아직 반영되지 않으므로 텍스트는 패딩이 0인 상태로 렌더링됩니다.

그런 다음 imageHeightPx의 값이 변경되어 예정된 두 번째 프레임을 시작합니다. 상태는 Box 내용 블록에서 읽혀지며 구성 단계에서 호출됩니다. 이번에는 텍스트가 이미지 높이와 일치하는 패딩으로 제공됩니다. 레이아웃 단계에서 코드는 다시 imageHeightPx의 값을 설정하지만, 값이 동일하므로 재구성이 예약되지 않습니다.

결국 원하는 패딩을 얻을 있지만, 패딩 값을 다른 단계로 다시 전달하는 여분의 프레임을 사용하고 겹치는 콘텐츠를 생성하는 결과를 가져올 있습니다.

  • Modifier.onSizeChanged(), onGloballyPositioned() 또는 다른 레이아웃 작업 수행
  • 어떤 상태(state) 업데이트
  • 그 상태를 레이아웃 수정자(padding(), height() 등)의 입력으로 사용
  • 필요한 경우 반복

위 예제의 수정은 적절한 레이아웃 기본 요소를 사용하는 것입니다. 위 예제는 간단한 Column()을 사용하여 구현할 수 있지만, 사용자 정의 요소가 필요한 더 복잡한 예제가 있을 수 있으며 이 경우 사용자 정의 레이아웃을 작성해야 할 수도 있습니다. 

여기에서의 일반적인 원칙은 여러 UI 요소에 대해 측정하고 배치해야 단일 참값(source of truth) 갖는 것입니다. 적절한 레이아웃 기본 요소를 사용하거나 사용자 정의 레이아웃을 만드는 것은 최소한의 공유 부모가 여러 요소 간의 관계를 조정할 있는 참값(source of truth) 역할을 하도록 하는 원칙을 따릅니다. 동적 상태를 도입하면 원칙이 깨질 있습니다.