1. 시작하며

Jetpack Compose실제 성능 문제 해결 9페이지에 Composable함수 값 주입을 필드에서 람다로 전환함으로써 불필요한 Composition단계를 피하고 있다. 기존의 코드는 아래와 같으며, 간단한 설명을 해보자면

  1. Button클릭 및 targetSize 상태값 변경
  2. targetSize변경이 size 상태값을 변경
  3. size상태값이 MyShape Composable함수로 주입
  4. 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)
  )
}

2. 문제점: 무수히 많은 리컴포지션

동적으로 변하는 size: Dp상태값에 따라, Modifier.size()를 호출해주고 있다. 해당 함수는 Compose의 Frame Rendering 3단계 중, Composition단계때 상태값을 읽는 함수이다. 따라서, Layout단계때 size상태값 변경이 일어났다 하더라도 이를 읽어들이는 시점이 Composition단계이기에 불필요한 프레임 렌더링을 1번 더 반복하게 된다.

Compose의 Frame Rendering은 UDF임.

출처 : (안드로이드 공식 홈페이지) Jetpack Compose단계

3. 해결책: 상태값 읽기 최적화

불필요한 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)
        }
      }
  )
}

4. 해결책: 참조값 변경에 따른 리컴포지션 제거

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작업 방지가 가능하기 때문이다.

특히, Lambdastable한 값을 캡쳐했다면, Compose CompilerLambdaremember(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 Runtimeunstable한 상태 변수를 무조건적인 Recomposition범위에 포함했던게, 지금은 Reference Equality (===)비교를 진행하여 Recomposition여부를 결정한다. 또한 stable한 상태 변수는 Structure Equality (Object.equals())비교를 진행해 Recomposition여부를 결정한다.

1. 파라미터가 Field일 때 : 불필요 Composition단계 진행

로그를 찍어 직접 확인해보자. 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를 사용하는것이 해답이다.

2. 파라미터가 Lambda일 때 : Composition단계 생략

아래 로그 결과를 보자. 아래는 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)
)

(추가) 3. 람다가 주입되더라도 참조값이 바뀐다면 어떻게될까?

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)
  )
}

5. 정리

Compose의 Frame Rendering 3단계 중, 최소 1단계 이상을 건너뛰어 성능을 개선하고자 한다면Modifier.layout { ... }, Modifier.drawBehind { ... }등의 함수에서 상태값 읽기가 필요하며, 이는 불필요한 Recomposition을 막는 것에서부터 시작한다. 이를 위해, 주입되는 상태값의 Referencial Equality 비교가 true로 떨어질 수 있도록 하는 Lambda타입을 고려해야 한다.

profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

2개의 댓글

comment-user-thumbnail
2025년 6월 17일

람다 형태로 값을 주입하는 것이 최적화에 도움이 된다는 것만 알고 그 이유를 몰랐었는데 잘 읽었습니다!

1개의 답글
Powered by GraphCDN, the GraphQL CDN