역방향 쓰기

Compose는 엄격하게 순서가 지정된 세 가지 순방향 흐름 단계로 프레임을 실행합니다.

3가지 순차적 단계: 1. 구성, 2. 레이아웃, 3. 그리기
그림 1. Compose 프레임의 세 단계
  1. 컴포지션: @Composable 함수를 실행하여 UI 트리를 빌드하고 업데이트합니다.
  2. 레이아웃: 하위 요소를 측정한 후 배치합니다.
  3. 그리기: 캔버스 그리기 명령어를 내보내 화면에 픽셀을 렌더링합니다.

Compose State가 단계 중에 읽힐 때마다 Compose는 해당 상태와 상응하는 단계 간의 종속 항목을 자동으로 기록합니다.


쓰기가 '뒤로' 이동하는 이유는 무엇인가요?

뒤로 쓰기상태가 읽힌 위치보다 나중 단계에서 수정되거나 다운스트림 범위(즉, 동일한 컴포지션 패스 내에서 순서대로 나중에 실행되는 컴포저블 범위)에서 수정될 때마다 발생하며, Compose가 이전 단계나 컴포저블을 다시 실행하도록 예약하여 리컴포즈하도록 강제합니다. 역방향 쓰기는 최적화되지 않은 리컴포지션 루프입니다.

리컴포지션 루프 단계를 통한 역방향 쓰기 예시
그림 2. 재구성 루프 단계를 통한 역방향 쓰기 예시

역방향 쓰기의 결과

역방향 쓰기가 반드시 나쁜 것은 아니며 항상 비정상 종료를 트리거하는 것도 아니지만 비효율적이며 다음과 같은 여러 방식으로 앱 성능에 해를 끼칠 수 있습니다.

  • 추가 프레임 렌더링 및 프레임 누락: 뒤로 쓰기로 인해 Compose가 연속 프레임에서 중복된 컴포지션 패스를 실행하여 CPU 및 GPU 리소스를 낭비하고 잠재적으로 버벅거림이 발생합니다.
  • 첫 번째 프레임 정확성 문제: 구성요소에서 최종 크기나 상태를 해결하기 위해 역방향 쓰기가 필요한 경우 첫 번째 프레임이 잘못되거나 기본값 또는 미해결 데이터 (예: 크기가 0이거나 오프셋이 잘못됨)로 렌더링됩니다. 이로 인해 두 번째 프레임이 렌더링될 때 시각적 팝핑이나 레이아웃 깜박임이 표시됩니다.
  • 무한 리컴포지션 루프: 상태 변경으로 레이아웃 크기가 변경되고 레이아웃 크기가 새 값을 상태에 계속 다시 쓰는 경우 화면이 안정화되지 않고 프레임마다 계속 리컴포지션되는 무한 프레임 루프가 생성될 수 있습니다.

단계를 통해 흐름 전달

상태 변경은 항상 단계를 통해 앞으로 흘러야 합니다.

읽기 단계 쓰기 컨텍스트 허용 여부 이유
레이아웃 (Modifier.offset { }) 구도 컴포지션이 상태를 업데이트함 → 레이아웃이 리컴포지션 없이 동일한 프레임에서 나중에 상태를 읽음
추첨 (graphicsLayer { }, drawBehind { }) 구도 컴포지션이 상태를 업데이트함 → 그리기에서 최종 단계에서 상태를 읽음 컴포지션과 레이아웃이 완전히 건너뛰어집니다.
그리기 레이아웃 레이아웃이 상태를 업데이트하고 그리기에서 이를 읽습니다(유효한 흐름).
구도 상태 변경을 유도하는 이벤트 콜백 (onClick, onValueChange) 참고: 레이아웃 콜백은 이벤트로 간주되지 않습니다. 이벤트는 컴포지션을 유도하는 데 사용되는 상태를 변경합니다. 이벤트가 프레임 외부에서 발생하는 경우 (Composition, Layout, Draw에 없음) 유효합니다.
구도 코루틴 (LaunchedEffect) 예(주의 필요) 수명 주기/이벤트에 대한 응답으로 비동기적으로 상태를 업데이트합니다. 효과에서 쓰기가 유효할 수 있지만 비효율적인 상태 레이어링을 나타낼 수 있습니다. 가능한 경우 사용하지 않는 것이 좋습니다.
배치 (레이아웃) 측정 (레이아웃) 레이아웃에서는 상태를 업데이트한 다음 배치에서 해당 상태를 읽는 것이 허용됩니다.
측정 (레이아웃) 배치 (레이아웃) 아니요 - 역방향 쓰기 배치에서 상태를 작성한 후 읽기 상태에서 더 작성하면 다시 측정 루프가 발생합니다.
구도 레이아웃 (onSizeChanged, LayoutModifier) 아니요 - 뒤로 쓰기 레이아웃으로 인해 컴포지션 → 리컴포지션 루프가 무효화됩니다.
구도 추첨 (drawWithContent, Canvas) 아니요 - 역방향 쓰기 그리기는 컴포지션 → 리컴포지션 루프를 무효화합니다.

위상 조합: 뒤로 대 앞으로

다음은 Compose 내에서 역방향 쓰기의 예와 이를 해결하는 방법을 보여줍니다.

뒤로: 컴포지션에서 읽고 레이아웃에서 쓰기

  • 발생하는 일: 컴포지션은 componentHeight를 읽어 어떤 UI를 내보낼지 결정합니다. 프레임 후반에 레이아웃 단계에서 뷰를 측정하거나 배치하고 componentHeight에 새 값을 씁니다 (예: onSizeChanged, onGloballyPositioned 또는 맞춤 LayoutModifier 사용).
  • 결과: 레이아웃에서 componentHeight를 수정하면 방금 완료된 컴포지션 단계가 무효화됩니다. onSizeChanged은 레이아웃 측정 패스가 완료된 후 크기를 보고합니다. 업데이트된 상태 값이 다음 패스에서 안정화되면 리컴포지션이 하나의 추가 프레임 후에 중지될 수 있습니다. 하지만 새 값이 계속 크기를 변경하면 무한 프레임 루프가 발생합니다. 또한 onGloballyPositioned는 레이아웃과 배치 모두 후에 실행되므로 그 안의 상태 쓰기는 연속 프레임에서 지속적인 리컴포지션 및 리레이아웃 루프에 훨씬 더 취약합니다.

// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged)
@Composable
fun BadAspectRatioImage(painter: Painter) {
    var calculatedHeight by remember { mutableStateOf(0.dp) }
    val density = LocalDensity.current

    // State read during COMPOSITION:
    Image(
        painter = painter,
        contentDescription = "Dynamic Image",
        modifier = Modifier
            .fillMaxWidth()
            .height(calculatedHeight)
            .onSizeChanged { size ->
                // State write during LAYOUT phase!
                // Triggers backwards write and recomposition pass
                val aspectRatio = 16f / 9f
                val widthDp = with(density) { size.width.toDp() }
                calculatedHeight = widthDp / aspectRatio
            }
    )
}

// ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition
@Composable
fun GoodAspectRatioImage(
    painter: Painter,
    aspectRatio: Float = 16f / 9f,
    modifier: Modifier = Modifier
) {
    Layout(
        content = {
            Image(
                painter = painter,
                contentDescription = "Dynamic Image"
            )
        },
        modifier = modifier
    ) { measurables, constraints ->
        val width = constraints.maxWidth
        val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio()
        val imageConstraints = constraints.copy(
            minWidth = width,
            maxWidth = width,
            minHeight = height,
            maxHeight = height
        )
        val placeable = measurables.first().measure(imageConstraints)
        layout(width, height) {
            placeable.placeRelative(0, 0)
        }
    }
}

뒤로: 컴포지션에서 읽기, 그리기에서 쓰기

  • 발생하는 문제: 상태가 컴포저블 본문 (컴포지션 단계)에서 읽히지만 Modifier.drawWithContent, Modifier.drawBehind 또는 Canvas (그리기 단계) 내에서 변형됩니다.
  • 결과: 그리기 단계에서 상태가 변경됨 → 컴포지션이 무효화됨 → 무한 루프

// ❌ BAD: Read in Composition, Written in Draw ()
@Composable
fun BadBackwardsWriteDraw() {
    var componentHeight by remember { mutableStateOf(0.dp) }
    // State read during COMPOSITION:
    Text(
        text = "Height is: $componentHeight",
        modifier = Modifier.drawBehind {
            // State write during the DRAW phase!
            // Invalidates Composition -> triggers recomposition loop!
            componentHeight = size.height.dp
        }
    )
}

뒤로: 컴포지션에서 읽기, 컴포지션에서 쓰기 (동일한 단계)

  • 발생하는 상황: 컴포저블 함수에서 count을 읽고 읽은 후 다른 컴포저블 콘텐츠 슬롯에서 count을 직접 수정합니다.
  • 결과: 스냅샷 시스템은 동일한 컴포지션 패스 내에서 읽기와 후속 쓰기를 기록하여 즉시 현재 범위를 무효화합니다.

// ❌ BAD: Direct write in Composable body after read
@Composable
fun BadCounter() {
    var count by remember { mutableIntStateOf(0) }
    Text("Count: $count") // State read in Composition
    Button(onClick = {}) {
        count++ // State write in Composition (Backwards write!)
    }
}
// Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read
@Composable
fun OkCounter() {
    var count by remember { mutableIntStateOf(0) }
    Button(onClick = {}) {
        count++ // State  write in Composition
    }
    Text("Count: $count") // State read in Composition
}


역방향 쓰기를 방지하는 주요 규칙

  1. 컴포지션에서 상태를 읽는 경우 onGloballyPositioned, onSizeChanged 또는 LayoutModifier에서 상태에 쓰지 마세요. 이렇게 하면 첫 번째 프레임 정확성 문제가 발생합니다.
    • 맞춤 그리기에만 레이아웃 좌표나 크기가 필요한 경우 그리기 또는 레이아웃 단계에서 직접 읽습니다 (예: Modifier.drawWithCache 또는 Modifier.layout 사용).
    • 창 수준 크기 조정 (WindowWidthSizeClass): 호이스트 크기 관찰을 창 수준으로 올립니다. 컴포지션은 로컬 측정이 발생하기 전에 창 크기 클래스에서 분기됩니다.
    • 컴포지션 균일성 유지: 컴포지션에서 사용되는 상태를 재구성하거나 변경하지 않고 2단계에서 측정 및 배치를 조정하는 단일 맞춤 레이아웃이나 FlowRow, LazyVerticalGrid과 같은 구성요소를 사용합니다.
    • 하위 컴포지션 사용: 하위 컴포저블이 로컬 너비 또는 높이에 따라 분기해야 하는 경우 BoxWithConstraints 또는 SubcomposeLayout를 사용합니다. 주의: 하위 컴포지션에는 성능 비용이 발생하며 일반적으로 피할 수 있습니다.
    • 최후의 수단: 첫 번째 프레임이 잘못되도록 허용하고 onSizeChanged에 크기를 저장하여 두 번째 프레임 리컴포지션을 트리거합니다. 이로 인해 눈에 띄는 레이아웃 팝핑, 버벅거림, 무한 루프 위험이 발생합니다.
  2. 컴포지션에서 처음 읽은 후 상태를 변경하지 마세요:
    • SideEffect 외부의 구성 중에 MutableState 객체에 안전하게 쓸 수 있지만 구성에서 이전에 읽었을 수 있는 상태에 쓰지 않도록 특별히 주의하세요. 컴포지션에서 상태 쓰기가 필요한 경우 rememberUpdatedState를 사용하는 것이 좋습니다. 컴포지션 중에 다른 방식으로 상태에 쓰는 것은 일반적으로 효과가 누락되었거나 상태 또는 컴포저블이 부적절하게 설계되었음을 나타냅니다. 컴포지션은 낙관적이며 항상 상태의 최신 값으로 실행되므로 리컴포지션에서 모든 상태 변경사항이 표시되지 않을 수 있습니다. UI 업데이트는 일회성 이벤트를 처리하는 방법으로 사용하면 안 되므로 리컴포즈의 결과로 상태 값을 업데이트해야 하는 경우는 드뭅니다.
    • 컴포지션 외부에서 관찰되는 상태 (예: ViewModel 필드 또는 isVisible 플래그)를 변경하지 마세요. 컴포지션에 영향을 미치는 상태 쓰기는 이벤트 람다 (onClick), 코루틴(LaunchedEffect) 또는 부수 효과 (SideEffect)에 속합니다. rememberUpdatedState@Composable 본문에서만 사용되는 상태를 변경하도록 설계되었으므로 예외입니다.
  3. 가능한 가장 늦은 단계로 상태 읽기 지연:
    • 그리기 (Modifier.graphicsLayer { alpha = ... }) 또는 레이아웃 (Modifier.offset { IntOffset(...) })에서 상태를 읽으면 변경사항이 2단계 또는 3단계만 무효화되어 1단계 (컴포지션)가 완전히 건너뛰어집니다.