Primary/Compose

Compose Strong Skip

해스끼 2024. 5. 5. 21:14

기존에는 composable의 모든 매개변수가 ``@Stable``이어야만 skippable이었다. 그러나 이제는 unstable한 매개변수를 갖는 composable도 skippable이 될 수 있다. 

 

Compose의 성능을 대폭 개선시킬 strong skip에 대해 알아보자.

Strong skip 규칙

이제 모든 restartable composable은 skippable이 될 수 있다. 구체적으로는, 각 매개변수를 다음과 같이 비교한다.

  • Unstable 매개변수는 object equality 연산자인 ``===``로 비교한 결과가 ``true``일 때 (새로 추가된 규칙)
  • Stable 매개변수는 ``Object.equals()``로 비교한 결과가 ``true``일 때 (기존과 같음)

모든 매개변수의 비교 결과가 ``true``일 때 recomposition을 skip할 수 있다.

 

다음 예시를 살펴보자.

@Composable
fun ArticleList(
    articles: List<Article>, // List = Unstable, Article = Stable
    modifier: Modifier = Modifier // Stable
) {
  // …
}

@Composable
fun CollectionScreen(viewModel: CollectionViewModel = viewModel()) {
  var favorite by remember { mutableStateOf(false) }
  Column {
    FavoriteButton(isFavorite = favorite, onToggle = { favorite = !favorite })
    ArticleList(viewModel.articles)
  }
}

``favorite`` 값이 바뀔 때, 기존 규칙대로라면 ``ArticleList``도 무조건 recompose된다. 그러나 strong skip 모드에서는 ``viewModel.articles``에 새로운 객체가 주어지지 않는 한 ``ArticleList``는 recompose되지 않는다.

Lambda 자동 memoization

현재 default 동작은, 내부에서 stable 값만을 참조하는 람다만 자동으로 remember된다. 추가로 ``@Composable`` 람다도 항상 remember된다.

 

Strong skip 모드에서는, unstable 값을 참조하는 람다식도 memoization될 수 있다(remember가 아님에 주의). 즉, 모든 lambda 매개변수가 자동으로 remember된다는 것. 따라서 strong skip이 활성화됐을 때, 아래 코드는

@Composable
fun MyComposable(unstableObject: Unstable, stableObject: Stable) {
  val lambda = {
    use(unstableObject)
    use(stableObject)
  }
}

내부적으로는 다음 코드와 비슷하게 컴파일된다.

@Composable
fun MyComposable(unstableObject: Unstable, stableObject: Stable) {
   val lambda = remember(unstableObject, stableObject) {
     {
       use(unstableObject)
       use(stableObject)
     }
  }
}

``remember`` 함수의 ``key`` 매개변수는 composable에서 unstable/stable 객체를 비교할 때와 동일한 방법으로 비교된다. 주의할 점은, 실제 ``remember`` 함수에서는 모든 매개변수를 ``Object.equals()``로 비교한다는 점.

Deep dive

Compose에서 모든 람다는 stable하다. 다음 코드를 보자.

@Composable
fun NumberComposable(
  current: Long,
  onValueChanged: (Long) -> Unit
) { }

``Long``은 stable하고, ``(Long) -> Unit`` 람다 역시 stable하므로 ``NumberComposable``은 skippable이다. 실제로 컴파일러를 돌려보면 다음과 같이 skippable하다는 결과가 나온다.

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun NumberComposable(
  stable current: Long
  stable onValueChanged: Function1<Long, Unit>
)

이제 ``NumberComposable``을 다음과 같이 사용해 보자.

@Composable
fun MyScreen(viewModel: MyViewModel) {
  val number by viewModel.number.collectAsState()
  var text by remember { mutableStateOf("") }
  NumberComposable(
    current = number,
    onValueChange = { viewModel.numberChanged(it) }
  )
  TextField(text, onValueChanged = { text = it })
}

사용자가 ``TextField``에 뭔가를 입력할 때, ``NumberComposable``은 skip된다고 예상할 수 있다. 그러나 Layout Inspector로 보면, 실제로는 skip되지 않는다!

 

그 이유는, 컴파일러 보고서가 잘못되었기 때문이 아니며, Layout Inspector가 잘못되었기 때문도 아니다. 바로 ``MyViewModel``이 unstable하기 때문이다.

 

참고로, 모든 ``ViewModel``이 unstable하지는 않으며, 구현에 따라 stable할 수도 있다. 그러나 ``MyViewModel``은 unstable하다고 가정하자.

 

람다는 Kotlin 컴파일러에서 단순히 객체로 취급된다. ``MyScreen``이 recompose될 때, ``NumberComposable``의 ``onValueChange`` 람다도 새로운 객체로 재할당된다. 그런데 람다는 reference equality를 사용하기 때문에, 주소값이 다른 새 객체가 주어졌으므로 ``NumberComposable``을 skip할 수 없는 것.

 

즉, lambda의 내용물이 동일함에도 불구하고 새로운 람다 객체가 할당되었기 때문이다. 그렇다면, 왜 새로운 람다 객체가 할당되었는가?

 

Google은 stable 값만을 capture하는 람다는 자동으로 remember되도록 구현하였다. 만약 다음과 같이 람다 안에서 stable한 ``stableViewModel`` 인스턴스를 참조했다면,

@Composable
fun NumberComposable(
  current = number,
  onValueChange = { stableViewModel.numberChanged(it) }
) { }

Compose 컴파일러에 의해 다음과 같이 rewrite된다. 

@Composable
fun NumberComposable(
    current = number,
    onValueChange = remember(stableViewModel) { { stableViewModel.numberChanged(it) } }
) { }

람다가 remember되므로 recomposition이 발생해도 기존 객체가 그대로 사용된다. 따라서 ``NumberComposable`` 역시 정상적으로 skip될 것이다.

변화

그러나, Strong skip을 통해 Google은 모든 lambda가 자동으로 memoization되게 하였다. Unstable 값을 하나라도 capture하는 람다는 ``Object.equals()`` 기반 메모이제이션을, stable 값만을 capture하는 람다는 기존 remember rewrite 방식으로 기억된다. 

 

메모리 사용량은 조금 늘겠지만, 람다를 포함하는 composable이 더 많이 skip되므로 런타임 성능은 개선될 것이다.

Strong skipping 켜기 (experimental?)

컴파일러의 ``experimentalStrongSkipping`` 값을 ``true``로 지정하면 된다. Root-level ``build.gradle``에 다음 코드를 추가하면 모든 모듈에서 strong skip을 켤 수 있다.

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>() {
    compilerOptions.freeCompilerArgs.addAll(
        "-P",
        "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
    )
}

이거 진짜 안전한가?

기존 모드에서 collection 객체가 unstable인 이유는 mutable collection 때문이었다. 예컨대 ``List``가 ``MutableList``일 수도 있기 때문.

 

예를 들어, 기존 모드에서 아래의 ``MyList``는 unskippable이었다.

@Composable
fun MyToggle(enabled: Boolean) {}
@Composable
fun MyList(list: List<String>) {}

@Composable
fun MyScreen() {
   var list by remember { mutableStateOf(mutableListOf("Foo")) }
   var toggle by remember { mutableStateOf(false) }
   MyToggle(toggle)
   MyList(list)

   Button(
      onClick = {
         list.add("Bar")
         toggle = !toggle
      }
   ) { Text("Toggle") }
}

그러나 strong skip 모드에서는 ``MyList``가 skippable하다고 간주된다. Google에서는 이것을 accidental side effect라고 언급하고 있지만, 은근히 자주 일어나는 실수라서.. 흠...

결론

Strong skip을 활성화하면 대부분의 경우 성능을 크게 향상시킬 수 있을 것이다. 그러나 ``MutableList`` 같은 일부(하지만 중요한) 케이스에서는 버그가 발생할 수 있으므로, 개발자가 알아서 조심하는 수밖에 없다.

 

Compose 개발자라면 skippability 정도는 알아두자.

Stable

Compose 컴파일러 1.5.13 버전에서 Strong skip 모드가 stable로 전환됐다. 곧 default 설정으로 바뀔 예정.

참고자료

Strong skip이 아직 테스트 중일 때 작성된 글이다. Strong skip 개발 과정의 비하인드 스토리도 담겨 있으니, 일독을 권한다.

 

Jetpack Compose: Strong Skipping Mode Explained

Strong skipping mode changes the rules for what composables can skip recomposition and should greatly reduce recomposition.

medium.com

Copyright 2024 Google LLC. SPDX-License-Identifier: Apache-2.0