[Jetpack Compose] Donut-hole Skipping을 통한 최적화
Defer Read(지연 읽기)
지연 읽기라는 개념은 Compose에만 국한되지는 않는다. 다만, Compose에서 불필요한 Recomposition을 최적화하기 위한 방법으로 활용할 수 있다. 이를 이해하기 위해서는 몇 가지 사전 개념에 대한 이해가 필요하다.
Recomposition의 범위
상태가 변하면 Recomposition이 일어나면서 UI를 다시 그린다. Composable은 UI 트리 형태로 그려지기 때문에 상태가 변경되면 그 중 가장 가까운(바로 상위의) Composable Scope만 invalidation이 일어난다.
다음 코드를 보자. 매우 흔하게 사용되는 패턴으로 ViewModel에서 state를 결정하고 Screen에서 구독하고 있는 형태이다. 이 상태에서 uiState의 값이 변하면 어떤 Composable이 다시 그려질까? uiState를 포함하고 있는 가장 가까운 Composable Scope인 MyScreen이 다시 그려진다.
MyScreen이 다시 그려지기 때문에 MyScreen의 하위 Composable인 Column, Title, Button이 모두 recomposition된다. 물론, Compose의 smart recomposition으로 인해 안정적이거나 immutable한 경우, recomposition이 생략된다. 하지만 극단적으로 uiState가 Title에서만 사용된다면 Title만 recomposition이 일어나는 것이 가장 이상적이지만 uiState의 위치로 인해 MyScreen 전체가 recomposition이 발생하는 비효율적인 상황이 발생할 수 있다.
@Composable
fun MyScreen(viewModel: MyViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifeCycle()
Column {
Title(uiState.title)
Button(onClick = { ... }) {
Text("Click")
}
}
}
@Composable
fun Title(title: String) { ... }
추가적으로 BoxScope와 같이 inline 함수로 구성된 경우에는, 해당 함수가 inline이기 때문에 inline을 포함하고 있는 가장 가까운 Composable Scope까지 recomposition이 발생한다.
위와 같은 비효율적인 상황을 어떻게 해결하면 좋을까?
람다를 이용
람다는 실행 가능한 코드 조각을 값처럼 다루는 것이다. 다음과 같은 예시 코드를 보자. 아래 코드에서 람다식인 x + 1은 lazyEval 내부의 block()이 호출되기 전까지 계산되지 않는다. 이를 Defer Read(지연 읽기)라고 한다.
fun lazyEval(block: () -> Int) {
println("Not yet!")
val result = block() // ❗️ 이 시점에 실행됨
println("Value = $result")
}
val x = 42
lazyEval { x + 1 } // 아직 실행 안 됨
이러한 람다를 이용해 불필요한 recomposition을 없앨 수 있다. 대표적인 예시로 scroll offset의 값을 다음과 같이 그대로 넘겨버리면 불필요한 recomposition이 발생할 수 있다. Jetpack Compose는 UI를 그릴 때 Composition -> Layout -> Draw 단계를 거치는데, 아래 코드에서 scrollY.dp는 Composition 시점에 읽히기 때문에 scrollY.dp의 값이 변하면 MyScreen 전체가 recomposition이 발생하면서 성능 측면에서 낭비가 커진다. 또한 scroll의 offset이 변하는 작업은 단순히 Layout이 변하는 작업이기 때문에 recomposition이 발생할 필요가 없다.
@Composable
fun MyScreen(scrollY: Int) {
Box(
modifier = Modifier
.offset(y = scrollY.dp) // composition 시점에 상태를 읽음
) {
Text("Hello, I'm scrollable")
}
}
반면 아래 코드와 같이 람다를 이용하면 scrollProvider()의 값을 Composition 시점에 읽지 않고 Layout 시점에 읽는다. 이유는 Compose가 UI를 그릴 때 Layout 단계에서 placeAt() 함수를 호출하는데, 해당 함수에서 scrollProvider()가 지연호출되기 때문이다. 람다가 가지고 있는 실제 사용 시점에 호출된다는 점을 이용해 Composition 시점에 값을 읽지 않아 y offset의 값이 변하더라도 recomposition이 발생하지 않는다.
@Composable
fun MyScreen(scrollProvider: () -> Int) {
Box(
modifier = Modifier
.offset {
IntOffset(x = 0, y = scrollProvider())
}
) {
Text("Hello, I'm scrollable")
}
}
Scroll 뿐만 아니라 색상을 변경하거나, 애니메이션 효과를 부여하는 경우 람다를 이용한 Defer Read를 사용하면 불필요한 Composition 단계를 줄이고 바로 Layout이나 Draw 단계를 거칠 수 있기 때문에, 이러한 경우에 불필요한 recomposition이 발생하고 있지는 않은지 유념해야 한다.
Donut-hole Skipping
하위 Composable의 상태만 변경되면, 상위 Composable은 건너뛰고 하위 Composable만 recomposition하는 Compose의 최적화 기술이다. 상위(바깥쪽) Composable은 건너뛰고 하위(안쪽) Composable만 recomposition 되는 구조가 도넛의 모양과 유사하여 Donut-hole Skipping이라고 부른다.
예를 들어 다음과 같은 코드를 보면, 최초 Composition에서는 당연히 Parent, Wrapper, Child가 모두 실행이 된다. 이 때 counter의 값이 바뀌면, counter와 가장 가까운 Composable scope인 Child만 recomposition이 발생하고 Parent와 Wrapper는 recomposition이 발생하지 않는 Donut Hole Skipping이 발생한다.
@Composable
fun Parent() {
println("Parent recomposed")
Wrapper {
println("Wrapper recomposed")
Child()
}
}
@Composable
fun Wrapper(content: @Composable () -> Unit) {
Column {
content()
}
}
@Composable
fun Child() {
val counter = remember { mutableStateOf(0) }
Button(onClick = { counter.value++ }) {
Text("Count = ${counter.value}")
}
}
이러한 Donut-hole Skipping을 통해 recomposition 최적화가 유지되기 위해서 가능한 상태(State)는 하위 Composable이 직접 읽어야 하고, 만약 상위 Composable을 통해 상태를 전달받아야 한다면 람다를 통해 Defer Read가 되도록 하여 하위 Composable에서만 recomposition이 발생하도록 해야 한다. 또한 Box와 같은 inline scope도 있기 때문에 위 코드의 Wrapper Composable 같은 pass-through 구조를 사용하여 상위 Composable의 불필요한 recomposition을 없애는 방법을 생각해볼 수 있다.
Compose로 UI를 작성할 때, Donut-hole Skipping을 이용해 최적화를 설계해보지 않았던 것 같은데 이러한 부분들을 고려해 코드를 개선하고 앞으로 새로운 화면을 설계할 때 반영해볼 수 있을 것 같다.