본문으로 바로가기
반응형

photo by unsplash

Compose는 기존에 xml보다 심플한 구현을 제공합니다. 선언형 UI framework에 대한 장점은 기본 도입부의 개요부터 지속적으로 나오고 있는 내용들이라 여기서 더이상 언급하지는 않습니다. 다만, 구현의 편리성과 modern항 개발 방식을 지원하는 한편 성능에 대한 이슈가 끊임없이 나옵니다. 즉 화면에 그리기는 쉽지만, 빠르게 그리려면 Compose의 동작방식을 이해하고 개발자 스마트하게 코드를 작성해야 합니다. 

이번 블로그에서는 초보 compose 개발자들이 composable function을 구성하면서 정확한 사용방법에 대한 이해가 부족했던 부분들을 채워보는 형태로 진행합니다. 이글은 android 공식 developer 페이지의 compose 성능 파트와, android dev summit 22'에 소개된 내용들을 중심으로 다룹니다. [1][2]

remember의 올바른 사용

remember는 composable function에서 recomposing이 발생하더라도 유지해야하는 변수를 저장하기 위한 용도로 사용됩니다. 하지만 동작만 가지고서는 composable function 안에서 변수선언시 항상 remember를 사용해야 하는건지 아닌건지에 대한 명확한 기준이 서지 않습니다. 아래 케이스를 보면서 언제 어떻게 사용해야 하는지 확인해 보겠습니다.

복잡한 계산에 사용

@Composable
fun ContactList(
    modifier: Modifier = Modifier,
    contacts: List<ContactModel>,
    comparator: Comparator<ContactModel>,
    headerText: String
) {

    LazyColumn(...) {
        // DO NOT THIS
        items(contacts.sortedWith(comparator)) { item ->
            Text(item.name)
        }
    }
}

위의 코드는 contact 정보를 받아 화면에 list로 출력합니다. 이때 정렬을 위해서 items(..)의 값으로 sorting된 리스트를 넘겨줍니다. 동작에는 이상이 없지만 이 코드는 스크롤링되어 새로운 화면을 그릴때마다 sorting 작업이 발생합니다. 따라서 이런 무거운 작업들은 변경이 없는 remember(key)를 이용하여 상태를 저장하도록 변경이 필요합니다.

@Composable
fun ContactList(
   ...
) {

    // DO THIS
    val sortedList = remember(contacts, comprator) {
        contacts.sortedWith(comparator) 
    }
    
    LazyColumn(...) {
        items(sortedList) { item ->
            Text(item.name)
        }
    }
}
하지만 실제로는 정렬을 viewModel같은 곳에서 이미 수행한 후 composable function에 넘겨주는것이 더 좋습니다.

이때 제목에 "전체목록 (118)" 같이 list의 개수를 표현해 주기 위해서 아래와 같이 변수를 추가합니다.

@Composable
fun ContactList(
    modifier: Modifier = Modifier,
    contacts: List<ContactModel>,
    comparator: Comparator<ContactModel>,
    headerText: String
) {
    
    ...
    
    //DO NOT USE REMEBER
    val contactSize = contacts.size
    
    LazyColumn(...) {
        item {
            Text("$headerText ($contactSize)")
        }
...
    }
}

이렇게 무거운 계산이 필요한 작업이 아니거나, contact list가 변경될때 마다 표시되어야 하는 상황이라면 굳이 remember를 사용할 필요가 없습니다. 위 경우는 전자에 해당하므로 일반 변수로 사용해도 상관이 없습니다.

derivedStateOf의 올바른 사용

derivedStateOf는 UI상태를 변경해야하는 횟수보다 실제 상태변화가 더 많이 일어날때 불필요한 UI 변경을 막기 위해 사용합니다.

즉 flow의 distinctUntilChanged()와 유사한 역할을 수행합니다.[3]

derivedStateOf ≒ distinctUntilChanged

가장 많이 제시되는 예제는 스크롤 up 버튼을 노출하기 위해 현재 최상위보이는 item의 index를 가져와서 확인하는 코드 입니다.[2]

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

//DO NOT THIS
val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

listState.firstVisibleItemIndex는 현재 list의 최상단에 보이는 item의 index를 반환합니다. 하지만 ScrollToTopButton()을 노출하기 위해서 실제 필요한 값은 첫번째 아이템이 보이느냐 마느냐의 정보로 boolean 형태가 필요합니다. 따라서 아래와 같이 변경하여 상태의 변경을 최소화 합니다.

val listState = rememberLazyListState()

LazyColumn(state = listState) {
  // ...
  }

// DO THIS
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

사실 이 예제만 보면 "그래서 이거 말고 다른곳에서는 어떻게 써야하지?"란 의문이 생길수 있습니다. 따라서 하나의 예제를 더 확인해 보겠습니다.

아래와 같이 text input을 받는 TextField와 Button을 화면에 배치합니다.

@Composable
fun DeriveStateOfSample(modifier: Modifier = Modifier) {
    var username by remember { mutableStateOf("") }
    //DO NOT THIS
    val submitEnabled = username.isNotEmpty()

    Column(...) {
        TextField(value = username, onValueChange = {username = it})
        Button(enabled = submitEnabled, ...) {
            Text("Submit")
        }
    }
}

여기서 글자가 추가될때 마다 button이 enable 되며 상태 변화를 표로 그리면 아래와 같습니다.

username submitEnabled
  false
H true
Ho true
Hong true
HongG true
HongGi true
HognGil true
... true

submitEnabled 상태는 매번 갱신될 필요가 없으므로 아래와 같은 코드가 유효해 집니다.

var username by remember { mutableStateOf("") }
val submitEnabled = remember {
    derivedStateOf { username.isNotEmpty() }
}

Column(...) {
   TextField(value = username, onValueChange = {username = it})
   Button( enabled = submitEnabled ...) {
        Text("Submit")
    }
}
username submitEnabled
  false
H true
Ho  
Hong  
HongG  
HongGi  
HognGil  
...  

실제 submitEnabled 상태는 계속해서 변경되지 않으므로 불필요한 상태전환을 막을 수 있습니다. 다만 위의 경우 derivedStateOf를 쓰지 않더라도 실제 boolean 값이 변경되지 않으니 button은 skippable 대상이 되며, 실제 recompose 시에 skip 된걸 확인할수 있습니다. Layout Inspector상으로는 derivedStateOf 사용 여부와 상관없이 동일하게 표현 됩니다.

 

추가 예제를 하나더 확인해 보겠습니다.

fun DeriveStateOfSample2(modifier: Modifier = Modifier) {
    var firstName by remember { mutableStateOf("") }
    var lastName by remember { mutableStateOf("") }

    //DO NOT THIS
    val mergedText = remember {
        derivedStateOf {
            firstName +  " " + lastName
        }
    }

    Column(...) {
        Row {
            OutlinedTextField(modifier = 
                value = firstName,
                onValueChange = { firstName = it }
                ...)
            OutlinedTextField(
                value = lastName,
                onValueChange = { lastName = it }
                ...)
        }

        ...
        Text(mergedText, ...)
    }
}

두개의 입력을 받아 합쳐서 하나의 text로 만들어 출력합니다. 이때 두개의 상태변화를 derivedStateOf를 사용하여 체크한다면 유리할것 같지만 실제로 어떤 text가 변하든지 변경되어야 하기 때문에 이는 redundant한 작업입니다. 따라서 mergedText는 아래와 같은 일반 변수로 변경되는것이 맞습니다.

반응형
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

//DO THIS
val mergedText = firstName +  " " + lastName

Column(...) {
  ...
}

여기서도 동일한 논리로 어떤 값비싼 작업을 수행해야 하는 경우라면 아래와 같이 remember를 사용하는것이 유리합니다.

var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

// DO THIS
val mergedText = remember(firstName, lastName) {
    expensive(firstName, lastName)
}

Column(...) {
  ...
}

LazyColumn의 성능향상

key의 사용

LazyColumn은 item의 요소로 key 값을 받습니다. 이 부분은 Compose 초기 소개글에서 부터 나오는 내용으로 불필요한 recomposition을 막는 역할을 합니다. 초반에 사용했던 contactList 예제를 변경하겠습니다.

@Composable
fun ContactList(
   ...
) {
    LazyColumn(...) {
        items(
            // Unique 한 id를 지정
            key = {_, contact -> contact.id)},
            items = sortedList
            ) { item ->
            Text(item.name)
        }
    }
}

data class ContactModel(val id: Int, val name: String)

key의 type은 any 입니다. 다만 bundle에 담을수 있는 primitive type이나 parcelable, enum들이 사용되어야 합니다. 위 코드에서는 contact의 id를 key로 사용합니다. 이 key는 LazyColumn안에서 unique해야 하며, Global하게 unique해야 하는 값은 아닙니다.

key가 없으면 item의 position이 key로 사용됩니다. 따라서 list에 특정 아이템이 추가/삭제되는 경우 해당 아이템 이후의 item들에 대해서는 composition tree개 재생성되어야 하므로 recomposition의 부담이 커집니다. 최악의 경우 맨 위 아이템을 삭제한다거나, 맨 위에 아이템을 추가한다면 모든 item에 대한 composition tree를 다시 생성하게 됩니다.

따라서 구분 가능한 unique한 키를 지정함으로써 item list의 중간값 또는 처음/마지막값 등 어떤 리스트가 삭제/추가되더라도 기존 composition tree를 유지하면서 변경된 item은 tree에 추가/삭제할 수 있습니다. 좀더 자세한 내용은 'Recomposition의 내부동작' 글에서 확인할 수 있습니다.[8]

contentType의 사용

compose 1.2 부터 contentType 항목이 추가되었습니다. 여러 다른 형태를 갖는 item을 리스트에 표시하는 경우 각 item의 type을 지정하면 lazy list나 grid에서 성능을 극대화 할수 있습니다.

contentType이 지정되어 있으면 Compose는 동일 type의 item일 경우에만 composition을 재사용할 수 있습니다. (유사한 구조일때만 composition을 재사용하는게 효율적입니다.)  다시말해 서로 형태의 A, B type의 item이 연속된다면 Compose는 구조가 다른 type임을 인지하고 composition 재사용을 시도하지 않습니다. 따라서 composition 재사용과 lazy layout 성능의 이점을 극대화 할수 있습니다.

LazyColumn(...) {
   items(
        // Unique 한 id를 지정
        key = {_, contact -> contact.id)},
        items = sortedList
        contentType = {_, contact -> contact.uiType } 
        ) { item ->
        Text(item.name)
    }
}

 Running backward

상태를 읽은후 다시 업데이트 하면 무한루프에 빠지는 케이스가 생깁니다.

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

    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, 상태를 읽은 이후 다시 재쓰기 시도
}

위 코드는 무한루프에 빠져 계속적인 recomposition이 발생합니다. 문제가 되는곳은 아래 부분입니다.[2]

var count by remember { mutableStateOf(0) }

...

Text("$count")
count++ // Backwards write, 상태를 읽은 이후 다시 재쓰기 시도

count 정보를 읽어 화면에 표시하고 다시 해당 상태를 update 합니다. 그럼 이 상태를 체크하는 상위 BadComposable()이 recompose가 되면서 다시 count를 표시하고, 다시 count++로 인하여 recomposable이 반복되는 무한루프에 빠집니다.

또다른 예시 코드입니다.[1]

var balance by remember { mutableStateOf(0) }
balance = 0

for (transaction in transactions) {
    Row {
        balance =+ transaction
        Text("Transaction: $transaction Balance: $balance")
    }
}

어디가 잘못되었는지 보이나요? 아직 잘 모르겠다면 아래와 같이 for문을 풀어보면 문제가 명확해 집니다.

var balance by remember { mutableStateOf(0) }
balance = 0

transaction = transactions[0]
balance =+ transaction
Text("Transaction: $transaction Balance: $balance")

transaction = transactions[1]
balance =+ transaction
Text("Transaction: $transaction Balance: $balance")

transaction = transactions[2]
balance =+ transaction
Text("Transaction: $transaction Balance: $balance")

...

for문이 진행될수록 첫번째 Text부터 전부 recompose가 발생하게 됩니다. 따라서 위 코드는 아래와 같이 수정될 수 있습니다.

val balances = remember(transactions) {
    transactions.runningReduce{ a, b -> a + b }
}

for ((transaction, balance) in transactions.zip(balances)) {
    Text("Transaction: $transaction Balance: $balance")
}

먼저 transaction의 누적값을 단계별로 만들어 list로 만든다음 실제 transactions와 zip으로 묶어서 처리하는 방법입니다. 하지만 이런게 하는것 보다 계산 자체가 viewModel에서 처리된후 결과만 넘어도록 구현하는것이 더 좋습니다.

Lambda function의 처리

Recompose를 가능한 적게하기 위한 방법으로 추후 lambda를 param으로 사용하는 작업에 대하여 언급할 예정입니다. 이 내용에 대한 자세한 설명은 다음 포스팅에서 진행합니다. 여기서는 반대로 lambda로 인하여 발생하는 불필요한 recompose에 대하여 설명합니다. 좀더 자세한 코드와 case study에 대한 내용은 이전글을 참고 하시면 됩니다.[7]

위와 같은 화면을 만듭니다. 버튼을 클릭하면 count가 하나씩 올라가며, 실제 count 값은 viewModel에 변수로 지정되어 있습니다. 먼저 화면을 만듭니다.

@Composable
fun PerformanceTestScreen3(
    modifier: Modifier = Modifier,
    state: ScreenState = ScreenState(),
    onCheckChanged: (Boolean) -> Unit,
    onBtnClicked: () -> Unit
) {
    Column(...) {
        Switch(checked = state.isChecked, onCheckedChange = { onCheckChanged(it) })
        Spacer(...)
        Counter(state.count, onClicked = onBtnClicked)
    }
}

// 버튼 + 카운트 텍스트
@Composable
fun Counter(count: Int, onClicked: () -> Unit) {
    Row {
        Button(onClick = onClicked) {
            Text("Increase Count")
        }
        Spacer(...)
        Text(text = count.toString(), ...)
    }
}

data class ScreenState(val isChecked: Boolean = false, val count: Int = 0)

그리고 switch의 on/off값, count의 누적값은 viewModel에 stateFlow로 저장합니다.

// ViewModel 코드
private val _screenState = MutableStateFlow<ScreenState>(ScreenState())
val screenState = _screenState.asStateFlow()

fun increaseCount() {
    val currentState = _screenState.value
    _screenState.value = currentState.copy(count = currentState.count + 1)
}

fun setSwitchValue(isChecked: Boolean) {
    val currentState = _screenState.value
    _screenState.value = currentState.copy(isChecked = isChecked)
}

마지막으로 activity에서 composable을 호출하고 event에 대한 callback으로 viewModel과 연결시킵니다.

//Activity 코드
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        ComposeTestTheme {
            val screenState by viewModel.screenState.collectAsState()
            PerformanceTestScreen3(
                screenState = screenState,
                onCheckChanged = { isChecked -> viewModel.setSwitchValue(isChecked) },
                onBtnClicked = { viewModel.increaseCount() }
            )
        }
    }
}

이상태에서 switch를 클릭하여 상태를 바꾸면 아래와 같이 recompose가 발생합니다.

Switch을 클릭시 recompose 상태

Swtich를 바꿨기 때문에 recompose가 발생한것은 이해할수 있으나 button객체 역시 recompose가 발생했습니다. 버튼을 클릭했을 경우에도 전혀 상관없는 switch에 recompose가 발생합니다.

버튼을 클릭시 recompose 상태

즉 서로 관계 없어야 하는 component에 recompose가 발생했음을 알수 있습니다.

PerformanceTestScreen3()이 recompose되는 이유

먼저 acitvity 코드를 다시 봅니다.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        ComposeTestTheme {
            // 클릭 or switch 변경시 screenState값이 변경됨.
            val screenState by viewModel.screenState.collectAsState()
            
            PerformanceTestScreen3(
                //여기 값이 변경되었으므로 PerformanceTestScreen3()는 recompose 대상임
                screenState = screenState,
                onCheckChanged = { isChecked -> viewModel.setSwitchValue(isChecked) },
                onBtnClicked = { viewModel.increaseCount() }
            )
        }
    }
}

클릭 또는 switch가 변경시 screenState값이 변경됩니다. 따라서 PerformanceTestScreen3() 는 recompose 대상이 됩니다. 여기서 문제는 onCheckChanged = {..}onBtnClicke = {..}의 람다 역시 재생성 된다는 점입니다.

lambda function은 compile time에 익명클래스 (anonymous class)로 변경됩니다. 즉 object: XXX 형태로 변경되어 instance가 됩니다. 다만 recompose가 되었다고 해서 lambda fucntion이 모두 새로운 익명 클래스로 바뀌는것은 아닙니다.

문제는 lambda function내부에 viewModel에 있다는점 입니다. Compose는 recompose의 대상을 정할때 방어적으로 대상을 선정 합니다. 즉 recompose의 대상에서 skippable하게 취급되려면 Stable 또는 Immutable 해야하며, equal값이 변경되지 않아야 합니다. ViewModel은 compose에서 stable한것으로 취급되지 않습니다. 따라서 해당 viewModel을 품고있는 두 lambda 모두 recompose 시점에 새로운 익명클래스로 재생성됩니다.

Solution #1 (function reference의 사용)

원인은 ViewModel이 Stable 하지 않기 때문에 lambda가 recompose시 마다 매번 새로운 익명클래스로 재생성 되는데 있습니다.

따라서 아래와 같이 function reference를 이용하면 고정된 함수 참조값을 사용하기 때문에 recompose가 되는걸 막을 수 있습니다.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        ComposeTestTheme {
            // 클릭 or switch 변경시 screenState값이 변경됨.
            val screenState by viewModel.screenState.collectAsState()
            
            PerformanceTestScreen3(
                //여기 값이 변경되었으므로 PerformanceTestScreen3()는 recompose 대상임
                screenState = screenState,				
                viewModel::setSwitchValue, // function reference를 이용
                viewModel::increaseCount // function reference를 이용
            )
        }
    }
}

Switch를 클릭

Switch를 클릭시 Counter는 recompose 대상에서 skip 됨을 확인할 수 있습니다. 반대로 Button을 클릭하면 Switch가 skip 됩니다.

Solution #2 (remember의 활용)

여기서는 viewModel의 간단한 함수 호출이므로 function reference 형태로 사용이 가능하지만 모든 함수가 가능한것은 아닙니다. 따라서 remember를 이용하여 한번 wrapping 하여 사용하도록 합니다.

 val screenState by viewModel.screenState.collectAsState()
 
  //remember로 wrapping하여 사용
 val checkedChanged = remember<(Boolean) -> Unit> {
     { viewModel.setSwitchValue(it) }
 }
 
   //remember로 wrapping하여 사용
 val increaseCount =  remember<() -> Unit> {
     { viewModel.increaseCount() }
 }
                    
 PerformanceTestScreen3(   
     screenState,
     checkedChanged,
     increaseCount
 )

 

Conclusion

실제로 compose를 사용해보면 화면이 느리다는 느낌을 강하게 받습니다. 그도 그럴것이 Compose의 codelab등에서 제공하는 구글의 샘플앱도 UI가 버벅이는것 같다는 느낌을 받을때가 있기 때문입니다. 즉 선언형 UI이기 때문에 얻는 장점좀 있지만 성능이라는 단점도 같이 존재한다고 생각합니다.

다만 성능이라는 단점은 선언형 UI의 단점이라기 보다는 개발자가 좀더 스마트하게 구현하지 못했기 때문에 (예상치 못한 부분들) 발생하는것들이 많습니다. 최근 들어 Android Dev Summit '22에서나 다른 여러 강좌에서 Compose에 대한 내용이 쏟아져 나오고 있습니다. 또한 성능관련된 부분을 강조하고 느렸던 샘플앱을 개선하는 코드로 개발자의 실수를 줄일수 있는 가이드나 지침을 주고 있습니다.

정리하면 성능면에서 광범위하게 적용할수 있는 것들은 아래와 같습니다.[1]

1. 계산작업은 Compose 외부로 이동한다. 

compose에서 강조하는것들중에 하나가 미려한고 구현하기 쉬운 animation입니다. 다만 Layout Inspector로 Animation component의 동작을 보면 매 frame 마다 recompose가 발생하여 수십번의 recompose count가 올라가는걸 볼수 있습니다. 따라서 가능한 무거운 계산은 viewModel이나, 기타 부분으로 이동시키고 compose 내부에서는 그리는것에만 집중해야 합니다.

2. Defer reading state

이번 포스팅에서는 다루지 않았지만 lambda를 이용하여 읽는 시점을 최대한 뒤로 미뤄야 합니다. 이는 상위 composable function에 있는 상태를 하위 composable function에서도 읽는 경우 최대한 하위 composable function으로 읽는 시점을 이동시켜 recompose 자체를 skip 시키거나 recompose의 단계를 간소화 시키는 작업입니다.  다음 포스팅에서 여러 예제를 통해서 자세히 다룰 예정입니다.

 

Referneces

[1] https://www.youtube.com/watch?v=EOQB8PTLkpY

[2] https://developer.android.com/jetpack/compose/performance

[3] https://www.youtube.com/watch?v=ahXLwg2JYpc&t=31s

[4] https://tourspace.tistory.com/411 

[5] https://developer.android.com/jetpack/compose/lists

[6] https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37

[7] 2021.11.16 - [개발이야기/Android] - [Compose] Understanding compose lifecycle and recomposing with case study

[8] 2021.09.21 - [개발이야기/Android] - [Compose] 5. LifeCycle, Recomposition의 내부 동작, Call site, @Stable

반응형