Compose의 성능 개선에 대해서 공부해보려고 한다. 해외 아티클들을 읽어보며 공부할 예정이며, 이번에 참고할 아티클은 아래와 같다.
Performance in Jetpack Compose
layout inspector를 통해 다음을 알 수 있다.
안정성에 대해 신경 써야 하는 이유를 이해하기 위해 건너뛸 수 있다는 것에 대해 살펴보자. 이를 통해 Compose 런타임은 사용하는 모든 매개변수가 안정적이라고 간주될 때 composable을 건너뛸 수 있다.
그렇다면 컴파일러가 안정적이라고 간주되는 것은 무엇일까?
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 표시가 되었는지 다시 확인해야 한다. 대부분 그렇지 않지만, 다음과 같은 옵션이 있다.
// 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")
}
}
@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로 분리한다.
@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()}")
}
@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") },
)
}
@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()}")
}
@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") },
)
}
-> 이게 좋은 걸까는 잘 모르겠다.
@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() })
}
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;