본문 바로가기

Jetpack Compose

[Jetpack Compose] UI State 저장 방법에 대하여

서론

Compose에서는 State를 이용해 상태를 추적하고 관리합니다. 이로 인해 recomposition 여부가 결정되며 사용자에게 보여지는 화면의 UI가 결정되기도 합니다. 요구사항에 맞춰 구현을 하다보니 A Screen에서 B Screen으로 이동했다가 다시 A Screen으로 이동했을 때 State가 유지되어야 하는 경우가 발생해 Compose에서 UI State를 저장하는 방식에 대해 알아보려고 합니다.

rememberSaveable

rememerSaveable은 UI State를 Bundle에 저장합니다. Bundle은 String 타입을 key로 가지는 Map 형태의 데이터 묶음인데, 안드로이드에서 상태의 저장 및 복구에 사용됩니다. 기본적으로 key에 대응하는 value 값으로 int, float, boolean과 같은 primitive type이 들어올 수 있고 다음과 같이 사용할 수 있습니다.

 

@Composable
fun TestScreen() {
    Surface(
        modifier = Modifier.fillMaxSize()
    ) {
        RememberSaveableTest()
    }
}

@Composable
fun RememberSaveableTest() {
    var count by rememberSaveable { mutableIntStateOf(0) }

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "$count"
        )
        Button(
            onClick = { count++ }
        ) {
            Text("Click Button!")
        }
    }
}

 

 

remember를 사용하게 되면 화면 회전이나 글자 크기 변경, 언어 변경과 같은 configuration change 발생 시 데이터가 기본 값으로 초기화 되지만 rememberSaveable을 이용하면 Bundle에 값을 저장해놓았다가 configuration change 발생 후에 Bundle에서 값을 가져오기 때문에 데이터를 유지할 수 있습니다. 

 

화면 회전에도 데이터가 유지되는 모습

 

 

기본적으로 Bundle에는 primitive type의 값들이 들어갈 수 있기 때문에 Bundle을 이용하는 rememberSaveable 역시 primitive type만을 기본적으로 지원합니다. 만약 List나 data class 타입을 저장하고 싶다면 Parcelize 어노테이션을 이용하거나 listSaver 나 mapSaver와 같이 Compose에서 지원하는 API를 이용하는 방법이 있습니다. 

 

다만 Bundle의 크기는 현재 1MB로 제한되어 있으며 큰 객체를 저장하는 경우 TransactionTooLarge Exception이 발생할 수 있습니다. 특히 하나의 Activity로 앱을 만드는 경우, 하나의 Bundle을 공유하기 때문에 크고 복잡한 객체를 저장하면 문제가 발생할 수 있습니다. Compose를 이용하는 경우 단일 Activity를 사용하는 경우가 많기 때문에 rememberSaveable을 적재적소에 사용하도록 주의해야할 것 같습니다.  

 

다음은 Parcelize 어노테이션을 통해 data class를 rememberSaveable을 이용해 저장하는 방법입니다. Parcelize 어노테이션을 사용하기 위해서는 app 레벨의 build.gradle에 다음과 같이 추가해주어야 합니다.

plugins {
	/* something */
    id("kotlin-parcelize")
}
@Composable
fun TestScreen() {
    var contact by rememberSaveable { mutableStateOf(Contact("peter", 1)) }

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "${contact.name}, ${contact.number}"
        )
        Button(
            onClick = { contact =  Contact("peter", 100) }
        ) {
            Text("Click Button!")
        }
    }
}

@Parcelize
data class Contact(val name: String, val number: Int) : Parcelable

 

 

SavedStateHandle

만약 앱이 StateHolder나 비즈니스 로직을 처리하는 방법으로 ViewModel을 사용하고 있는 경우에는 ViewModel에서 제공하는 API인 SavedStateHandle을 활용을 고려해볼 수 있습니다.

 

먼저 ViewModel의 특성을 잠깐 짚고 넘어가려 합니다. ViewModel 객체의 생명주기는 Activity의 생명주기보다 길어서 configuration change를 처리할 수 있기 때문에 ViewModel에 데이터를 저장해 놓으면 개발자는 화면 회전이나 다크모드 전환 등과 같은 configuration change를 신경쓰지 않아도 됩니다.

 

ViewModel 객체는 생성자를 통해 SavedStateHandle 객체를 수신하게 되는데, 해당 객체 또한 내부적으로 Bundle로 구성되어 있어 키-값 형태로 데이터를 저장하거나 가져올 수 있습니다. 기본적으로 다음과 같이 사용할 수 있습니다.

class TestViewModel(
    private val savedStateHandle: SavedStateHandle
): ViewModel() {
    private var count: Int? = savedStateHandle["count"]

    fun getSavedStateHandle() {
        count = savedStateHandle["count"]
    }

    fun setSavedStateHandle(count: Int) {
        savedStateHandle["count"] = count
    }
}

 

위와 같이 key-value 형태로 값을 저장 및 가져올 수도 있지만 실제 프로젝트에서는 스트림으로 받거나 Compose의 State를 활용하는 경우가 많습니다. SavedStateHandle API에서는 이러한 경우를 위해 값을 가져올 때 StateFlow로 가져오는 방법과 Compose의 State로 가져오는 방법을 제공하고 있습니다.

 

getStateFlow() 를 이용하면 savedStateHandle에 저장된 값을 실시간 스트림으로 변환할 수 있습니다. 변환된 값은 Flow이기 때문에 filter나 map을 통해 수정할 수 있고 값을 사용하고자 하는 곳에서 collect해서 사용할 수 있습니다. 인자로는 key와 함께 StateFlow이기 때문에 default value를 필요로 합니다.

val count: StateFlow<Int> = savedStateHandle.getStateFlow("count", 0)

 

saveable API는 Compose의 State와 SavedStateHandle 사이의 상호운용성을 제공해 Compose의 State를 SavedStateHandle에 저장하거나 가져올 수 있습니다. 다음 코드에서와 같이 saveable 함수를 통해 초기 값을 지정해줄 수 있고 withMutableSnapshot 을 통해 값을 업데이트할 수 있습니다. SavedStateHandle의 saveable API는 현재 Experimental이기 때문에 다음 코드의 첫번째 줄과 같이 어노테이션을 추가해주어야 경고 표시가 사라지게 됩니다.

 

    @OptIn(SavedStateHandleSaveableApi::class)
    private var count: Int by savedStateHandle.saveable {
        mutableIntStateOf(0)
    }

    fun setSavedStateHandle(updatedCount: Int) {
        withMutableSnapshot {
            count = updatedCount
        }
    }