Jetpack Compose 상태 관리

목차
$$ F(State) = View $$
이 함수는 선언형 UI(Declarative UI)를 표현하는 식이다. UI 컴포넌트(F)에 상태(State)를 전달하면 그 상태값에 따라 UI를 그리는(View) 방식을 취한다. 이 공식은 플랫폼을 떠나서 선언형 UI의 방식을 가지는 프레임워크라면(Jetpack Compose 뿐 아니라 React, Flutter, Switft UI 등) 모두 공유한다.
Jetpack Compose도 전혀 다르지 않다. Composable 함수(F)에 상태값(State)을 매개변수로 전달하면 그에 맞춰 UI를 렌더링(View)한다.
Jetpack Compose의 상태 #
컴포저블(Composable) 함수는 그 함수에 전달된 매개변수가 바뀌어야 리컴포지션1을 수행한다.
@Composable
fun InputField() {
var text by remember { mutableStateOf("") }
TextField(
modifier = Modifier.fillMaxWidth(),
value = text,
onValueChange = { text = it }
)
}
위의 코드는 TextField
를 사용해 입력을 받는 UI를 그리는 간단한 예시이다.
Jetpack Compose에서 TextField
는 사용자가 입력한다고 자동으로 업데이트되지 않는다.
사용자 입력이 발생하면 onValueChange
를 통해 변경된 값이 전달되고, 그 값을 text
에 반영한다.
그러면 변경된 text
가 다시 TextField
로 전달되며 리컴포지션을 수행하고 UI가 업데이트된다.
예전의 명령형 UI 방식인 XML View에서는 UI 업데이트를 위해서 사용자가 입력한 값을 View 외부에 저장할 필요가 없었다. 하지만 선언형 UI 방식인 Jetpack Compose는 외부에 상태값을 두고 전달해야 UI가 업데이트 된다.
Jetpack Compose는 상태 저장을 위해 State
라는 객체를 사용한다.
State
와 이를 상속하는 MutableState
는 값이 변경되면 리컴포지션을 일으켜 변경된 상태값으로 UI를 업데이트하도록 유도한다.
때문에 상태가 바뀌면 그에 맞춰 UI가 변화하는 선언형 UI 패러다임에 따르기 위해 상태는 State
로 만들어 주어야 한다.
상태 저장 #
Jetpack Compose에서 상태값은 보통 일반 변수로 생성하지 않는다.
위에서 설명한 대로 State
로 만들어야 한다.
Jetpack Compose에서는 상태를 만들기 위해 mutableStateOf
와 같은 여러 가지 API를 제공한다.
그리고 Compose의 영역이 아닌 여러 가지 다른 상태 객체와의 상호운용성을 위해 호환성 API를 제공한다.
val stateFromFlow by stateFlow.collectAsState() // Coroutines Flow
val stateFromLiveData by liveData.observeAsState() // LiveData
val stateFromRx by observable.subscribeAsState() // RxJava
위와 같은 호환성 API는 Jetpack Compose와 Non-Compose 코드와의 연결을 손쉽게 만들어준다. 이를 통해서 ViewModel 등에 선언된 상태값을 Compose UI에서 편리하게 반영할 수 있다.
StateFlow
값을 State
로 바꿔주는 API는 포함되어 있다.
하지만 LiveData
를 위한 observeAsState
나 RxJava의 Observable
을 위한 subscribeAsState
는 추가 디펜던시가 필요할 수 있다.@Composable
fun InputField() {
var text by remember { mutableStateOf("") } // <-- remember를 사용해 저장.
TextField(
modifier = Modifier.fillMaxWidth(),
value = text,
onValueChange = { text = it }
)
}
다시 같은 코드를 살펴보면 텍스트를 저장 할 상태값을 var text = ""
와 같이 일반 변수로 선언하지 않고 mutableStateOf
를 사용해 만든 것을 알 수 있다.
MutableState
로 만듦으로서 text
가 변할 때 자동으로 리컴포지션을 일으키며 UI에 반영할 수 있다.
하지만 여기서 remember
에 대한 의문점이 생길 수 있다.
왜 상태 변수를 var text by mutableStateOf("")
가 아니라 var text by remember { mutableStateOf("") }
로 선언했을까?
그 이유는 리컴포지션이 일어나도 값을 기억해야 하기 때문이다.
컴포저블 함수가 리컴포지션 되어 재호출 될 때, 컴포저블 함수 내부에서 선언된 지역 변수는 다시 초기화된다.
초기화 되는 것을 피하려면 그 상태값을 매개변수로 받도록 다시 말해 더 바깥에서 관리하게 만들어 리컴포지션 범위를 벗어나게 만들거나(상태 호이스팅), remember
와 같은 상태 저장 API를 활용해 리컴포지션을 넘어 기억할 수 있게 만들어야 한다.
상태 호이스팅 #
위에서 계속 사용한 예시 코드는 InputField
내부에 text
라는 상태값을 관리하고 있다.
이렇게 내부적으로 상태를 관리하고 있는 컴포저블을 Stateful 이라고 부른다.
Stateful 방식으로 컴포넌트를 설계하면 외부에서의 사용은 편해질 수 있다. 내부적으로 스스로 상태를 관리하기 때문이다. 하지만 상태가 외부의 다른 컴포넌트와 공유되어야 하거나 더욱 유연한 사용을 할 수 있게 만드려면 상태를 외부로 분리하는 것이 좋다.
@Composable
fun InputScreen() {
var text by remember { mutableStateOf("") }
Column {
InputField(
modifier = Modifier.fillMaxWidth(),
text = text,
onTextChange = { text = it }
)
// ...
}
}
@Composable
fun InputField(
text: String,
onTextChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
modifier = modifier,
value = text,
onValueChange = onTextChange
)
}
상태를 호이스팅2해서 코드를 변경해 보았다.
InputField
내부에 있던 text
상태를 바깥에서 받고 text
를 업데이트 하던 이벤트도 람다로 전달하도록 만들었다.
이제 상태는 외부에서 전달받기 때문에 InputField
는 상태 관리에 대한 부담이 사라졌다.
또한 InputField
의 재활용이 쉬워졌다.
여러 개의 InputField
를 만들어도 상태값만 전달하면 쉽게 재사용 할 수 있다.
테스트 용이성도 높아졌다.
InputField
에 여러 가지 경우에 따른 상태값을 전달해보며 테스트하기 더 쉽다.
이렇게 상태를 가지지 않는 컴포저블을 Stateless라고 부른다.
상태를 호이스팅 하면서 자연스럽게 단방향 데이터 흐름(Unidirectional Data Flow)3 이 이루어졌다. 선언형 UI에서는 가능하다면 단방향으로 흐름을 만들어 하위 요소는 UI를 업데이트 하는데에 집중하고 이벤트 처리는 상위로 끌어올려 UI의 재사용성과 테스트 용이성을 높이는 편이 좋다.
remember #
앞에서는 Jetpack Compose에서 상태란 무엇이고 상태를 어디에서 관리하는지에 대해서 간략히 다루었다.
그리고 그 과정에서 remember
와 같은 상태 저장 API를 활용하는 예시 코드를 보았다.
이제는 그 remember
에 대해서 간략히 정리해 보려고 한다.
remember
는 가장 기본적인 상태 관리 API로, 컴포저블이 유지되는 한 리컴포지션 전반에서 값을 기억할 수 있게 해준다.
remember
를 사용하지 않고 컴포저블 내부에서 값을 선언하면 리컴포지션이 일어날 때 다시 초기화되어 사라질 수 있다.
저장해야 할 변수가 초기화되지 않고 이전의 값을 유지하게 하기 위해서 필요한 API이다.
@Composable
inline fun <T> remember(key1: Any?, crossinline calculation: @DisallowComposableCalls () -> T): T {
return currentComposer.cache(currentComposer.changed(key1), calculation)
}
remember
의 실제 코드는 위와 같이 생겼다.
실제로는 key
가 없는 remember
부터 여러 개 있는 remember
까지 더 다양한 바리에이션이 존재하지만 key
의 개수 차이 정도이므로 생략한다.
하나하나 살펴보자.
currentComposer
는 현재 사용할 수 있는Composer
를 가져오는 API이다.Composer
는 Jetpack Compose에서 UI 트리 구성부터 업데이트, 각 컴포저블의 생명주기 등을 관리하는 핵심 컴포넌트다.Composer
는 상태 관리도 담당하기 때문에remember
는Composer
를 통해 값을 기억한다.key
는 선택적으로 줄 수 있는 파라미터로remember
가 값을 다시 계산해야 할 조건이 있을 때 사용하는 파라미터다. 만약key
변경되면remember
는calculation
을 다시 계산한다.calculation
은remember
가 기억해야 할 값을 계산하는 람다 파라미터다. 컴포지션이 일어났을 때 기억해야 할 값을 계산하고 메모리에 저장한 후 리컴포지션 시 다시 계산하지 않고 저장해 두었던 값을 반환한다. 만약key
가 변경되면 다시 실행된다.
calculation
람다에는 @DisallowComposableCalls
어노테이션이 붙어 있는데, 이 어노테이션은 calculation
람다 안에서 컴포저블 함수를 호출하지 않아야 한다는 것을 표시하는 마커다.remember
를 사용한 코드는 리컴포지션을 일으킬 가능성이 높은데 만약 calculation
안에서 컴포저블 함수가 호출되면 리컴포지션이 무한 루프에 빠질 수도 있기 때문에 원천 방지되어 있다.또한
remember
는 상태 관리 API이지 UI를 그리는 API가 아니다.
remember
내부에서 UI를 그리는 것은 의도와 맞지 않다.@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
@Suppress("UNCHECKED_CAST")
return rememberedValue().let { // <-- 1
if (invalid || it === Composer.Empty) { // <-- 2
val value = block() // <--┒
updateRememberedValue(value) // | 3
value // <--------------------------------┚
} else it // <-- 4
} as T
}
한 단계 더 들어가 Composer.cache
의 코드를 보면 위와 같이 구현되어 있다.
실제로 이 함수를 직접 사용할 일은 없지만 이해를 위해 살짝만 살펴보자.
rememberedValue
는Composer
에 기억된 값을 꺼내는 함수다. 만약 기억된 값이 없으면Composer.Empty
를 반환한다.이전에 기억한 값이 없거나(
Composer.Empty
)remember
에서key
가 변경되었다면(currentComposer.changed(key1)
) 3번, 아니면 4번 동작으로 간다.block
을 실행해 값을 계산하고updateRememberedValue
를 통해 저장한 뒤 반환한다.꺼낸 값을 그대로 반환한다.
여기까지만 살펴봐도 remember
의 동작에 대해서 간단히 이해해볼 수 있다.
remember의 한계 #
remember
는 리컴포지션이 일어나도 이전의 값을 기억하고 재사용할 수 있게 해주는 API이지만 한계점도 존재한다.
위에서 살짝 언급한 대로 remember
는 자신이 속한 컴포저블이 유지되는 동안 값을 기억한다.
다시 말하면 자신이 속한 컴포저블이 더 이상 유지되지 않을 때, remember
에 저장 된 값도 함께 정리된다.
@Composable
fun Screen() {
LazyColumn {
items(100) { index ->
Item(index = index)
}
}
}
@Composable
fun Item(
index: Int,
modifier: Modifier = Modifier
) {
var count by remember { mutableIntStateOf(0) }
Column(modifier = modifier) {
Text(text = "[$index] count=$count")
Button(
onClick = {
count++
Log.d("test", "[$index] count=$count")
}
) {
Text(text = "Click!")
}
}
}
위의 코드로 컴포저블이 정리되었을 때 remember
에 기억된 값도 함께 정리되는 것을 간단하게 확인해 볼 수 있다.
100개의 Item
이 있는 LazyColumn
이 있다.
그리고 각 Item
에는 count
라는 이름의 클릭 횟수 상태값이 있다.
count
는 remember
를 통해 저장되어 있다.
만약 위 코드를 실행해서 아래 동작을 수행하면 어떻게 될까?
LazyColumn
은 아이템의 개수가 많아도 화면에 보이지 않는 아이템 컴포저블을 그리지 않는다.- index 0인 버튼을 3번 클릭한다.
- index 0인 버튼이 보이지 않을 때 까지 스크롤한다.
- 다시 index 0인 버튼이 보일 떄 까지 스크롤한다.
- index 0인 버튼을 2번 클릭한다.
실제 로그 결과는 아래와 같다.
[0] count=1
[0] count=2
[0] count=3
[0] count=1
[0] count=2
count
는 3번 클릭하는 동안 정상적으로 증가했다.
하지만 화면에 보이지 않게 된 후로 컴포저블이 정리되며 저장된 값도 함께 메모리에서 제거되었다.
그 후 다시 화면에 보이며 초기화되었고 다시 2까지 증가했다.
즉, 컴포저블이 정리되면 그에 속해 있던 remember
도 함께 정리된다.
따라서 컴포저블이 화면에서 보이지 않게 되어도 값이 유지되어야 하는 경우 상태를 더 상위로 호이스팅 하는 것이 좋다.
rememberSaveable #
앞서 remember
는 컴포저블이 유지되어 있는 한 리컴포지션을 넘어 상태값을 유지할 수 있지만, 컴포저블이 정리된 경우 사라진다는 점을 소개했다.
하지만 아무리 최상위 컴포저블에서 remember
를 사용해도 프로세스가 중지 후 복원되는 등 더 상위의 과정이 재동작 하는 경우 상태를 유지할 수 없다.
예를 들어 안드로이드에서 Activity가 백그라운드로 유지되다가 복원되는 경우, 화면 회전이 일어나 configuration change가 일어나는 경우 등이 있다.
그런 경우에도 상태를 유지하려면 rememberSaveable
을 사용할 수 있다.
@Composable
fun <T : Any> rememberSaveable(
vararg inputs: Any?,
saver: Saver<T, out Any> = autoSaver(),
key: String? = null,
init: () -> T
): T {
val compositeKey = currentCompositeKeyHash
val finalKey = if (!key.isNullOrEmpty()) {
key
} else {
compositeKey.toString(MaxSupportedRadix)
}
@Suppress("UNCHECKED_CAST") (saver as Saver<T, Any>)
val registry = LocalSaveableStateRegistry.current // <-- SaveableStateRegistry 사용
val holder = remember {
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
}
위의 코드는 rememberSaveable
의 구현이다.
복잡한 코드처럼 보이지만 중요한 부분은 LocalSaveableStateRegistry
를 사용한다는 점이다.
현재 컴포저블 스코프에서 가져올 수 있는 SaveableStateRegistry
를 가져오는 코드이며, 이 registry
를 통해서 값을 저장하고 복원하는 것을 살펴볼 수 있다.
SaveableStateRegistry
는 Bundle
을 통해 값을 직렬화/역직렬화 해 관리하는 컴포넌트이다.
그리고 이렇게 저장된 값은 configutation change나 프로세스 복원 시에도 읽어서 값을 복원할 수 있다.
정리하자면 rememberSaveable
은 remember
처럼 값을 저장하지만 기억하는 방식에서 차이점이 있다.
remember
가 컴포저블이 유지되는 한 저장한다면 rememberSaveable
은 Bundle
을 사용한다.
따라서 앱이 중단된 시점의 상태를 유지해야 한다면 remember
보다 rememberSaveable
가 적합하다.
rememberSaveable
는 Bundle
이 지원하는 타입이여야 값을 저장할 수 있다.
하지만 Jetpack Compose를 사용하다 보면 다양한 상태를 저장해야 한다.
그렇기 때문에 rememberSaveable
에 값을 저장할 수 있는 여러 가지 방법이 있다.
1. Bundle이 지원하는 타입
val state by rememberSaveable { mutableIntStateOf(0) }
Bundle
에서 지원하는 종류의 타입은 특별한 조치 없이도 쉽게 값을 저장할 수 있다.
2. Parcelable 타입
@Parcelize
data class Person(
val firstName: String,
val lastName: String
) : Parcelable
val state by rememberSaveable { mutableStateOf(Person("Jaeung", "Cheon")) }
커스텀한 클래스라도 Parcelable
타입은 Bundle
로 (역)직렬화할 수 있기 때문에 저장할 수 있다.
3. Saver 구현
복잡한 객체라면 직접 저장/복원 방식을 구현하는 Saver
를 지정할 수도 있다.
Saver
구현을 조금이라도 편하게 할 수 있게 listSaver
와 mapSaver
API가 제공된다.
data class Person(
val firstName: String,
val lastName: String
)
val PersonSaver = listSaver<Person, Any>(
save = { listOf(it.firstName, it.lastName) },
restore = { Person(it[0] as String, it[1] as String) }
)
val state by rememberSaveable(saver = PersonSaver) {
mutableStateOf(Person("Jaeung", "Cheon"))
}
위의 코드는 listSaver
를 사용해 Person
객체를 저장하는 예시 코드이다.
save
와 restore
를 구현해 Person
이라는 객체를 어떻게 List<T>
형태로 분해하고 조립할지를 정의하면 된다.
data class Person(
val firstName: String,
val lastName: String
)
val PersonSaver = mapSaver<Person, Any>(
save = { mapOf("FirstName" to it.firstName, "LastName" to it.lastName) },
restore = { Person(it["FirstName"] as String, it["LastName"] as String) }
)
val state by rememberSaveable(saver = PersonSaver) {
mutableStateOf(Person("Jaeung", "Cheon"))
}
mapSaver
도 크게 다르지 않다.
save
와 restore
를 구현해 Person
이라는 객체를 어떻게 Map<String, T>
형태로 분해하고 조립할지를 정의하면 된다.
listSaver
와 mapSaver
는 성능 면에서 무의미할 정도로 차이가 없으며 가독성과 편의에 따라 선택해 사용하면 된다.위와 같이 rememberSaveable
을 사용하면 안드로이드에서 Activity가 회전해 configuration change가 발생하는 등 프로세스가 재시작해도 값을 다시 복원할 수 있다.
실제로 LazyColumn
, LazyRow
의 LazyListState
또는 바텀시트의 ModalBottomSheetState
와 같이 복잡하면서 상태를 유지해야 하는 경우는 내부적으로 rememberSaveable
을 사용해 구현하는 것을 확인해 볼 수 있다.
하지만 상태 저장/복원을 하는 데에 필요한 동작이 더 무겁기 때문에 remember
에 비해서 오버헤드가 존재한다는 단점이 있다.
필요에 따라 적절하게 선택하며 상태 값을 저장하면 최적화된 앱을 만드는 데에 도움이 될 것이다.
Recomposition. Composable 함수를 다시 재구성하는 동작을 의미하며, 리컴포지션이 일어나면 Composable 함수가 다시 실행되며 변경 된 상태를 반영해 UI를 업데이트한다. ↩︎
상태 호이스팅이란, 상태 관리 포인트를 컴포넌트를 호출하는 부모 컴포넌트 쪽으로 이동시켜 컴포넌트는 상태를 관리하지 않고 UI 로직에 집중하도록 만드는 패턴을 말한다. ↩︎
상태 데이터는 아래 방향으로만 전달하고 이벤트는 상위로만 전달하게 만들어 캡슐화, 테스트 용이성을 높이고, 상태 관리 포인트를 집중시키면서 상태를 받는 소스를 하나로 만들어 데이터가 업데이트 되었을 때 일관되게 UI가 업데이트 될 수 있도록 할 수 있는 방식이다. ↩︎