읽기 전

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

Jetpack Compose의 State 코드랩Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서 컴포지션 종료와 재생성 간 상태를 어떻게 보관하는지를 Compose에서의 목록 사용과 연계해서 알아봅니다.

관련 자료

Compose State 복원은 Android | Jetpack Compose Remember State에서 이어집니다.

Compose 목록 사용은 Android | Jetpack Compose State Hoisting에서 이어집니다.

액티비티 재구성에 따른 상태 변화

Compose 상태가 바뀌면 리컴포지션이 발생하면서 변화된 상태를 반영했었다. 그리고 변경된 상태를 리컴포지션에도 유지하기 위해 remember API를 사용하여 저장한다. 그렇다면, 화면 회전이나 언어 변경, 라이트/다크 모드 전환과 같이 Config Change로 인해 실행 중인 Activity가 Android 시스템에 의해 닫혔다가 다시 생성되는 경우엔 어떻게 될까?

앱을 실행한 뒤 Add one 버튼을 눌러 count 변수를 0에서 1로 바꾼 뒤 기기를 회전시켜 화면을 세로모드에서 가로모드로 바꿔보자.

Android_Compose_State_Restore_001

Activity는 Config Change(구성 변경, 이 경우는 방향) 후 재생성되므로 저장된 상태를 삭제된다. 따라서, count 변수는 다시 초기값인 0으로 되돌아가며 카운터가 사라진다.

remember를 사용하면 리컴포지션 간 상태를 유지할 수 있지만 Config Change로 인한 구성 변경 간에는 유지되지 않는다. 이를 위한 새로운 rememberSaveable이라는 API를 사용해야 한다.

상태 복원에 쓰이는 rememberSaveable

rememberSaveableBundle에 저장할 수 있는 모든 값을 자동으로 저장한다. 다른 값의 경우 커스텀 Saver 객체를 전달할 수 있다.

Bundle에 저장할 수 없는 항목 저장

Parcelize 주석 추가

가장 간단한 방법으로 @Parcelize 주석을 추가하는 것이다. 그러면 객체가 parceable이 되며 번들로 제공된다. 다음의 코드는 City 데이터 클래스를 parceable로 만들어 상태에 저장한다.

@Parcelize
data class City(val name: String, val country: String) : Parceable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

MapSaver API 사용

key-value 집합으로 객체를 변환하는 규칙을 정의하여 Bundle에 저장할 수 있는 값 집합으로 만들 수 있다.

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

ListSaver API 사용

list 형태로도 stateSaver를 만들 수 있다.

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

rememberSaveable 적용

기존 WaterCounter에서 rememberrememberSaveable로 바꿔보자.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       //var count by remember { mutableStateOf(0) }
       var count by rememberSaveable { mutableStateOf(0) }

       if (count > 0) {
           Text("You've had $count glasses.")
       }
       Button(
           onClick = { count++ },
           enabled = count < 10,
           Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

Android_Compose_State_Restore_002

결국 앱의 UX 상황에 맞춰서 remember를 사용할 것인지 rememberSaveable을 사용할 것인지 결정해야 한다.

Compose에서의 목록 사용

Android | Jetpack Compose State Hoisting에 이어 목록 사용을 위해 앱에 두 가지 작업을 추가한다.

  • 작업을 완료로 표시하기 위해 목록 항목 선택 기능
  • 완료할 필요 없는 작업을 목록에서 삭제 기능

목록 item 정의

목록에 보여줄 아이템을 정의해야 한다. 이전에 Android | Jetpack Compose Remember State에서 정의했던 WellnessTaskItem을 재사용하여 체크박스와 닫기 버튼을 추가해주자.

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

Checkbox 컴포저블과 IconButton 컴포저블을 추가하여 체크와 닫기 기능을 지원할 수 있도록 UI를 구성하였고 컴포저블 파라미터로 checked, onCheckedChange, onClose를 입력하여 Stateless 컴포저블이 될 수 있도록 한다.

목록 item의 상태 정의

목록 item을 stateless하게 정의했으므로 외부에서 상태를 주입해야 한다. checkedStated를 정의하는 동일한 이름의 Stateful 컴포저블을 우선 정의해둔다.

WellnessTaskItem.kt

@Composable
fun WellnessTaskItem(taskName: String, modifier: Modifier = Modifier) {
   var checkedState by remember { mutableStateOf(false) }

   WellnessTaskItem(
       taskName = taskName,
       checked = checkedState,
       onCheckedChange = { newValue -> checkedState = newValue },
       onClose = {}, // we will implement this later!
       modifier = modifier,
   )
}

Task의 내용을 담을 데이터 클래스 정의

WellnessTask.kt파일을 만들어 ID와 라벨이 포함된 Task 모델을 데이터 클래스로 정의한다.

WellnessTask.kt

data class WellnessTask(val id: Int, val label: String)

Task 목록을 반환할 함수 추가 정의

WellnessTasksList.kt라는 파일을 만들어 더미 데이터를 반환하는 함수를 정의한다.

WellnessTasksList.kt

private fun getWellnessTasks() = List(30) { i ->
    WellnessTask(i, "Task # $i")
}

가짜 데이터를 반환하고 있지만 실제 서비스에서는 데이터 영역에서 조회하거나 네트워크를 통해 데이터를 받아서 전달한다.

WellnessTasksList.kt에서 목록 정의

WellnessTasksList.kt에서 목록을 만드는 컴포저블 함수를 정의한다. LazyColumn으로 목록을 구현하고 삽입할 item으로 앞서 정의했던 WellnessTaskItem을 활용하면 되겠다.

@Composable
fun WellnessTasksList(
    modifier: Modifier = Modifier,
    list: List<WellnessTask> = remember { getWellnessTasks() }
) {
    LazyColumn(
        modifier = modifier
    ) {
        items(list) { task ->
            WellnessTaskItem(taskName = task.label)
        }
    }
}

목록을 WellnessScreen에 추가

Task들을 보여줄 목록을 정의했으므로 화면에 출력하기 위해 WellnessTaskList 컴포저블을 WellnessScreen에 추가한다.

WellnessScreen.kt

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   Column(modifier = modifier) {
       StatefulCounter()
       WellnessTasksList()
   }
}

이제 앱을 실행해보면 체크박스 선택이 가능해진다.

Android_Compose_State_Restore_003

다만 삭제하는 로직은 빈 람다함수를 파라미터로 넣었기 때문에 아직 동작하지 않는다. 그리고 WellnessTaskItem의 상태로 remember API를 사용했었는데 이와 관련해서 어떤 부작용이 있는지 확인해보자.

LazyList에서의 상태 복원

checkedState는 각 WellnessTaskItem 컴포저블에 독립적으로 속한다. checkedState가 변경되면 WellnessTaskItem의 해당 인스턴스만 재구성되며 LazyColumn의 모든 WellnessTaskItem 인스턴스에 리컴포지션이 트리거되진 않는다. 현대 WellnessTaskItem 코드를 보면 remember API를 사용하여 checkedState 변수를 정의하였다. 앱을 켜고 다음의 절차를 따라해보면 의도와는 다르게 동작함을 확인할 수 있다.

  1. 해당 목록 상단의 item 체크박스를 선택
  2. 선택한 item들이 화면 밖으로 나가도록 스크롤
  3. 앞서 선택한 item이 화면 안으로 들어오도록 스크롤
  4. 선택했던 item들의 체크박스가 해제되어 있음

remember의 문제점으로 컴포지션에서 호출되지 않으면 기억된 상태를 삭제한다는 점이 있었다. LazyColumn 컴포저블의 경우 스크롤하면서 항목을 지나치면 해당 item에 해당 컴포지션을 완전히 종료하기 때문에 UI 컴포지션에서 삭제되어 더 이상 항목이 표시되지 않는다.

Android_Compose_State_Restore_004

이 문제를 해결하기 위해선 `rememberSaveable을 사용하면 된다. 저장된 값은 저장된 인스턴스 상태 메커니즘을 통해 Activity나 프로세스 재생성 시에도 유지되기 때문에 컴포지션이 종료될 때도 유지된다.

Statefull하게 정의한 WellnessTaskItem의 코드에서 rememberrememberSaveable로 바꿔보자.

WellnessTaskItem.kt

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

코드를 수정한 뒤 앱을 실행해보면 체크 상태가 유지되고 있음을 확인할 수 있다.

Android_Compose_State_Restore_005

Compose의 일반적 패턴

LazyColumn의 내부 구현을 보면 state를 다음과 같이 정의해두고 있다.

LazyDsl.kt

@Composable
fun LazyColumn(
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
    ...
) {
    ...
}

내부 상태 정의를 위해 LazyListState 타입을 반환하는 rememberLazyListState()함수를 호출함을 확인할 수 있다. rememberLazyListState 함수의 내부 구현을 더 들어가보자.

LazyListSTate.kt

@Composable
fun rememberLazyListState(
    initialFirstVisibleItemIndex: Int = 0,
    initialFirstVisibleItemScrollOffset: Int = 0
): LazyListState {
    return rememberSaveable(saver = LazyListState.Saver) {
        LazyListState(
            initialFirstVisibleItemIndex,
            initialFirstVisibleItemScrollOffset
        )
    }
}

rememberLazyListState 컴포저블 함수는 rememberSaveable을 사용하여 목록의 초기 상태를 구현한다. 그렇기 때문에 Activity가 재생성 되더라도 스크롤 상태는 어떠한 조치를 취하지 않아도 유지된다.

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #8. Compose에서 상태 복원
  2. Google Codelab - Jetpack Compose의 상태 #10. 목록 사용
  3. Android Developers - 상태 및 Jetpack Compose

+ Recent posts