Compose 성능을 개선하기 위한 Best Practices
Compose UI의 성능을 최적화하기 위한 몇 가지 best practice를 알아보자.
복잡한 계산은 remember하기
Composable은 대단히 자주 실행될 수 있다. 극단적으로는 애니메이션의 매 프레임마다 실행될 수 있다. 따라서 composable 내부에서는 되도록 복잡한 계산을 하지 않는 것이 좋다.
부득이하게 복잡한 계산을 해야 한다면, ``remember``를 활용하여 계산 결과를 오랫동안 기억해야 한다. ``remember``를 사용하면 같은 값에 대해 계산을 두 번 이상 수행하지 않는다.
예를 들어, 다음과 같이 ``LazyColumn`` 내부에서 리스트를 정렬하면 안 된다.
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
LazyColumn(modifier) {
// DON’T DO THIS
items(contacts.sortedWith(comparator)) { contact ->
// ...
}
}
}
리스트가 바뀌지 않더라도, ``ContactList``가 recompose될 때마다 전체 리스트가 다시 정렬된다. 심지어 ``LazyColumn``이 스크롤되어 새로운 아이템이 나타날 때에도 매번 다시 정렬된다. 끔찍한 전력 낭비의 현장이다.
리스트를 ``LazyColumn`` 밖에서 정렬하고, 정렬 결과를 ``remember``로 기억하면 문제를 해결할 수 있다. ``remember``의 매개변수에는 정렬 결과에 영향을 주는 변수를 넣으면 된다.
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
val sortedContacts = remember(contacts, comparator) {
contacts.sortedWith(comparator)
}
LazyColumn(modifier) {
items(sortedContacts) {
// ...
}
}
}
이제 연락처 정보 자체가 바뀌지 않는 이상 ``contacts``가 불필요하게 다시 정렬되지 않는다.
사실 복잡한 계산은 ``ViewModel`` 등 UI 외부에서 수행한 후, UI에서는 그대로 보여주기만 하는 것이 제일 좋다.
Lazy 레이아웃에 key 적용
다음과 같이 LazyColumn에서 노트를 보여주는 UI가 있다.
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes
) { note ->
NoteRow(note)
}
}
}
그러나 이 코드에는 문제가 있다. 리스트의 맨 마지막 노트가 일부 값이 바뀐 채 리스트의 맨 앞으로 왔다고 가정하자. 이때 리스트의 크기는 그대로이지만, 인덱스 기준으로 모든 값이 달라졌으므로 Compose는 모든 아이템을 recompose한다. 실질적으로는 맨 마지막 아이템만 바뀌었음에도 불구하고.
이 문제를 해결하려면 아이템 key를 적용하면 된다. 각 아이템에 stable key를 적용하면, Compose는 key가 달라지지 않은 아이템을 recompose하지 않는다. 따라서 불필요한 recomposition을 대폭 줄일 수 있다.
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes,
key = { note ->
// Return a stable, unique key for the note
note.id
}
) { note ->
NoteRow(note)
}
}
}
물론 key는 unique한 값이어야 한다.
derivedStateOf를 활용하여 recomposition 줄이기
Composable 내부에서 state를 사용할 때, state가 매우 빠르게 변한다면 UI가 여러 번 recompose될 수 있다.
예를 들어, 다음과 같이 ``LazyColumn``에서 첫 번째로 보여주는 아이템의 index를 알고 싶다고 해 보자.
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton = listState.firstVisibleItemIndex > 0
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
``showButton``을 계산하기 위해 ``listState``를 참조하고 있다. ``listState``의 값이 변하면 ``showButton``도 다시 계산되고, 따라서 ``showButton``을 포함하는 composable 전체가 recompose된다.
그런데 ``listState``는 사용자가 ``LazyColumn``을 스크롤하는 매 순간 변하므로, 최악의 경우 이 composable이 매 프레임마다 recompose될 가능성이 있다. 사실 ``showButton``의 값 자체는 그렇게까지 자주 변하지 않는다. 첫 번째로 보이는 아이템의 index가 0에서 0이 아닌 값으로, 또는 0이 아닌 값에서 0으로 바뀔 때에만 값이 달라지기 때문.
``derivedStateOf``를 사용하면 문제를 해결할 수 있다. Derived state는 Compose에게 어떤 state가 바뀌었을 때 recomposition을 수행해야 하는지 정확하게 알려줄 수 있다. 이 경우에는 ``listState`` 자체가 아닌 ``listState.firstVisibleItemIndex > 0``의 값이 바뀔 때에만 recompose이 발생해야 하므로, 비교문 전체를 ``derivedStateOf`` 안에 넣으면 된다.
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
``derivedStateOf`` 내부의 값이 바뀔 때에만 recomposition을 수행함으로서 UI의 성능을 크게 개선할 수 있다.
State 읽기를 최대한 미루자
성능 이슈를 해결하는 여러 방법 중에는 state를 최대한 나중에 읽는 것도 있다. 예를 들어, 상위 composable에 hoist된 state를 하위 composable에서 읽을 때, state read를 람다로 묶을 수 있다. 값 자체를 계속해서 바꾸는 대신, 값을 제공하는 객체(람다) 자체는 그대로 두고, 객체가 제공하는 값만을 바꾸는 것이다.
실제로 구글의 공식 샘플 앱인 Jetsnack에서 이 방법을 적용하여 스크롤 성능을 개선한 사례가 있다. 기존 코드에서는 다음과 같이 ``Title``에 offset을 적용하고 있었다.
@Composable
fun SnackDetail() {
// ...
Box(Modifier.fillMaxSize()) {
val scroll = rememberScrollState(0)
// ...
Title(snack, scroll.value)
// ...
}
}
@Composable
private fun Title(snack: Snack, scroll: Int) {
// ...
val offset = with(LocalDensity.current) { scroll.toDp() }
Column(
modifier = Modifier
.offset(y = offset)
) {
// ...
}
}
Scroll state가 바뀌면, 가장 인접한 recomposition scope에서 recomposition이 발생한다. inline 함수인 ``Box`` 대신 ``SnackDetail``가 recompose된다.
이 코드에서는 scroll state를 ``Box`` 내부에서 읽고 있는데, 다음과 같이 실제로 state를 사용하는 ``Title``에서 값을 읽는다면 recomposition을 줄일 수 있다.
@Composable
fun SnackDetail() {
// ...
Box(Modifier.fillMaxSize()) {
val scroll = rememberScrollState(0)
// ...
Title(snack) { scroll.value }
// ...
}
}
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
val offset = with(LocalDensity.current) { scrollProvider().toDp() }
Column(
modifier = Modifier
.offset(y = offset)
) {
// ...
}
}
Scroll state의 값이 ``Title``에서만 참조되고 있으므로, scroll value가 바뀌어도 ``Title``만 recompose된다. ``Title``에 매개변수로 넘기는 람다에서 참조하는 것 아니냐고 생각할 수도 있는데, 저건 그냥 람다를 만드는 코드이며 실제로 값을 참조하지는 않는다.
그러나, UI를 단순히 re-layout 또는 redraw하기 위해 recomposition을 수행할 필요는 없다! Composition 단계에서는 무엇을 그릴지 결정하고, layout과 draw 단계에서는 그것들을 어떻게 그릴 지 결정한다. 코드를 보면 offset이 바뀌었다고 해서 그릴 것이 바뀌지는 않는다. 즉 recomposition을 수행할 필요가 없다.
Compose에서는 ``Modifier``의 매개변수가 바뀔 때에도 recomposition을 수행한다. 자주 변하는 값을 modifier에 사용할 때에는, 매개변수 대신 람다 modifier를 적용하는 것이 좋다.
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
val offset = with(LocalDensity.current) { scrollProvider().toDp() }
Column(
modifier = Modifier
.offset { // 람다 버전 modifier
val scroll = scrollProvider()
val offset = (maxOffset - scroll).coerceAtLeast(minOffset)
IntOffset(x = 0, y = offset.toInt())
}
) {
// ...
}
}
같은 예시로, ``Modifier.background``도 다음과 같이 ``Modifier.drawBehind``로 바꿀 수 있다.
// Before
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
Modifier
.fillMaxSize()
.background(color)
)
// After
val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
Modifier
.fillMaxSize()
.drawBehind {
drawRect(color)
}
)
방금 생각난 건데, 그럼 ``Modifier.background(Color)``가 내부적으로 ``Modifier.drawBehind``를 참조하도록 하면 되지 않나? 이건 구글 잘못 아닌가?
Backwards write 없애기
이미 읽힌 state를 다시 수정하는 행위를 backwards write 라고 한다. 예를 들어 이런 코드.
@Composable
fun BadComposable() {
var count by remember { mutableStateOf(0) }
// Causes recomposition on click
Button(onClick = { count++ }, Modifier.wrapContentSize()) {
Text("Recompose")
}
Text("$count")
count++ // Backwards write, writing to state after it has been read</b>
}
``BadComposable``의 맨 밑에서 count의 값을 바꾸고 있다. 맨 처음 composition에서는 아직 recomposition scope가 등록되지 않았으므로 recompose되지 않지만, 최초 composition이 끝나 recomposition scope가 등록된 후에 버튼을 클릭하면 무한 recomposition이 발생한다.
이 문제를 원천적으로 해결하는 방법은, Composition 단계에서 절대 state의 값을 변경하지 않으면 된다. 대신 ``onClick`` 등의 콜백에서 state의 값을 변경해야 한다.
참고로 맨 밑의 ``count++``를 ``SideEffect { }``로 묶으면 버튼을 클릭하지 않아도 무한 recomposition이 발생한다. ``SideEffect``는 composition이 끝난 후에 블럭을 실행하므로, recomposition scope가 등록된 상태에서 값이 바뀌기 때문.
참고문헌