
이 글은 JetpackCompose 공식문서 - 상태 관리를 읽어보면서 정리한 글 입니다.
앱의 상태는 시간이 지남에 따라 변할 수 있는 값입니다. 이는 매우 광범위한 정의로서 Room 데이터베이스부터 클래스 변수까지 모든 항목이 포함됩니다.
Android에서는 사용자에게 상태를 표시합니다. 그리고 Jetpack Compose에서는 Android앱에서 상태를 저장하고 사용하는 위치와 방법을 명시적으로 나타낼 수 있습니다. 이 글에서는 Jetpack Compose에서 제공하는 상태처리 API, 상태 관리 방법등에 대해 정리해보도록 하겠습니다😌.
Compose에서는 상태가 업데이트되면 그에 따라 재구성(Recomposition)을 실행합니다. 따라서 UI가 새로운 상태에 따라 업데이트 되려면 새로운 상태를 명시해 주어야 합니다.
OutlinedTextField(
value = "",
onValueChange = { },
label = { Text("Name") }
)
위 코드는 새로운 상태를 명시해 주지 않았기 때문에 아무 반응도 하지 않습니다. 다음 코드는 새로운 상태를 명시해주는 예시 코드입니다.
val textState = remember { mutableStateOf("") } // remeber은 밑에서 자세히 다루겠습니다.
OutlinedTextField(
value = textState.value, // 상태값을 받아옵니다.textState가 업데이트 되면 value도 업데이트 됩니다.
onValueChange = { textState.value = it }, // 새로운 상태를 명시합니다.
label = { Text("Name") }
)
위의 예시코드에 remember{} 람다가 있었습니다. 이 람다는 메모리에 단일 객체를 저장합니다. remember에 의해 계산된값은 초기 컴포지션시에 화면에 구성(Composition)됩니다. 그리고 재구성(Recomposition)시에도 저장된 값을 반환합니다.
remember에는 변할 수 있는 값(Mutable)과 변하지 않는 값(Immutable)을 모두 저장할 수 있습니다.
remember는 객체를 컴포지션에 저장하고, remember를 호출한 Composable이 컴포지션에서 삭제되면 그 객체를 잊습니다.
Composable에서 MutableState를 선언하는 방법은 다음 세 가지 방법이 있습니다.
위 세 가지 방법은 모두 동일한 것으로, Composable함수 작성시 가장 읽기 쉬운 방법으로 선언하면 됩니다.
remember함수가 재구성시에 상태를 유지하는데에는 도움이 되지만, 구성 변경(화면전환 등)시에는 상태를 유지하지 못합니다. 따라서 이런 상황에는 rememberSavable을 사용해야합니다. rememberSavable은 Bundle에 저장할 수 있는 값을 자동으로 저장합니다. rememberSavable에 대한 내용은 밑에서 더 자세히 다뤄보도록 하겠습니다.
Jetpack Compose에서는 상태를 State로만 읽기 때문에 다른 유형을 사용한다면 State로 변환해야 합니다. 다음 나열되는 유형들은 Compose에서 유형 변경을 지원하는 유형들입니다.
위 유형들은 Compose의 확장함수를 통해 state로 변환할 수 있습니다.
스테이트풀(Stateful)한 Composable은 remember을 사용하여 Composable함수 내에서 상태를 만들고, 관리합니다.
@Composable
fun NameTextField() {
val name = remember { mutableStateOf("") }
OutlinedTextField(
value = name.value,
onValueChange = { name.value = it },
label = { Text("Name") }
)
}
NameTextField함수는 함수내에서 State(이 함수에서는 name)를 변경하고 저장하므로 스테이트풀한 함수입니다. 이런 함수는 호출자(상위함수)에서 상태를 제어할 필요가 없고, 상태를 직접 관리하지 않아도 상태를 사용할 수 있는 경우에 유용합니다. 하지만 내부에 상태를 갖는 Composable함수는 재사용하기 어렵고, 테스트하기 어렵다는 단점이 있습니다.
반대로 스테이트리스 함수는 상태를 갖지 않습니다. 이런 함수들은 상태를 매개변수를 통해 전달 받습니다.
@Composable
fun NameTextField(name: String, onNameChanged: (String) -> Unit) {
OutlinedTextField(
value = name,
onValueChange = { onNameChanged(it) },
label = { Text("Name") }
)
}
이렇게 상태를 전달받는것을 상태호이스팅이라고 하는데, 이 부분은 밑에서 자세히 다루겠습니다.
정리하자면, 스테이트풀은 호출자가 상태를 염두하지 않을 때 편리하고, 스테이트리스는 호출자가 상태를 제어하거나 끌어올려야할 때 필요합니다.
Compose에서 상태 호이스팅은 Composable함수를 스테이트리스로 만들기 위해 상태를 호출자(상위함수)로 옮기는 패턴입니다. 호이스팅할 수 있는 변수의 일반적인 유형으로는 다음 두가지가 있습니다.
이렇게 호이스팅한 상태는 다음과 같은 특징을 가집니다.
@Composable
fun Screen() {
val data = remember {
mutableStateOf("")
}
Content(text = data.value) {
//doOnClick
}
}
@Composable
fun Content(text: String, doOnViewClick: () -> Unit) {
Text(text = text, Modifier.clickable { doOnViewClick() })
}
위 코드는 Content에 표시할 text 상태와, Content를 클릭했을때 상태 변경을 요청하는 이벤트를 호이스팅한 예시입니다. 이런식으로 상태 호이스팅을 하면, Content()는 여러상황에서 재사용하며 테스트할 수 있게됩니다.

이런식으로 상태는 내려오고 이벤트는 올라가는 페턴을 단방향 데이터 흐름이라고 합니다.
상태 호이스팅할때는 다음과 같은 규칙들을 지켜야 합니다.
이 규칙들 보다 더 높은 수준의 Composable로 끌어올리는건 상관 없지만, 이 규칙보다 낮으면 단방향 데이터흐름을 지키기 어렵거나 불가능합니다.
Compose에서는 rememberSaveable을 사용하여 프로세스가 다시 실행된 뒤에 UI상태를 복원합니다. rememberSaveable에는 Bundle에 추가할 수 있는 항목들을 저장하지만, 다른 항목들도 저장할 수 있는 방법이 있습니다. 다음 제시되는 방법을 사용하여 저장할 수 있습니다.
@Parcelize 어노테이션을 붙여서 객체를 번들로 저장하는 방법입니다.
@Parcelize
data class Person(val name: String, val age: Int) : Parcelable
@Composable
fun PersonScreen() {
var selectedCity = rememberSaveable {
mutableStateOf(Person("JaeWon", 19))
}
}
@Parcelize가 적합하지 않을 경우 mapSaver을 사용하여 저장할 수 있습니다.
data class Person(val name: String, val age: Int)
val PersonSaver = run {
val nameKey = "Name"
val ageKey = "Age"
mapSaver(
save = { mapOf(nameKey to it.name, ageKey to it.age) },
restore = { Person(it[nameKey] as String, it[ageKey] as Int) }
)
}
@Composable
fun PersonScreen() {
var selectedCity = rememberSaveable(stateSaver = PersonSaver) { //지정한 saver을 매개변수로 넣어줍니다.
mutableStateOf(Person("JaeWon", 19))
}
}
listSaver는 MapSaver와 달리 키를 정의할 필요가 없습니다.
data class Person(val name: String, val age: Int)
val PersonSaver = listSaver<Person,Any> (
save = { listOf(it.name, it.age) },
restore = { City(it[0] as String, it[1] as Int) }
)
@Composable
fun PersonScreen() {
var selectedCity = rememberSaveable(stateSaver = PersonSaver) { //지정한 saver을 매개변수로 넣어줍니다.
mutableStateOf(Person("JaeWon", 19))
}
}
간단한 상태들은 Composable함수 내에서 자체적으로 관리해도 무방합니다. 하지만 상태의 수가 많이 지거나, 상태를 관리하기위한 로직이 필요한경우 상태관리의 책임을 다른 클래스(상태 홀더)에 위임하는것이 좋습니다.
상태 홀더: 컴포저블의 로직과 상태를 관리합니다.
Compose에서 상태를 관리할때는 다음 세 가지를 사용하여 관리할 수 있습니다.
Android앱에는 고려할 상태가 네 가지 있습니다.
상태 관리에 복잡한 로직이 있다면 상태 홀더에 위임해야합니다. 그러면 로직을 테스트하기도 쉽고, Composable함수의 복잡성이 줄어든다는 장점이 있습니다. 이 방식은 관심사 분리 원칙을 따릅니다.
상태 홀더가 UI 로직과 UI 요소의 상태를 포함하면, Composable함수가 UI요소를 방출합니다.
상태 홀더는 컴포저블의 수명주기를 따르므로 Compose 종속 항목을 사용할 수 있습니다.
상태 홀더를 사용할 때는 상태홀더 객체를 기억(remember)하는 메서드를 만들어 사용하는 것이 좋습니다.
class MyAppState( // MyAppState를 갖고있는 상태 홀더
val scaffoldState: ScaffoldState,
val navController: NavHostController,
private val resources: Resources,
/* ... */
) {
val bottomBarTabs = /* State */
val shouldShowBottomBar: Boolean
get() = /* ... */
fun navigateToBottomBarRoute(route: String) { /* ... */ }
fun showSnackbar(message: String) { /* ... */ }
}
@Composable
fun rememberMyAppState( // 상태를 저장하고 있는 메서드. 다른 Composable함수에서 호출하여 사용할 수 있습니다.
scaffoldState: ScaffoldState = rememberScaffoldState(),
navController: NavHostController = rememberNavController(),
resources: Resources = LocalContext.current.resources,
/* ... */
) = remember(scaffoldState, navController, resources, /* ... */) {
MyAppState(scaffoldState, navController, resources, /* ... */)
}
ViewModel은 특별한 유형의 상태 홀더로서 다음 작업들을 실행합니다.
ViewModel은 컴포지션보다 수명주기가 깁니다. 따라서 컴포지션의 수명을 따르는 상태를 ViewModel이 오랬동안 참조하면 안 됩니다. 그렇게 되면 메모리 누수가 발생할 수 있기 떄문입니다.
Hilt를 사용하고 있다면 Composable함수에 ViewModel을 매개변수로 넣을 때 androidx.hilt.navigation.compose.hiltViewModel()를 사용하면 편리합니다.
이렇게 해서 Compose의 상태에 대해 알아보았습니다. 다음 글에서는 Composable의 생명 주기(수명 주기)에 대해 알아보도록 하겠습니다. 끝까지 읽어주셔서 감사하고 즐거운 개발 되세요😌.
참고자료 - 상태 관리