시간이 지나면서 변할 수 있는 값을 말한다. room 데이터베이스부터 클래스까지 매우 다양하고, 예시는 다음과 같다.
상태가 시간이 지남에 따라 변하는 값이라면, 변하는 이유는 무엇일까? Android 앱에서는 이벤트에 대한 응답으로 상태를 업데이트하는 것이다.
그러니까 이벤트는 다음과 같다.
UI 업데이트 루프를 아래 그림으로 알아보자.
컴포저블을 실행할 때 Compose에서 빌드한 UI를 컴포지션이라 한다. 데이터가 변경될 때 컴포지션을 업데이트하기 위해 컴포저블을 재실행하는 것은 리컴포지션이다. 컴포지션은 초기 컴포지션을 통해서만 생성되고 리컴포지션을 통해서만 업데이트될 수 있다. 컴포지션을 수정하는 유일한 방법은 리컴포지션 뿐이다.
그러니까 상태가 변경되면 Compose는 영향을 받는 함수들을 새로운 상태로 재실행하는 리컴포지션을 한다. 이때, 데이터가 변경된 요소만 재실행하고 아닌 요소는 건너뛰도록 확인해야 하는데 이럴려면 Compose가 추적할 상태를 알아야 한다. 이 상태 추적 시스템이 바로 **MutableState**
다.
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
val count: MutableState<Int> = mutableStateOf(0)
Text("You've had ${count.value} glasses.")
Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
하지만 이 코드는 아무리 버튼을 클릭해도 의도대로 count 변수의 value가 늘어나지 않는다. 왜냐하면 리컴포지션이 발생할 때 count가 다시 0이 되기 때문이다. 그래서 리컴포지션이 되어도 value를 유지하는 방법이 필요하고, 이것이 바로 인라인 함수 **remember**
다.
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
val count: MutableState<Int> = remember { mutableStateOf(0) }
Text("You've had ${count.value} glasses.")
Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
**by**
키워드를 사용하면 다음과 같이 간소화 할 수 있다.
var count by remember { mutableStateOf(0) }
그런데 remember
는 리포지션 간에 상태 유지를 할 뿐 화면 회전이나 lazyLayout에서 스크롤할 때와 같이 activity나 프로세스가 재생성되어 변경될 때는 상태가 유지되지 않는다. 이럴 때는 Bundle에 있는 값을 저장하는 rememberSaveable
을 사용해야 한다.
var count by rememberSaveable { mutableStateOf(0) }
@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
var count by rememberSaveable { mutableStateOf(0) }
StatelessCounter(count, { count++ }, modifier)
}
@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
if (count > 0) {
Text("You've had $count glasses.")
}
Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = count < 10) {
Text("Add one")
}
}
}
Compose에서 상태 호이스팅은 컴포저블을 stateless로 만들기 위해 상태를 컴포저블의 호출자로 옮기는 패턴이다. Compose에서 상태 호이스팅을 위한 일반적 패턴은 상태 변수를 다음 두 개의 매개변수로 바꾼다.
이렇게 stateless로 호이스팅한 상태는 다음 장점을 가진다.
예시를 보자.
// stateless method
@Composable
fun WellnessTaskItem(
taskName: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier, verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
text = taskName
)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}
// stateful method
@Composable
fun WellnessTaskItem(taskName: String, modifier: Modifier = Modifier) {
var checkedState by rememberSaveable { mutableStateOf(false) }
WellnessTaskItem(
taskName = taskName,
checked = checkedState,
onCheckedChange = { newValue -> checkedState = newValue },
onClose = {}, // we will implement this later!
modifier = modifier,
)
}
이 코드는 상태 변수 checkedState
를 stateful 함수에 정의하고, 동일한 이름의 stateless 함수에 상태를 전달하고 있다.
이전 xml에서와 같이 단순히 자료 구조를 ArrayList<T>
또는 mutableListOf
로 바꾼다고 해서 리스트를 변경 가능하게 만들 수 없다. 저 두 유형은 리컴포지션을 한다고 Compose에 알리지 않기 때문이다. 그래서 Compose에서 Observing이 가능한 MutableList
인스턴스를 따로 만들어야 한다.
mutableStateListOf
및 toMutableStateList
함수는 SnapshotStateList<T>
유형의 객체를 반환한다
코드로 살펴보자.
getWellnessTasks()
를 호출하고 확장 함수 toMutableStateList
를 사용하여 list를 만든다.
@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
WaterCounter()
val list = remember { getWellnessTasks().toMutableStateList() }
WellnessTasksList(list = list, onCloseTask = { task -> list.remove(task) })
}
}
private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
toMutableStateList
대신 mutableStateListOf
API를 사용하여 list를 만들 수 있지만, 예기치 않은 리컴포지션이 발생하고 UI 성능이 최적화되지 않을 수 있다. 아래와 같은 방법으로 list를 만들면 모든 리컴포지션에 중복 항복이 추가된다. 절대 이렇게 하지 말자.val list = remember { mutableStateListOf<WellnessTask>() }
list.addAll(getWellnessTasks())
list를 화면단까지 호이스팅했기 때문에 WellnessTaskList
를 수정한다.
@Composable
fun WellnessTasksList(
list: List<WellnessTask>,
onCloseTask: (WellnessTask) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(modifier = modifier) {
items(
items = list,
key = { task -> task.id }
) { task ->
WellnessTaskItem(taskName = task.label, onClose = { onCloseTask(task) })
}
}
}
mutableList는 데이터가 변경될 때마다 문제가 생기는데, 위치가 변경되는 item은 상태를 잃기 때문이다. 이를 방지하기 위해 WellnessTaskItem
의 id를 각 item의 key값으로 사용한다.
stateful 함수의 매개변수를 수정한다.
@Composable
fun WellnessTaskItem(
taskName: String, onClose: () -> Unit, modifier: Modifier = Modifier
) {
var checkedState by rememberSaveable { mutableStateOf(false) }
WellnessTaskItem(
taskName = taskName,
checked = checkedState,
onCheckedChange = { newValue -> checkedState = newValue },
onClose = onClose,
modifier = modifier,
)
}
이렇게 되면 다음 도식대로 잘 동작한다.
우선 주의점부터 알고가자. ViewModel은 컴포지션의 일부가 아니다. 따라서 메모리 누수가 발생할 수 있기 때문에 컴포저블에서 만든 상태를 보유해서는 안 된다.
기존 코드(ui 로직과 비즈니스 로직)를 ViewModel 코드를 작성해 옮기자.
class WellnessViewModel : ViewModel() {
private val _tasks = getWellnessTasks().toMutableStateList()
val tasks: List<WellnessTask>
get() = _tasks
fun remove(item: WellnessTask) {
_tasks.remove(item)
}
}
private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
화면의 매개변수로 ViewModel을 호출해 인스턴스화 한다.
@Composable
fun WellnessScreen(
modifier: Modifier = Modifier,
wellnessViewModel: WellnessViewModel = viewModel()
) {
Column(modifier = modifier) {
StatefulCounter()
WellnessTasksList(
list = wellnessViewModel.tasks,
onCloseTask = { task -> wellnessViewModel.remove(task) })
}
}
viewModel()
은 기존 ViewModel
을 반환하거나 지정된 범위에서 새 ViewModel을 생성한다. ViewModel 인스턴스는 범위가 활성화되어 있는 동안 유지된다. 예를 들어 컴포저블이 activity에서 사용되는 경우 viewModel()
은 활동이 완료되거나 프로세스가 종료될 때까지 동일한 인스턴스를 반환한다.
user의 입력에 따라 선택된 상태를 저장하고, 기본 값을 다음 data class에 설정하기
data class WellnessTask(val id: Int, val label: String, var checked: Boolean = false)
ViewModel에 선택된 새 상태의 값을 수신하는 메서드 구현하기
class WellnessViewModel : ViewModel() {
...
fun changeTaskChecked(item: WellnessTask, checked: Boolean) =
tasks.find { it.id == item.id }?.let { task ->
task.checked = checked
}
}
선택된 상태에 따라 ui가 움직이도록 화면 구현하기
@Composable
fun WellnessScreen(
modifier: Modifier = Modifier,
wellnessViewModel: WellnessViewModel = viewModel()
) {
Column(modifier = modifier) {
StatefulCounter()
WellnessTasksList(
list = wellnessViewModel.tasks,
onCheckedTask = { task, checked ->
wellnessViewModel.changeTaskChecked(task, checked)
},
onCloseTask = { task ->
wellnessViewModel.remove(task)
}
)
}
}
item마다 적용될 수 있도록 onCheckedTask 매개변수 전달하기. ViewModel의 인스턴스를 직접 전달하지 않고 매개변수로 받고 있다.
@Composable
fun WellnessTasksList(
list: List<WellnessTask>,
onCheckedTask: (WellnessTask, Boolean) -> Unit,
onCloseTask: (WellnessTask) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier
) {
items(
items = list,
key = { task -> task.id }
) { task ->
WellnessTaskItem(
taskName = task.label,
checked = task.checked,
onCheckedChange = { checked -> onCheckedTask(task, checked) },
onClose = { onCloseTask(task) }
)
}
}
}
checkbox의 상태가 task의 상위인 list 단까지 호이스팅되었기 때문에 더 이상 stateful 함수는 필요하지 않으므로 statless 메서드만 남기기.
@Composable
fun WellnessTaskItem(
taskName: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier, verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
text = taskName
)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}
앱이 동작하면 다음과 같이 제대로 동작하지 않기 때문에, checkedState의 변경사항을 추적하도록 수정하기. 방법은 다음 두 가지가 있음.
WellnessTask
를 변경하여 checkedState
가 Boolean
대신 MutableState<Boolean>
이 되도록 한다.데이터 클래스 변경하기
```kotlin
data class WellnessTask(val id: Int, val label: String, val checked: MutableState<Boolean> = mutableStateOf(false))
```
다음과 같이 데이터 클래스가 아니라 클래스로 만들면 더 간단해진다.
```kotlin
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class WellnessTask(
val id: Int,
val label: String,
initialChecked: Boolean = false
) {
var checked by mutableStateOf(initialChecked)
}
```