Jetpack Compose실제 성능 문제 해결 9페이지에 Composable함수 값 주입을 필드에서 람다로 전환함으로써 불필요한 Composition
단계를 피하고 있다. 기존의 코드는 아래와 같으며, 간단한 설명을 해보자면
targetSize
상태값 변경targetSize
변경이 size
상태값을 변경size
상태값이 MyShape
Composable함수로 주입MyShape
함수 내, Modifier.size()
호출 및 UI크기를 동적으로 변경private val smallSize = 64.dp
private val bigSize = 200.dp
@Composable
fun PhasesAnimatedShape() = trace("PhasesAnimatedShape") {
var targetSize by remember { mutableStateOf(smallSize) }
val size by animateDpAsState(
targetValue = targetSize,
label = "box_size",
animationSpec = spring(Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessVeryLow)
)
Box(Modifier.fillMaxSize()) {
MyShape(
size = size,
modifier = Modifier.align(Alignment.Center)
)
Button(
onClick = {
targetSize = if (targetSize == smallSize) {
bigSize
} else {
smallSize
}
},
modifier = Modifier
.align(Alignment.TopCenter)
.padding(horizontal = 32.dp, vertical = 16.dp)
) {
Text("Toggle Size")
}
}
}
@Composable
fun MyShape(size: Dp, modifier: Modifier = Modifier) = trace("MyShape") {
Box(
modifier = modifier
.background(color = Purple80, shape = CircleShape)
.size(size)
)
}
동적으로 변하는 size: Dp
상태값에 따라, Modifier.size()
를 호출해주고 있다. 해당 함수는 Compose의 Frame Rendering 3단계 중, Composition
단계때 상태값을 읽는 함수이다. 따라서, Layout
단계때 size
상태값 변경이 일어났다 하더라도 이를 읽어들이는 시점이 Composition
단계이기에 불필요한 프레임 렌더링을 1번 더 반복하게 된다.
Compose의 Frame Rendering은 UDF임.
출처 : (안드로이드 공식 홈페이지) Jetpack Compose단계
불필요한 Frame Rendering 반복을 방지하려면 어떻게 할까? 첫 번째로, Composable함수의 높이/너비/위치와 같은 치수 관련 정보만 변경하고자 한다면 해당 상태값을 Layout
단계때 읽어들이는 것이다. 따라서 첫 번째 수정되어야 할 곳은 MyShape
함수의 Modifier.size()
함수이다. 이를 Modifier.layout { ... }
로 전환함으로써 size
상태값을 Layout
단계때 읽어들이는 것이다.
[Modifier확장함수의 상태값 읽어들이는 시기?]
.offset { ... }
,.layout { ... }
등의 함수는Layout
단계 때 상태값을 읽음.drawBehind { ... }
,.graphicLayer { ... }
등의 함수는Drawing
단계 때 상태값을 읽음
@Composable
fun MyShape(
modifier: Modifier = Modifier,
size: Dp
) = trace("MyShape") {
println("frameRengered, internalComposition")
Box(
modifier = modifier
.background(color = Purple80, shape = CircleShape)
// .size(size) 제거
.layout { measurable, _ ->
val sizePx = size
.roundToPx()
.coerceAtLeast(0)
val constraints = Constraints.fixed(
width = sizePx,
height = sizePx,
)
val placeable = measurable.measure(constraints)
layout(sizePx, sizePx) {
placeable.place(0, 0)
}
}
)
}
MyShape
함수 파라미터가 size: Dp
타입으로, 필드값 형태로 주입받는데, 이는 문제가 있다.
Composable함수는 Recomposition
진행 시, 이전 값과 동등성 비교(Referencial or Structure Equality)를 진행한다. 비교 결과, false라면 Recomposition
을 진행시키고, true라면 건너뛰는 방식이다.
하지만 targetSize
상태값 변경에 따른 size
상태 값은 동등성 비교에서 항상 false가 떨어지게 된다. 왜냐하면 새롭게 갱신 된 size
상태값은 매번 새로운 Dp객체로, Compose Runtime
이 Equality비교에서 false를 계산하기 때문이다. 즉, size
상태값 변경은 MyShape
함수를 불필요하게 Recomposition
하게 된다.
또한 위 코드에서 size
상태값이 Modifier.layout { ... }
단계때 사용되고 있음을 볼 수 있다. 이로 인해 아래와 같이 생각할 수 있다.
"
Composition
단계를 건너 뛰고,Layout
단계 때, 상태값이 읽히는게 아닌가?"
하지만 그렇지 않다. 왜냐하면 size
상태값은 항상 새로운 참조값 형태로 주입받고, 이를 내부 Composable 함수에서 사용하기 때문이다.
따라서 MyShape
컴포저블 함수의 size
상태값은 Lambda
로 넘겨야만 한다. 왜냐하면 Compose함수에 존재하는 Lambda
는 컴포즈 컴파일러가 다르게 컴파일하여 결국, Referencial Equality 비교를 true로 반환 및 불필요한 Recomposition
작업 방지가 가능하기 때문이다.
특히, Lambda
가 stable
한 값을 캡쳐했다면, Compose Compiler
는 Lambda
를 remember(key=...) { ... }
로 래핑하며, 캡쳐한 값은 key
파라미터로 주입한다. 따라서 결국, Lambda
가 캡쳐한 값이 변할 경우, 해당 Lambda
는 재실행 되지만, 새로운 Lambda
객체를 생성하지 않고 기존의 것을 재활용하는 방식으로 실행된다.
// Before compilation
val number: Int = 6
val lambda = { number }
// After compilation
val number: Int = 6
val lambda = remember(number) { { number } }
참고 : 안드로이드 공식 홈페이지
[추가]
Kotlin 2.0.20버전부터StrongSkippingMode
가 기본 true로 바뀌었다. 이로 인해,Compose Runtime
이unstable
한 상태 변수를 무조건적인Recomposition
범위에 포함했던게, 지금은 Reference Equality (===)비교를 진행하여Recomposition
여부를 결정한다. 또한stable
한 상태 변수는 Structure Equality (Object.equals())비교를 진행해Recomposition
여부를 결정한다.
로그를 찍어 직접 확인해보자. MyShape
가 필드값 형태로 값을 주입했을 땐, size: Dp
상태값 변경에 따라 내부 값이 바뀐다. 따라서 Structure Equality가 false로 떨어져 불필요한 Recomposition
이 807회나 진행한걸 볼 수 있다. 즉, size: Dp
라는 필드값이 주입될때마다, Compose Runtime
이 수행하는 Structure Equality가 항상 false를 계산하여 불필요한 Composition
을 야기한다.
val size by animateDpAsState(
targetValue = targetSize,
label = "box_size",
animationSpec = spring(Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessVeryLow)
)
MyShape(
size = size.also { println("frameRengered, externalLambda : [${it.hashCode()}] : ${it}") },
modifier = Modifier.align(Alignment.Center)
)
그럼 어떻게 해야할까? 정답은 Compose Runtime
이 진행하는 Referencial/Structure Equality작업에서 true를 반환하게 만드는 것이고, 이는 참조값이 변하지 않게하는 Lambda
를 사용하는것이 해답이다.
아래 로그 결과를 보자. 아래는 size
파라미터를 Lambda
로 바꾸었는데, 이는 곧 Compose Compiler
가 해당 파라미터를 remember(key...) { ... }
로 Wrapping할 것임을 우린 예측할 수 있다. 이로 인해 Lambda
타입의 참조값은 key
파라미터 변경에도 변하지 않는다. 따라서 MyShape
는 불필요한 Composition
단계를 건너뛰고, Layout
단계만 무효화하여 효율적으로 UI를 그릴 수 있게 된다.
val size by animateDpAsState(
targetValue = targetSize,
label = "box_size",
animationSpec = spring(Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessVeryLow)
)
MyShape(
size = { size }.also { println("frameRengered, externalLambda : [${it.hashCode()}] : ${it}") }
modifier = Modifier.align(Alignment.Center)
)
Lambda
타입이라도 Structure Equality 비교에서 false가 떨어지면 Recomposition
을 트리거한다. 따라서 Lambda
타입으로 선언했다고 끝난게 아니다.
Lambda
를 Wrapping할 때, value
값 지정을 안함으로써 remember
함수를 통한 새로운 객체를 생성하도록 의도했다. 그 결과, Lambda
타입이어도 Recomposition
이 무수히 많이 발생하는걸 볼 수 있다.
val size by animateDpAsState(
targetValue = targetSize,
label = "box_size",
animationSpec = spring(Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessVeryLow)
)
val sizeLambda by remember(size) { mutableStateOf({ size }) }
Box(Modifier.fillMaxSize()) {
MyShape(
size = sizeLambda.also { println("frameRengered, externalLambda : [${it.hashCode()}] : ${it}") }
modifier = Modifier.align(Alignment.Center)
)
}
Compose의 Frame Rendering 3단계 중, 최소 1단계 이상을 건너뛰어 성능을 개선하고자 한다면
Modifier.layout { ... }
,Modifier.drawBehind { ... }
등의 함수에서 상태값 읽기가 필요하며, 이는 불필요한Recomposition
을 막는 것에서부터 시작한다. 이를 위해, 주입되는 상태값의 Referencial Equality 비교가 true로 떨어질 수 있도록 하는Lambda
타입을 고려해야 한다.
잘 읽었습니다!