Performance in Jetpack Compose

이승우·2024년 10월 12일

Compose의 성능 개선에 대해서 공부해보려고 한다. 해외 아티클들을 읽어보며 공부할 예정이며, 이번에 참고할 아티클은 아래와 같다.

Performance in Jetpack Compose

  • Optimising recompositions
  • When should you use @Immutable and @Stable
    annotations;
  • Unstable classes, variables, lambdas;
  • Non-restartable & skippable composables;
  • Lambda modifiers;
  • Passing lambdas providing required fields instead of fields in composables;
  • Inlined composables;
  • When you should use remember { }.

layout inspector를 통해 다음을 알 수 있다.

  • 클래스가 stable 한지 여부
  • composable 함수가 건너뛸 수 있거나 다시 시작할 수 있는지 여부
  • recomposition count
  1. Unstable objects on UI layer.

안정성에 대해 신경 써야 하는 이유를 이해하기 위해 건너뛸 수 있다는 것에 대해 살펴보자. 이를 통해 Compose 런타임은 사용하는 모든 매개변수가 안정적이라고 간주될 때 composable을 건너뛸 수 있다.

그렇다면 컴파일러가 안정적이라고 간주되는 것은 무엇일까?

  • all primitive type : Boolean, Int, Long, Float, Char, etc
  • Strings
  • Lambdas(항상 그렇지는 않음)

1) Don’t use var when seeking stability. Fields declared as var are considered unstable: var를 사용하지 말자.

data class User(val id: Long, val name: String, var isSelected: Boolean)

@Composable
fun UnstableClassUsageScreen(viewModel: TypicalViewModel = hiltViewModel()) {
    val user by viewModel.user.collectAsStateWithLifecycle()
    Box {
        UserDetails(user)
    }
}

@Composable
private fun UserDetails(user: User) {
    Text(text = "name: ${user.name} id:${user.id}")
}

// compiler stability report

restartable scheme("[androidx.compose.ui.UiComposable]") fun UserDetails(
  unstable user: User
)

UserDetails는 User가 수정되지 않더라도 재구성된다. var 대신 val를 사용하면 해결된다.

2) Not all lambdas are considered stable. Let’s look at the following examples:

@Composable
fun UnstableLambdaScreen(viewModel: TypicalViewModel = hiltViewModel()) {
    Column {
        // button is not skippable due to usage of viewModel.onContinueClick()
        Button(onClick = { viewModel.onContinueClick() }) {
            Text(text = "button with unstable lambda")
        }
    }

@Composable
fun UnstableLambdaScreen() {
    val focusRequester = LocalFocusManager.current

    Column {
        // unstable lambda results in unskippable Button
        Button(onClick = { focusRequester.clearFocus() }) {
            Text(text = "button with unstable lambda")
        }
    }
}

람다는 외부의 scope을 캡처하므로 예상대로 자동으로 안정된 것으로 추론되지 않고 재사용되지 않는다.

람다가 외부 변수에 접근해야 하는 경우, 컴파일러는 해당 변수를 필드로 추가하여 람다의 생성자에 전달한다.

A) Use references, it will prevent the creation of a new class that references the outside variable: 참조를 사용하면, 외부 변수를 참조하는 새 클래스가 생성되지 않는다.

@Composable
fun UnstableLambdaScreen(viewModel: TypicalViewModel = hiltViewModel()) {

    Column {
        // stable
        Button(onClick = viewModel::onContinueClick) {
            Text(text = "button with stable lambda")
        }
    }

간단한 경우에는 효과적이지만, 때로는 함수 호출을 내부에 넣어야 할 때가 있다.

B) Explicitly remembering lambdas will ensure that we reuse the same lambda instance across recompositions: 람다를 명시적으로 기억하면 재구성시 동일한 람다 인스턴스를 재사용할 수 있다.

@Composable
fun UnstableLambdaScreen(viewModel: TypicalViewModel = hiltViewModel()) {
    val focusRequester = LocalFocusManager.current
    val onNameEnteredClick: (value: String) -> Unit = remember {
        return@remember viewModel::onNameEntered
    }
    val clearFocus = remember { { focusRequester.clearFocus() } }
    
    Column {
        // stable lambdas, skippable button
        Button(onClick = {
            onNameEnteredClick("..")
            clearFocus()
            someTopLvlFunction()
        }) {
            Text(text = "button with stable lambdas")
        }
    }
}

// top lvl functions without unstable params are stable
fun someTopLvlFunction() {

}

clearFocus의 이중 괄호는 함수로 전달해야 하는 경우가 아니면 필요하지 않다.

3) Beware of unstable classes from libraries & other modules. When a class is used from a module or a library that doesn’t use compose, it won’t be considered as stable even if all requirements are met : 라이브러리 및 기타 모듈의 불안정한 클래스에 주의해야 한다. compose를 사용하지 않는 모듈이나 라이브러리에서 클래스를 사용하면 모든 요구 사항이 충족되더라도 안정된 것으로 간주되지 않는다.

대부분의 경우 Compose는 자체적으로 불변성 여부를 잘 판단한다. 그래서 @Immutable이나 @Stable을 명시적으로 사용할 필요는 없다. 하지만, 멀티 모듈 프로젝트를 사용하거나 다른 개발자들이 사용하는 라이브러리를 만드는 경우라면 불변성을 명시적으로 알리는 것이 중요할 수 있다. 이유는 모듈 간 혹은 라이브러리의 경계를 넘어 상태 관리가 이루어지기 때문에 상태의 안정성에 대한 보장이 필요할 수 있기 때문이다.

@Immutable: 해당 어노테이션을 사용하면 해당 클래스의 인스턴스가 불변(immutable)임을 Compose에게 알리는 것이다. 즉, 객체가 변하지 않으므로 이를 안전하게 캐시하거나 다시 그리지 않아도 된다는 정보를 제공한다.

@Stable: 이 어노테이션은 객체의 특정 프로퍼티들이 자주 바뀔 수 있지만, 자체적으로는 안정적인(참조가 변하지 않음) 객체임을 알린다. 이는 성능 최적화에 도움이 된다.

@Stable: 이 어노테이션은 객체의 특정 프로퍼티들이 자주 바뀔 수 있지만, 자체적으로는 안정적인(참조가 변하지 않음) 객체임을 알립니다. 이는 성능 최적화에 도움이 됩니다.

@Immutable, @Stable 은 compose 컴파일러에 대한 특정한 약속을 만드는데 사용되므로 최적화된 코드를 생성할 수 있다. 이 작업은 컴파일러에서 처리하며 이미 안정성이 추론된 클래스에 어노테이션을 표시해도 아무런 영향이 없다.

UI Layer에서 멀티 모듈 프로젝트나 라이브러리의 객체를 사용해야 하는 경우 stable 표시가 되었는지 다시 확인해야 한다. 대부분 그렇지 않지만, 다음과 같은 옵션이 있다.

  • 자신만의 클래스를 만들고 mapping 한다.
  • 안정적인 필드를 필요한 컴포저블에 직접 전달하므로 user class를 전달하는 대신 primitie 값을 전달한다. (ex. user.id, user.name)
  • @Immutable로 표시된 wrapper 클래스를 사용한다.
// not skippable if User is unstable
@Composable
private fun UserDetails(user: User) {
    Text(text = "name: ${user.name} id:${user.id}")
}

// skippable even of User is unstable
@Composable
private fun UserDetails(id: Long, name: String) {
    Text(text = "name: $name id: $id")
}

@Immutable
data class ImmutableUserWrapper(val user: User)

// wrapper approach

@Composable
private fun UserDetails(wrapper: ImmutableUserWrapper) {
    Text(text = "name: ${wrapper.user.name} id:${wrapper.user.id}")
}

wrapper class를 사용하는 것은 좋은 방법은 아니다.

4) Unstable collections. Kotlin lists are read-only, but not immutable, so in this snippet:

@Composable
fun UnstableListScreen() {
    val items = remember { (0..100).map { it } }
    Box {
        WrappedLazyColumn(items)
    }
}

@Composable
private fun WrappedLazyColumn(items: List<Int>) {
    LazyColumn {
        items(items = items, key = { item -> item }) { item ->
            Text(text = "item $item")
        }
    }
}

List<>가 stable 하지 않으므로 건너뛸 수 없다. 따라서 목록이 변경되지 않아도 재구성된다.

A) Create a wrapper for list, same as we did when treating entities from another modules, and use it on the UI layer: 다른 모듈의 entity를 처리할 때와 마찬가지로 list에 대한 wrapper를 만들고 ui layer에서 사용한다.

@Immutable
class ImmutableListWrapper(val items: List<Int>)

// optimized using @Immutable marked ImmutableWrapper
@Composable
private fun WrappedLazyColumnApproach1(wrapper: ImmutableListWrapper) {
    LazyColumn {
        items(items = wrapper.items, key = { item -> item }) { item ->
            Text(text = "item $item")
        }
    }
}

B) immutable collections from kotlinx.collections.immutable. It’s available as a dependency. Beware of this issue though, duplicated here as well : immutable collection 사용

@Composable
private fun WrappedLazyColumnApproach2(items: ImmutableList<Int>) {
   LazyColumn {
        items(items = items, key = { item -> item }) { item ->
            Text(text = "item $item")
        }
    }
  1. Inlined composables
    Column, Row, Box는 inline으로 동작한다.
  • inline 동작 : Column, Row, Box는 인라인 함수로 구현되어 있다. 이 함수들은 별도의 state read scope을 만들지 않고 그들이 포함된 부모 컴포저블의 scope을 그대로 사용한다.
  • state read scope : 상태를 읽을 때, 상태를 읽은 scope가 재구성된다. 일반적으로 상태를 읽은 composable은 recomposition 되어야 하는 scope가 된다. 그러나 인라인 컴포저블은 별도의 scope를 만들지 않기 때문에 그 내부에서 상태가 읽힐 때 부모 scope가 recomposition 될 수 있다.
  • recomposition 문제 : 만약 상태를 인라인 컴포저블 내부에서 읽으면 그 상태 변화로 인해 해당 인라인 컴포저블의 부모가 recomposition 될 수 있다.
@Composable
fun StateReadsInlinedComposablesScreen() {
    var count by remember { mutableStateOf(0) }

    Column {
        Text(text = "count: $count")
        Button(onClick = { count++ }) {
            Text(text = "count++")
        }
    }
}

// Difference — WrappedColumn composable

@Composable
fun StateReadsInlinedComposablesScreen() {
    var count by remember { mutableStateOf(0) }

    WrappedColumn {
        Text(text = "count: $count")
        Button(onClick = { count++ }) {
            Text(text = "increase count")
        }
    }
}
@Composable
fun WrappedColumn(content: @Composable ColumnScope.() -> Unit) {
    Column(content = content)
}

차이점은 Column을 호스팅하는 WrappedColumn을 만든 것 뿐이다. 상태를 읽는 부분을 별도의 Composable로 분리한다.

  1. State reads from too high scope
@Composable
fun StateReadsInlinedComposablesSolution2Screen() {
    var count by remember { mutableStateOf(0) }
    Column {
        TextWrapper(provideCount = { count })
        Button(onClick = { count++ }) {
            Text(text = "count++")
        }
    }
}
@Composable
fun TextWrapper(provideCount: () -> Int) {
    Text(text = "Count: ${provideCount()}")
}
  • 필요할 때, 값을 읽기 위해 람다를 전달하면 state read scope가 생성되어 부모 composable의 재구성을 건너뛸 수 있다.
@Composable
fun StateReadsInputLambdasScreen(viewModel: TypicalViewModel = hiltViewModel()) {
    val name by viewModel.name.collectAsStateWithLifecycle()
    val creditCardNumber by viewModel.creditCardNumber.collectAsStateWithLifecycle()

    Column {
        InputsWrapper(
            name = name,
            creditCardNumber = creditCardNumber,
            onNameEntered = viewModel::onNameEntered,
            onCreditCardNumberEntered = viewModel::onCardNumberEntered
        )
    }
}

@Composable
private fun InputsWrapper(
    name: String,
    creditCardNumber: String,
    onNameEntered: (value: String) -> Unit,
    onCreditCardNumberEntered: (value: String) -> Unit,
) {
    var count by remember { mutableStateOf(0) }

    NameTextField(name, onNameEntered)
    CreditCardNumberTextField(creditCardNumber, onCreditCardNumberEntered)
    Text(text = "Count: $count")
    Button(onClick = { count++ }) {
        Text(text = "count++")
    }
}

@Composable
private fun NameTextField(
    name: String,
    onNameEntered: (value: String) -> Unit
) {
    TextField(
        value = name,
        onValueChange = onNameEntered,
        label = { Text("name") },
    )
}
  • 버튼을 누르면 InputsWrapper의 재구성이 발생한다. 같은 범위에서 count가 읽히기 때문
  • name, credit card number 를 입력하면 재구성이 발생한다.
@Composable
private fun InputsWrapper(
    name: String,
    creditCardNumber: String,
    onNameEntered: (value: String) -> Unit,
    onCreditCardNumberEntered: (value: String) -> Unit,
) {
    var count by remember {
        mutableStateOf(0)
    }

    NameTextField(name, onNameEntered)
    CreditCardNumberTextField(creditCardNumber, onCreditCardNumberEntered)
    TextWrapper(provideCount = { count })
    Button(onClick = { count++ }) {
        Text(text = "count++")
    }
}
@Composable
fun TextWrapper(provideCount: () -> Int) {
    Text(text = "Count: ${provideCount()}")
}
  • TextWrapper를 만들고, 람다를 전달하도록 하여 state read scope을 별도로 분리
@Composable
private fun InputsWrapperOptimized(
    provideName: () -> String,
    provideCreditCardNumber: () -> String,
    onNameEntered: (value: String) -> Unit,
    onCreditCardNumberEntered: (value: String) -> Unit,
) {
    var count by remember { mutableStateOf(0) }
    
    NameTextFieldOptimized(provideName, onNameEntered)
    CreditCardNumberTextFieldOptimized(provideCreditCardNumber, onCreditCardNumberEntered)
    TextWrapper(provideCount = { innerCount })
    Button(onClick = { innerCount++ }) {
        Text(text = "inner count++")
    }
}

@Composable
private fun NameTextFieldOptimized(
    provideName: () -> String,
    onNameEntered: (value: String) -> Unit
) {
    TextField(
        modifier = Modifier.fillMaxWidth(),
        value = provideName(),
        onValueChange = onNameEntered,
        label = { Text("name") },
    )
}
  1. NonRestartableComposable annotation

-> 이게 좋은 걸까는 잘 모르겠다.

  • 재시작 가능하지만, 건너뛸 수 없는 함수가 보인다면 항상 나쁜 신호는 아니다.
  • 모든 매개변수가 안정되도록 하여 함수를 건너뛸 수 있게 한다.
  • @NonRestartableComposable를 사용하여 함수를 재시작할 수 없게 한다.
  1. Use lambda modifiers whenever possible
  • 자주 변경되는 상태 변수를 modifier에 전달하는 경우, 가능하면 modifier의 람다 버전을 사용해야 한다.
  • modifier의 람다 버전으로 전환하면 함수가 레이아웃 단계에서 스크롤 상태를 읽도록 할 수 있다. Compose는 구성 단계를 건너뛰고 바로 레이아웃 단계로 이동할 수 있다.
@Composable
fun ModifierLambdasScreen() {
    val scrollState = rememberScrollState()
    BoxWrapper {
        ScrollingArea(scrollState)
        HorizontallyMovingButton(scrollState.value * 1.5f)
    }
}

@Composable
private fun ScrollingArea(scrollState: ScrollState) {
    Spacer(modifier = Modifier.verticalScroll(scrollState).height(2000.dp))
}

@Composable
private fun HorizontallyMovingButton(scrollOffset: Float) {
    Button(modifier = Modifier.graphicsLayer(translationX = scrollOffset))
}
@Composable
private fun HorizontallyMovingButton(scrollProvider: () -> Float) {
    Button(modifier = Modifier.graphicsLayer { translationX = scrollProvider() }) 
}
  1. When to use remember { }?
  • 한번 이상 실행될 수 있지만, remember에서 전달된 키가 변경될 때까지 실행되어서는 안되는 모든 작업의 경우
  • 불안정한 람다를 포함하여 컴파일러에서 안정된 것으로 처리되지 않는 객체를 사용할 때

Use with:

KeyboardActions();
LocalClipboardManager.current.setText (unstable lambdas);
LocalFocusManager.current.clearFocus/moveFocus (unstable lambdas);
FocusRequester;
MutableInteractionSource;
derivedStateOf();

Don’t use with:

TextStyle();
KeyboardOptions();
LocalSoftwareKeyboardController.current.hide/show;
FontFamily/Font;
.dp;
Arrangement;
Alignment;
RoundedCornerShape;
BorderStroke;

profile
Android Developer

0개의 댓글