Compose 기초 - Compose 프레임의 3단계 최적화
Compose 프레임의 3단계 (원문)
💡개념이 헷갈리거나 제가 잘 모르거나 많은 사람들이 잘 모를 것 같은 것 위주로 정리
Compose 프레임에서의 3단계
Android 뷰 시스템의 프레임에서 실행되는 3단계
- 1단계 측정
- 2단계 레이아웃
- 3단계 그리기
Compose의 프레임에서 실행되는 3단계
- 1단계 Composition : Composable들을 실행하고 UI설명을 만든다.
- 2단계 Layout : 레이아웃 트리에 있는 각 노드를 2D 좌표로 측정하고 배치한다.
- 3단계 Drawing : UI가 캔버스에 그려진다.
상태 읽기에 따라 Composition, Layout, Drawing 단계를 건너뛴다.
Composition 단계 건너뛰기(=Smart Recomposition)
@Composable
함수 안에서의 상태 읽기는 Composition과 이후 단계에 영향을 미친다.
- 상태 값이 변경되면 Recomposer는 이 상태 값을 읽는 모든
@Composable
함수의 재실행을 예약한다. - 입력이 변경되지 않은 경우 런타임에서
@Composable
함수의 일부 또는 모두를 건너뛸 수 있다. - 크기값이나 Composable Tree가 변경되지 않았다면 다음 단계인 Layout 단계를 건너뛸 수 있다.
Layout 단계 건너뛰기
Layout 단계는 Measurement과 Placement라는 두 단계로 구성된다.
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)
}
)
- 상태 값이 변경되면 Compose UI는 Layout단계를 예약한다.
- Placement 단계의 상태 읽기로 인해 Measurement 단계를 다시 시작시킬 수 있다.
- 크기와 위치가 변경되지 않았다면 다음 단계인 Drawing 단계를 건너뛸 수 있다.
Drawing 단계 건너뛰기
Canvas
Composable, Modifier.drawBehind()
, Modifier.drawWithContent()
에서 사용하는 상태값이 변경되면 Drawing 단계가 재실행 된다.
var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
drawRect(color)
}
람다{…}를 써서 프레임 단계 최적화 하기
최적화 전 Composition 단계부터 재시작
- Composition 단계에서 상태 읽기를 시도한다.
- 값이 바뀔 때마다 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 단계부터 재시작
- 람다
{...}
를 사용해 Layout 단계에서 상태읽기를 시도하도록 만든다. - 값이 바뀔 때마다 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 단계부터 재시작
- Layout 단계에서 상태 읽기를 시도한다.
- 값이 바뀔 때마다 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 되는 것으로 끝나는 단순한 루프다.
최적화 후 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 가이드를 참고한다.