읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose의 State 코드랩Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서 ViewModel이 UI 상태를 어떻게 다루는 지 알아봅니다.

관련 자료

Android | Jetpack Compose Observable MutableList에서 이어지는 포스트입니다.

Compose에서의 ViewModel

ViewModel은 config change 후에도 유지되므로 컴포지션보다 생명주기가 더 길고 Compose 콘텐츠 호스트(Activity, Fragment, Compose Navigation의 경우 Navigation Graph의 Destination)의 수명주기를 따를 수 있다.

주의할 점 - Compose 관련 함수

ViewModel은 컴포지션의 일부가 아니기에 메모리 누수가 발생할 수 있다. 따라서, 컴포저블에서 만든 상태를(ex. remember~State 등) 보유해서는 안된다. 특히, remember 키워드로 생성된 Compose 상태를 ViewModel 인스턴스가 보관할 경우 config change가 발생하여 컴포지션이 종료된 경우 문제가 될 수 있다. Activity가 재생성되면서 기존 remember한 상태는 컴포지션에서 삭제되었음에도 ViewModel 인스턴스는 기존 상태에 대한 레퍼런스를 들고 있어 의도와는 다르게 동작할 수 있기 때문이다.

목록 이전 및 함수 삭제

UI 코드에서 생성했던 상태와 목록을 ViewModel로 이전함과 동시에 비즈니스 로직도 ViewModel로 추출해보자.

WellnessViewModel.kt 생성

목록을 반환하던 getWellnessTasks()함수를 WellnessViewModel로 이동시킨다. 이전에 작성하였듯 toMutableStateList를 사용하여 내부 _tasks 변수를 정의하고 외부에 노출시킬 tasks를 정의한다. 이렇게 작성하면 ViewModel 외부에서 수정할 일이 없기에 책임을 집중시킬 수 있다.

아이템 제거하던 코드도 ViewModel로 이전하여 내장 remove함수를 호출하는 함수를 작성한다.

WellnessViewModel.kt

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() 함수를 호출하여 컴포저블에서 해당 ViewModel에 액세스

viewModel()함수는 추가 라이브러리가 필요하다.

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1"

wellnessViewModel을 인스턴스화하고 컴포저블에 적용

WellnessScreen.kt

@Composable
fun WellnessScreen(
  modifier: Modifier = Modifier,
  wellnessViewModel: WellnessViewModel = viewModel()
) {
    Column(modifier = modifier) {
        StatefulCounter()
        WellnessTasksList(
            list = wellnessViewModel.tasks,
            onCloseTask = { 
                task -> wellnessViewModel.remove(task) 
            }
        )
    }
}

viewModel()은 기존 생성되어 있던 ViewModel을 반환하거나 지정된 scope에서 새로운 ViewModel을 생성한다. ViewModel 인스턴스는 scope가 활성화되어 있는 동안 유지된다. 만약 컴포저블이 Activity에서 사용되는 경우 viewmodel()은 Activity가 finish되거나 프로세스가 종료될 때까지 동일한 인스턴스를 반환한다.

이로써 UI 표시될 데이터와 비즈니스 로직이 포함된 ViewModel과 화면이 서로 연결되었다.

참고할 점 - Composable에서의 ViewModel 사용

ViewModel은 Navigation Graph의 Activity나 Fragment 혹은 이런 호스트에서 호출되는 Root 컴포저블 근처에서 사용하는 것이 좋다. 그리고 앞서 주의점으로 설명하였듯이 ViewModel을 다른 컴포저블로 전달하지 않고 필요한 데이터나 로직을 실행하는 함수만 매개변수로 전달함을 잊어셔는 안된다.

목록 아이템 선택 상태 이전

마지막으로 선택된 상태와 관련된 로직을 ViewModel로 이전해보자. 해당 작업이 완료되면 모든 데이터와 상태가 ViewModel에서 관리되므로 코드가 더 간단해지며 테스트도 용이해진다.

WellnessTask 모델 클래스 수정

앞서 데이터 클래스로 정의했던 WellnessTask 클래스에 선택된 상태를 저장할 수 있도록 변수를 선언하고 기본값을 false로 지정한다.

WellnessTask.kt

data class WellnessTask(val id: Int, val label: String, var checked: Boolean = false)

ViewModel에 목록 아이템 선택 상태 수정 함수 정의

목록 아이템 선택 여부를 저장하도록 수정하였으니 ViewModel에서 선택된 상태를 새로운 값으로 수정하는 함수를 구현한다.

WellnessViewModel.kt

class WellnessViewModel : ViewModel() {
   ...
   fun changeTaskChecked(item: WellnessTask, checked: Boolean) =
       tasks.find { it.id == item.id }?.let { task ->
           task.checked = checked
       }
}

새롭게 정의된 함수는 파라미터로 수정할 목록 아이템과 선택 상태를 수정할 값을 받는다. 이후 갖고 있는 아이템 목록에서 일치하는 아이템을 찾고 값을 수정한다.

Composable에서 아이템 선택 상태 수정 함수 호출

목록 아이템의 선택 상태를 정의하고 선택 상태를 변경하는 함수를 정의했으니 이제 Compose의 목록 아이템에서 함수를 호출하는 코드를 작성해야 한다.

WellnessScreen.kt

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

우선 목록 컴포저블의 onCheckedTask 변수로 ViewModel의 changeTaskChecked 함수를 호출하는 람다함수를 정의한다.

파라미터로 전달한 람다 함수를 목록 아이템에 추가

목록 컴포저블에 체크박스 선택 시 동작을 전달했으므로 각 아이템 컴포저블에 필요한 매개변수를 전달하면 된다.

WellnessTasksList.kt

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

목록 컴포저블로부터 전달받은 onCheckedTask 람다함수에 실제 반영될 파라미터인 taskchecked 변수를 입력하여 앞서 정의했던 ViewModel의 함수에 아이템의 정보가 입력되게끔 한다.

불필요한 WellnessTaskItem 코드 정리

체크박스 선택 상태를 목록 컴포저블까지 호이스팅했기에 이제 Stateful 컴포저블은 필요치 않다. 따라서, 삭제를 진행한 뒤 Stateless 컴포저블만 남겨둔다.

WellnessTaskItem.kt

@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")
       }
   }
}

앱을 실행해보면 아직 체크박스 상태가 제대로 동작하지 않음을 확인할 수 있다.

Android_Compmose_ViewModel_State_001

의도한 대로 동작하지 않는 이유로는 Compose에서 MutableList를 위해 추적하는 상태가 아이템 추가 및 삭제와 관련된 변경사항이기 때문이다. 따라서, 삭제는 제대로 작동하지만 추적하도록 지시하지 않았던 아이템의 값 변경사항에 대해서는 인지하지 못하기 때문이다.

문제를 해결하기 위해 두 가지 방법을 사용할 수 있다.

  • 데이터 클래스 WellnessTask를 변경하여 checkedState의 타입을 Boolean에서 MutableState<Boolean>이 되도록 하면 Compose에서 해당 변수 상태의 변경사항을 추적한다.
  • 변경하려는 항목을 복사하고 목록에서 항목을 삭제한 뒤 변경된 항목을 다시 목록에 추가한다. 그러면 Compose에서는 아이템의 추가 및 삭제와 관련하여 추적하고 있기 때문에 목록 변경사항을 인지한다.

두 방법 모두 장단이 존재하며 상황에 따라 취사선택하라고 문서에서 제시한다. 구현된 목록 구조에 따라 요소를 삭제하고 읽는 데 비용이 많이 들 수 있기에 코드랩에서는 잠재적으로 비용이 많이 드는 목록 작업 대신 Compose 직관적인 방법을 택하였다. 따라서 checkedState를 Observable하게 변경한다.

체크박스 선택 여부를 Observable하게 변경

변수 객체타입을 바꾸면 다음과 같이 변경할 수 있다.

WellnessTask.kt

data class WellnessTask(
  val id: Int,
  val label: String,
  val checked: MutableState<Boolean> = mutableStateOf(false)
)

Kotlin의 delegated(위임된) 속성을 이용하면 checked 변수를 좀 더 간단하게 사용할 수 있다.

WellnessTask.kt

class WellnessTask(
    val id: Int,
    val label: String,
    initialChecked: Boolean = false
) {
    var checked by mutableStateOf(initialChecked)
}

이제 앱을 실행해보면 체크박스 작동이 정상적으로 이루어짐을 확인할 수 있다.

Android_Compmose_ViewModel_State_002

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #12. ViewModel의 상태

+ Recent posts