compose의 상태(state)를 알아보자!

Hanseul Lee·2023년 4월 3일
1

Compose를 공부하자!

목록 보기
2/2

Compose에서 상태(state)란?

시간이 지나면서 변할 수 있는 값을 말한다. room 데이터베이스부터 클래스까지 매우 다양하고, 예시는 다음과 같다.

  • 채팅 앱에서 가장 최근에 수신된 메시지
  • 사용자의 프로필 사진
  • recyclerView와 같은 list의 스크롤 위치
💡 상태에 따라 **특정 시점에 UI에 표시되는 항목이 결정**된다.

Compose에서 이벤트(events)란?

상태가 시간이 지남에 따라 변하는 값이라면, 변하는 이유는 무엇일까? Android 앱에서는 이벤트에 대한 응답으로 상태를 업데이트하는 것이다.

그러니까 이벤트는 다음과 같다.

  • 버튼 누르기 등으로 UI와 상호작용하는 사용자
  • 기타 요인(예: 새 값을 전송하는 센서 또는 네트워크 응답)
💡 **상태**는 존재하고, **이벤트**는 발생한다.

UI 업데이트 루프를 아래 그림으로 알아보자.

  • 이벤트: 사용자 또는 프로그램의 다른 부분에 의해 생성.
  • 상태 업데이트: 이벤트 핸들러가 UI에서 사용하는 상태를 변경.
  • 상태 표시: 새로운 상태를 표시하도록 UI를 업데이트.
💡 Compose에서 상태 관리는 **상태와 이벤트가 서로 상호작용**하는 방식을 이해하는 것이 핵심이다.

Compose에서 컴포지션(Composition)과 리컴포지션(Recomposition)이란?

컴포저블을 실행할 때 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) }

💡 Compose는 선언형 UI 프레임워크다. **특정 상태의 조건에 UI가 어떻게** **존재**하는지 보여준다는 말이다.

Compose의 Stateful과 Stateless

  • stateful : 상태를 소유하는 컴포저블.
    @Composable
    fun StatefulCounter(modifier: Modifier = Modifier) {
       var count by rememberSaveable { mutableStateOf(0) }
       StatelessCounter(count, { count++ }, modifier)
    }
  • stateless : 상태를 소유하지 않는 컴포저블. 새 상태를 가지거나 수정할 일이 없다.
    @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에서 상태 호이스팅을 위한 일반적 패턴은 상태 변수를 다음 두 개의 매개변수로 바꾼다.

  1. value: T - 표시할 현재 값입니다.
  2. onValueChange: (T) -> Unit - 값을 변경하도록 요청하는 이벤트다. 여기서 T는 제안된 새 값이다.

이렇게 stateless로 호이스팅한 상태는 다음 장점을 가진다.

  • 단일 소스 저장소: 상태를 복제하는 대신 옮겼기 때문에 소스 저장소가 하나만 있어 버그 방지에 도움이 됩니다.
  • 공유 가능함: 호이스팅한 상태를 여러 컴포저블과 공유할 수 있다.
  • 인터셉트 가능: stateless 컴포저블의 호출자는 상태를 변경하기 전에 이벤트를 무시할지 수정할지 결정할 수 있다.
  • 분리: 분리 구성이 가능하다. 구성 가능한 stateless 함수의 상태는 ViewModel과 같이 어디에든 저장할 수 있다.

예시를 보자.

// 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 함수에 상태를 전달하고 있다.

Compose에서 Observing MutableList 만들기

이전 xml에서와 같이 단순히 자료 구조를 ArrayList<T> 또는 mutableListOf로 바꾼다고 해서 리스트를 변경 가능하게 만들 수 없다. 저 두 유형은 리컴포지션을 한다고 Compose에 알리지 않기 때문이다. 그래서 Compose에서 Observing이 가능한 MutableList 인스턴스를 따로 만들어야 한다.

💡 `mutableStateOf` 함수는 `MutableState` 유형의 객체를 반환한다.

mutableStateListOf 및 toMutableStateList 함수는 SnapshotStateList<T> 유형의 객체를 반환한다

코드로 살펴보자.

  1. 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") }
    • cf) toMutableStateList 대신 mutableStateListOf API를 사용하여 list를 만들 수 있지만, 예기치 않은 리컴포지션이 발생하고 UI 성능이 최적화되지 않을 수 있다. 아래와 같은 방법으로 list를 만들면 모든 리컴포지션에 중복 항복이 추가된다. 절대 이렇게 하지 말자.
      val list = remember { mutableStateListOf<WellnessTask>() }
      
      list.addAll(getWellnessTasks())
  2. 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값으로 사용한다.

  3. 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,
       )
    }

이렇게 되면 다음 도식대로 잘 동작한다.

Compose에서 ViewModel 사용하기

우선 주의점부터 알고가자. ViewModel은 컴포지션의 일부가 아니다. 따라서 메모리 누수가 발생할 수 있기 때문에 컴포저블에서 만든 상태를 보유해서는 안 된다.

  1. 기존 코드(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") }
  2. 화면의 매개변수로 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()은 활동이 완료되거나 프로세스가 종료될 때까지 동일한 인스턴스를 반환한다.

    💡 ViewModel은 **컴포저블의 생명주기와 같이 한다**는 말이다. 따라서 **루트 컴포저블에서 사용**하는 것이 좋고, 다른 컴포저블로 전달하면 안 된다. 필요한 데이터와 필수 로직을 실행하는 함수만 매개변수만 전달하자.

Compose에서 ViewModel 사용 시 상태 이전하기

  1. user의 입력에 따라 선택된 상태를 저장하고, 기본 값을 다음 data class에 설정하기

    data class WellnessTask(val id: Int, val label: String, var checked: Boolean = false)
  2. ViewModel에 선택된 새 상태의 값을 수신하는 메서드 구현하기

    class WellnessViewModel : ViewModel() {
       ...
       fun changeTaskChecked(item: WellnessTask, checked: Boolean) =
           tasks.find { it.id == item.id }?.let { task ->
               task.checked = checked
           }
    }
  3. 선택된 상태에 따라 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)
               }
           )
       }
    }
  4. 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) }
               )
           }
       }
    }
  5. 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")
           }
       }
    }
  6. 앱이 동작하면 다음과 같이 제대로 동작하지 않기 때문에, checkedState의 변경사항을 추적하도록 수정하기. 방법은 다음 두 가지가 있음.

    https://developer.android.com/static/codelabs/jetpack-compose-state/img/8e2e731c58123cd8.gif?hl=ko

    1. 데이터 클래스 WellnessTask를 변경하여 checkedState가 Boolean 대신 MutableState<Boolean>이 되도록 한다.
    2. 변경하려는 item을 복사하고 list에서 item을 삭제한 후 변경된 item을 다시 list에 추가한다. → 고비용
  7. 데이터 클래스 변경하기

    ```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)
    }
    ```

    Android codelab 컴포즈 상태

0개의 댓글