[Compose] Composable 함수의 데이터 관리
Compose는 Composable 함수를 기반으로 UI를 구성한다.
UI를 구성하는 과정에서 다양한 데이터를 저장하고 사용하지만, Composable 함수의 특성 상 기존처럼 변수를 선언한다면 원하는 결과 값을 얻을 수 없다.
Recomposition이 일어나면 해당 Composable 함수를 다시 재실행하여 기존의 데이터가 다 초기화되기 때문이다.
또한 UI와 직접적으로 연관이 있는 데이터의 경우 값이 변경됨에 따라 Recomposition이 일어나야 하지만 기존의 변수를 사용한다면 값의 변경을 알아차릴 수 없기 때문에 Recomposition 자체가 일어나지 않는다.
그렇다면 Composable 함수에서는 데이터를 어떻게 선언해야 할까?
State<T>
Composable 함수가 Recomposition이 되는 이유는 다음과 같다.
- Composable 함수의 매개 변수가 변경되는 경우
- 매개 변수가 Unstable 한 경우
- 관찰 중인 State가 변경되는 경우
데이터의 변경에 따라 UI가 변경되어야 하는 경우 해당 데이터를 State<T> 객체로 감싸 관찰 가능하도록 해야 한다.
mutableStateOf(value)
이제 State 객체의 값이 변경되면 Recomposition이 일어나 Composable 함수는 재실행 될 것이다.
그러나 재실행됨에 따라 객체의 값이 초기 값으로 다시 설정되어 Recomposition만 일어나고 UI는 아무런 변동사항이 일어나지 않았다.
그렇다면 Composable 함수 내에서 업데이트 된 데이터를 Recomposition이 일어나도 기억하고 있어야 한다.
remember
val intData: Int = remember { 0 }
val stateData: MutableState<Int> = remember { mutableStateOf(0) }
var stateData2: Int by remember { mutableStateOf(0) }
직관적인 함수 이름처럼 remember 함수를 사용하여 업데이트 된 데이터를 Recomposition이 일어나도 기억할 수 있다.
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
currentComposer.cache(false, calculation)
기억할 수 있는 이유는 Composer에 캐시로 저장해놨다가 Recomposition이 일어나면 캐시에서 확인 후 업데이트 해주기 때문이다.
Configuration Change가 일어나도 기존의 데이터가 유지될까?
Configuration Change
Compose는 Activity에서 최초 선언된다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
RecompositionTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
ParentCompose(
modifier = Modifier.padding(innerPadding),
)
}
}
}
}
}
기존과 마찬가지로 Configuration Change가 발생하면 Activity는 Destroy 후 다시 onCreate가 실행되기 때문에 Composable 함수들은 Recomposition이 아닌 다시 처음부터 Composition이 일어난다. 이 과정에서 Composer의 캐시도 초기화되기 때문에 값이 유지되지 않는다.
@Composable
fun TimerCompose(
modifier: Modifier = Modifier,
) {
var timerState by remember { mutableStateOf(false) }
var time by remember { mutableIntStateOf(0) }
Column(
modifier = modifier
.padding(20.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if(timerState){
Timer(
plus = { time++ }
)
}
Text("$time")
Button(
onClick = { timerState = !timerState }
) {
Text(if(timerState) "Off" else "On")
}
}
}
@Composable
private fun Timer(
plus: () -> Unit
){
LaunchedEffect(Unit) {
while(true){
delay(1000)
plus()
}
}
}
1초마다 time이 증가하면서 ParentCompose 함수가 Recomposition이 일어나지만 time의 데이터는 최신 상태로 유지되고 있다가 다크모드를 해체하는 순간 값이 0으로 초기화 되는 것을 확인할 수 있다.
rememberSaveable
Configuration Change가 발생해도 rememberSaveable을 사용하면 기존의 데이터를 유지할 수 있다.
rememberSabeable은 어떤 방법으로 값을 저장하는지 살펴보자.
@Composable
fun <T : Any> rememberSaveable(
vararg inputs: Any?,
saver: Saver<T, out Any> = autoSaver(),
key: String? = null,
init: () -> T
): T {
val compositeKey = currentCompositeKeyHash
// key is the one provided by the user or the one generated by the compose runtime
val finalKey = if (!key.isNullOrEmpty()) {
key
} else {
compositeKey.toString(MaxSupportedRadix)
}
@Suppress("UNCHECKED_CAST")
(saver as Saver<T, Any>)
val registry = LocalSaveableStateRegistry.current
val holder = remember {
// value is restored using the registry or created via [init] lambda
val restored = registry?.consumeRestored(finalKey)?.let {
saver.restore(it)
}
val finalValue = restored ?: init()
SaveableHolder(saver, registry, finalKey, finalValue, inputs)
}
val value = holder.getValueIfInputsDidntChange(inputs) ?: init()
SideEffect {
holder.update(saver, registry, finalKey, value, inputs)
}
return value
}
위의 코드를 간단하게 살펴보면 SaveableHolder에 값을 넣어 holder를 통해 이전 값을 가져오거나 바뀐 데이터를 업데이트 해주는 것을 볼 수 있다. SaveableHolder 내부를 살펴보자.
private class SaveableHolder<T>(
private var saver: Saver<T, Any>,
private var registry: SaveableStateRegistry?,
private var key: String,
private var value: T,
private var inputs: Array<out Any?>
) : SaverScope, RememberObserver {
private var entry: SaveableStateRegistry.Entry? = null
/**
* Value provider called by the registry.
*/
private val valueProvider = {
with(saver) {
save(requireNotNull(value) { "Value should be initialized" })
}
}
fun update(...){...}
...
}
값을 update하는 함수 등 다양한 메서드가 있는데 값은 Saver 객체에 저장하고 있다.
Saver란?
Original 클래스의 객체를 단순화하고 Saveable로 변환하는 방법을 설명하는 인터페이스
Bundle로 된 savedInstatanceState를 사용하여 상태를 유지한다.
rememberSaveable에는 Bundle이 지원하는 데이터만 저장할 수 있다.
Bundle은 원시 데이터만 지원하기 때문에 객체를 저장하기 위해서는 2가지 방법이 있다.
1. @Parcelize 어노테이션 사용
parcelize 어노테이션으로 직렬화 시켜 저장하는 방법
@Parcelize
data class User(
val name: String
)
@Composable
fun Test(){
val user by rememberSaveable { User(name="Kim") }
}
2. Custom Saver 구현
interface Saver<Original, Saveable : Any> {
/**
* Convert the value into a saveable one. If null is returned the value will not be saved.
*/
fun SaverScope.save(value: Original): Saveable?
/**
* Convert the restored value back to the original Class. If null is returned the value will
* not be restored and would be initialized again instead.
*/
fun restore(value: Saveable): Original?
}
- List
data class User(
val name: String,
val age: Int
)
val saver = listSaver(
save = { user: User ->
listOf(
user.name,
user.age
)
},
restore = {
User(
name = it[0] as String,
age = it[1] as Int
)
}
)
- Map
val saver = mapSaver(
save = { user: User ->
mapOf(
"name" to user.name,
"age" to user.age
)
},
restore = {
User(
name = it["name"] as String,
age = it["age"] as Int
)
}
)
위와 같이 Saver를 만들었다면 rememberSaveable 매개 변수로 넘겨주고 사용하면 된다.
val user = rememberSaveable(saver = saver) { User(name = "Kim", age = 20) }