Jetpack compose의 최적화
Mashup 12기 프로젝트를 진행하면서, 앱 배포 이후 유지보수 기간을 가지게 되었습니다.
앱을 동작시키면서 Layout Inspector로 recomposition count를 통해 recomposition이 많이 일어나는 부분이 없는지 찾아보면서 유지보수를 진행하고 있는데요~
자연스럽게 Jetpack compose의 최적화에 대해서 관심이 생기게되었습다.
일단 글에 들어가기 앞서 recomposition이 무엇인지 언제 일어나는지 파악할 필요가 있습니다.
recomposition
recomposition(재구성)은 입력이 변경될 때, 구성 가능한 함수를 다시 호출하는 프로세스입니다. 이것은 함수의 입력이 변경될 때 발생합니다.
Compose가 새 입력을 기반으로 재구성할 때, 변경 되었을 수 있는 함수 또는 람다만 호출하고 나머지는 건너뜁니다. 매개변수가 변경되지 않은 모든 함수 또는 람다를 건너뛰면 Compose가 효율적으로 재구성할 수 있습니다.
여기서 중요한 점은 컴포저블의 매개변수가 업데이트 되지 않았음을 Compose가 확인 할 수 있는 경우에만 컴포저블을 건너뛸 수 있다는 점입니다. Compose가 확신할 수 없는 경우(안정성이 없는 경우)는 부모 Composable이 재구성될 때 항상 재구성됩니다.
안정성이 없다면 재구성이 자주 발생할 수 있기때문에, skippable하게 Composable을 유지하는 것이 중요합니다.
skippable
skippable은 전 호출 이후 입력이 변경되지 않은 Composable함수에 대한 호출을 건너뛸 수 있다는 것입니다.
그렇다면 skippable이 되는 조건이 무엇일까요?
조건은 다음과 같습니다.
- 모든 입력은 안정적( stable ) 이어야합니다.
- 입력은 이전 호출에서 변경되지 않아야합니다.
위 두가지 조건은 @Stable한 타입에 대해서 설명합니다.
Stable한 타입은
- 두개의 인스턴스들의 equals를 수행한 결과가 같다
- public property가 변경된다면, Composition은 알림을 받는다
- 모든 public property들은 stable하다.
@Stable의 주석을 사용하지않아도 모든 기본값 유형들( Int, float 등등) , 문자열은 안정적이라고 합니다.
바로 예시를 들어보겠습니다.
var count by remember { mutableStateOf(0) }
data class TestClass(
val id: Int,
val value: String
)
@Composable
fun NonStableComposable(
testClassList: List<TestClass>,
count: Int,
plusCount: () -> Unit // count++
) {
LogCompositions("NonStable", "recompose Out")
Text(text = count.toString())
NonStableTextLazyColumn(testClassList)
Button(
onClick = plusCount
) {
Text("Non_Stable click Me")
}
}
@Composable
fun NonStableTextLazyColumn(
testClassList: List<TestClass>
) {
LogCompositions("NonStable", "recompose In")
LazyColumn {
items(
testClassList
) {
Text(text = it.value)
}
}
}
예시로 NonStableComposable을 만들고, 매개변수로 전달된, testClass들 내부에 LazyColumn에 전달합니다.
testClass는 primitive type으로 구성되어 있고, testClassList에는 4개의 testClass가 담겨있습니다.
우리는 NonStableComposable의 버튼을 클릭하고, 람다로 count를 증가를 시켰습니다.
우리가 원하는 방향은 count가 증가가 되어도, count를 매개변수를 이용하는
Text(text = count.toString())
만 리컴포지션이 되기를 원합니다.
하지만 실제로는 NonStableTextyLazyColumn도 같이 리컴포지션이 일어납니다.
List와 같은 경우는 안정적이라고 하지 않습니다.
실제 컴파일러 보고서를 이용해서 List를 컴포저블의 매개변수로 넣어놓은
NonStableComposable를 보겠습니다.
restartable scheme("[androidx.compose.ui.UiComposable]") fun NonStableComposable(
unstable testClass: List<TestClass>
stable count: Int
stable plusCount: Function0<Unit>
)
이렇게 stable하지 않은 매개변수들은 unstable로 표시가 되는데요. 이 컴포저블은 skippable하지 않아서 컴포저블의 내부 변수중에 하나만 변경이 되어도, unstable한 항목을 가지는 composable은 recomposition이 계속 이뤄지게 됩니다.
List의 경우 stable하게 바꾸기 위해서는 2가지 방법이 있습니다.
1. @Immutable & @Stable
첫번째로 @Immutable annotation이나 @Stable 을 붙인 wrapper로 List를 감싸주는 방법입니다
@Immutable
data class ImmutableItems(
val items: List<TestClass>
) {
companion object {
val List = ImmutableItems(
non_stable_list
)
}
}
위와 같이 @Immutable로 한번더 감싸고, composable의 매개변수로 넘겨준다면
@Composable
fun StableComposable(
testClass: ImmutableItems,
count: Int,
plusCount: () -> Unit
) {
LogCompositions("StableCompose Tag", "in")
Text(text = count.toString())
TextRow(testClass)
TextLazyColumn(testClass)
Button(
onClick = plusCount
) {
Text("Stable click Me")
}
}
@Composable
fun TextLazyColumn(
testClass: ImmutableItems
) {
LazyColumn {
items(
testClass.items
) {
LogCompositions("StableCompose Tag", "in2")
Text(text = it.value)
}
}
}
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun StableComposable(
stable testClass: ImmutableItems
stable count: Int
stable plusCount: Function0<Unit>
)
testClass는 stable하게 바뀌며 skippable이 추가 된것을 볼 수 있습니다.
그럼 @Stable과 @Immutable의 차이가 뭐지?
컴파일러는 둘 다 동일하게 취급하지만
@Immutable
- 그 가치가 절대 변하지 않는다는 약속입니다.
@Stable
- 값을 관찰할 수 있고 값이 변경되면 리스너에게 알림을 제공한다는 약속입니다.
2. ImmutableList
두번째 방법은
kotlinx-collections의 immutableList을 사용하는 것입니다.
build.gradle에 다음과 같이 dependency를 추가합니다.
immutableList을 사용하기 위해서는 컴포즈 컴파일러 1.2버전이상이 되어야합니다.
// build.gradle
composeOptions {
kotlinCompilerExtensionVersion "1.2.0"
}
//dependency
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
StableComposable(
testClass = non_stable_list.toImmutableList(),
count = clickValue,
plusCount = {
clickValue++
}
)
non_stable한 List를 ImmutableList로 바꿔주고 넘겨준다면 동일한 결과를 얻을 수 있습니다.
지금까지 불안정한 리스트를 어떻게 안정적으로 바꿀 수 있는지에 대해 알아보았고, 다른 부분도 보겠습니다
Var to Val
안정성을 위해서는 var를 사용해서는 안됩니다. var로 선언된 필드는 불안정한 것으로 간주됩니다.
data class DummyClass(
var id: Int,
var value: String
)
@Composable
fun NonStableDummyComposable(
dummyClass: DummyClass
){
Text(text = dummyClass.id.toString())
}
restartable scheme("[androidx.compose.ui.UiComposable]") fun NonStableDummyComposable(
unstable dummyClass: DummyClass
)
DummyClass가 수정되지 않더라도, NonStableDummyComposable은 재구성됩니다.
var field를 val로 수정되주면 해결됩니다.
안정적이지 않은 람다
공식문서에 모든 람다는 안정적이라고 쓰여있지만, 실제로는 예외가 있습니다.
// ViewModel
class NamesViewModel : ViewModel() {
private val _state = MutableStateFlow(State.Empty)
val state = _state.asStateFlow()
var count = 0
fun addName() {
val newList = _state.value.names + "${count++}"
_state.value = _state.value.copy(
names = newList
)
}
fun handleNameClick() {
}
}
@Immutable
data class State(
val names: List<String>
) {
companion object {
val Empty = State(
emptyList()
)
}
}
@Composable
fun RecompositionTest() {
val viewModel = remember { NamesViewModel() }
val state by viewModel.state.collectAsState()
NameColumnWithButton(
names = state.names,
onButtonClick = {viewModel.addName()},
onNameClick = {viewModel.handleNameClick()}
)
}
@Composable
fun NameColumnWithButton(
names: List<String>,
onButtonClick: () -> Unit,
onNameClick: () -> Unit
) {
Column {
names.forEach {
CompositionTrackingName(name = it, onClick = onNameClick)
}
Button(onClick = onButtonClick) { Text("Add a Name") }
}
}
@Composable
fun CompositionTrackingName(name: String, onClick: () -> Unit) {
Log.e("name", name)
Text(name, modifier = Modifier.clickable(onClick = onClick))
}
우리는 다음의 코드에서 버튼 클릭시 리스트에 원소를 추가하고,
추가된 원소는 CompositionTrackingName안에 Text로 보여지겠구나 라고 생각합니다.
따라서 버튼이 한번 클릭되었을 때, 새로운 값에 대한 리컴포지션이 하나 일어나는 것이라고 예상됩니다.
실제로는 데이터가 추가될 될 때마다, 기존값에 대한 리컴포지션도 일어나는 것을 볼 수 있습니다.
why?
class NameClickLambda(val viewModel: NamesViewModel) {
operator fun invoke() {
viewModel.handleNameClick()
}
}
실제로 NameClick 람다는 컴파일러에 의해 클래스를 생성한다고 합니다.
viewModel을 캡쳐하고 있는데, 값을 캡쳐하는 람다는 매번 새로운 인스턴스를 만들어서 invoke를 한다고 합니다.
따라서 람다의 동일하지 않으니까, 기존의 리스트에서도 recomposition이 일어난다고 이해했습니다.
해결책
- ViewModel에 @Stable 어노테이션 붙이기
@Stable
class NamesViewModel : ViewModel() {
// snipped for brevity
}
실제로 @Stable을 붙였을 때, 버튼 클릭시 재구성이 새로운 값에 대해서만 발생합니다.
- 메서드 참조를 사용하기
NameColumnWithButton(
names = state.names,
onButtonClick = viewModel::addName, // Method reference
onNameClick = viewModel::handleNameClick, // Method reference
)
람다 대신, 메서드참조를 사용했을 경우, 뷰모델을 참조하는 새 클래스가 생성되는 것을 방지할 수 있다고합니다.
Recomposition 범위 변경
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
data class Contact(val name: String, val number: String)
@Composable
fun ContactRow(contact: Contact, modifier: Modifier = Modifier) {
var selected by remember { mutableStateOf(false) }
Row(modifier) {
ContactDetails(contact)
ToggleButton(selected, onToggled = { selected = !selected })
}
}
//https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8
실제 리컴포지션은 위와 같이 동작합니다.
위와 같은 코드에서 selected의 상태가 변하게 된다면, 실제로 읽히는 위치에 가장 가까운 재시작 가능한 Composable은 ContactRow입니다.
Row나 Column Box와 같은 함수는 다시 시작할 수 있는 범위가 아닙니다.
위와 다르게 ContactRow가 복잡하다고 가정을 해본다면, 토글 버튼의 상태번화로 무거운 ContactRow가 재구성되게 됩니다.
이를 막기 위해서 WrapperComposable을 만들어서 감싸는 전략을 사용합니다.
@Composable
fun WrapperRow(content : @Composable RowScope.() ->Unit){
Row(content = content)
}
@Composable
fun ContactRow(contact: Contact, modifier: Modifier = Modifier) {
var selected by remember { mutableStateOf(false) }
WrapperRow { // <- 가장 가까운 재시작가능한 Composable
ContactDetails(contact)
ToggleButton(selected, onToggled = { selected = !selected })
}
}
이로서 ContactRow가 재구성을 건너뛸 수 있게 됩니다.
마무리
이외에도 다양한 방법들이 있지만, 프로젝트에서 가장 유용하게 쓰일 법한 최적화 방법들을 가져와 보았습니다.
프로젝트를 막 배포했을 때만 해도 정말 잘 만들었다라고만 생각을 했었는데, 위에서 소개한 방법을 적용할 곳이 너무나 많은 것을 깨닫게 되었습니다.
이 글을 작성한 후 재구성을 피하거나 줄이기 위해서 위의 방법을 적용시켜보려고 합니다.
다른 분들도 최적화에 도움이 되는 글이 되었으면 좋겠습니다. 읽어주셔서 감사합니다!
출처
https://multithreaded.stitchfix.com/blog/2022/08/05/jetpack-compose-recomposition/
https://developer.android.com/jetpack/compose/lifecycle
https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8
https://chris.banes.dev/composable-metrics/
https://skyyo.medium.com/performance-in-jetpack-compose-9a85ce02f8f9
https://dycoder.tistory.com/entry/Compose-Recomposition-최적화-실험해보기
https://tourspace.tistory.com/428
https://sungbin.land/jetpack-compose-람다-최적화에-대한-고찰-b8854e38067a
https://skyyo.medium.com/performance-in-jetpack-compose-9a85ce02f8f9
'Compose' 카테고리의 다른 글
Compose와 함께 Motion Layout을 사용하여 애니메이션 구현하기 (0) | 2023.05.24 |
---|---|
rememberUpdatedState (0) | 2023.02.15 |
SwipeToDismiss Jetpack Compose로 구현하기 (0) | 2023.02.02 |
Jetpack Compose GapBuffer (1) | 2022.10.27 |
Blur in Jetpack Compose (0) | 2022.07.20 |