Developing Myself Everyday
article thumbnail
Published 2023. 10. 5. 13:45
Compose에서의 State(상태) Android/Compose

사진: UnsplashBraden Collum

 

 

이전에 아래의 게시글에서 상태에 대해 알아본적이 있습니다. 

 

State와 StatedFlow, SharedFlow에 대해 알아보기

State란? State는 객체 지향 관점에서 자주 사용되는 단어로 객체가 특정 시점에서 어떤 데이터 값을 가지고 있는지 나타내는 것으로, 객체의 특성이나 속성을 나타냅니다. 안드로이드의 UI 레이어

everyday-develop-myself.tistory.com

 

 

앱의 상태는 시간이 지남에 따라 변할 수 있는 값을 의미합니다. 이번 게시글에서는 Compose에서 앱의 상태를 어떻게 저장하고 사용하는지 알아보고자 합니다.


 

Compose와 상태


Compose는 선언형 UI 프레임워크로 Composition을 통해 UI를 기술합니다.

 

 


컴포지션: 컴포저블을 실행할 때 Jetpack Compose에서 빌드한 UI에 관한 설명입니다.
초기 컴포지션: 처음 컴포저블을 실행하여 컴포지션을 만듭니다.
리컴포지션: 데이터가 변경될 때 컴포지션을 업데이트하기 위해 컴포저블을 다시 실행하는 것을 말합니다.

 

 

처음에는 Composition 실행해 UI를 기술합니다.

 

만약 특정한 작업을 해 앱의 상태가 변경되었다면 Jetpack Compose는 Recomposition을 예약합니다. Recomposition은 상태의 변경 사항에 따라 변경될 수 있는 Composition을 다시 실행하고 변경사항을 반영하도록 Composition을 업데이트합니다.

 

앱의 UI를 변경하는 방법은 결국 상태가 변경되어  Recomposition을 하는 방법 밖에 없습니다.

 

 

아래의 @Composable `WaterCounter` 함수의 상태는 `count` 변수입니다. 다만 상태만 있다면 상태를 변경할 수 없습니다.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   val count = 0
   Text(
       text = "You've had $count glasses.",
       modifier = modifier.padding(16.dp)
   )
}

 

 

상태와 이벤트

그럼 상태를 변경하려면 어떻게 해야 할까요?? 안드로이드에서는 이벤트가 발생하면 그에 대한 응답으로 상태가 변경됩니다.

 

아래의 그림은 UI를 업데이트하는 루프를 나타내었습니다.

 

 

 

아래의 코드를 보면 이전과 다르게 `onClick` 이벤트가 생겼습니다. 이제 이벤트가 생겼으니 변수의 상태가 바뀔 것입니다.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       var count = 0
       Text("You've had $count glasses.")
       Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

 

 

다만 이는 틀렸습니다.

 

 

 

`count` 변수에 다른 값을 설정해도 Compose에서는 아직은 이 값을 상태 변경으로 감지하지 않습니다. 그 이유는 상태가 변경될 때 Compose에 화면을 다시 그려야 한다고 알리지 않았기 때문입니다.

 

Compose에서는 모든 객체를 추적하지는 않습니다. 그렇기에 특별한 상태 추적 시스템을 사용합니다. 이를 통해 전체 UI가 아닌 변경해야 하는 @Composable 함수만 재구성할 수 있습니다.

 

Compose에서 상태를 추적하도록 하는 방법은 `State` 및 `MutableState` 유형을 사용하는 것입니다.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
      // Changes to count are now tracked by Compose
       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")
       }
   }
}

 

 

Compose는 상태의 value 속성을 읽는 각 컴포저블을 추적하고 그 value가 변경되면 리컴포지션을 트리거합니다. 

 

그런데 상태는 초기값을 가지고 있기에 리컴포지션이 되어도 다시 해당 초기값으로 초기화가 되어 값이 유지되지가 않습니다.

 

이를 위해 @Composable 인라인 함수인 `remember`를 사용할 수 있습니다.

 

 

remember

`remember`로 계산된 값은 초기 컴포지션중에 컴포지션에 저장되고 저장된 값은 리컴포지션 간에 유지됩니다.

 

아래의 코드는 이를 가능하게 하는 `remember` 함수입니다.

@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)
    
@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let {
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

 

 

`remember` 함수는 아래와 같이 사용하거나 속성 위임(by)를 사용해 정의할 수 있습니다.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
    	var count by remember { mutableStateOf(0) }
        // 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")
        }
    }
}

 

 

상태 초기화

액티비티의 구성 변경(화면 회전)이 발생하면 액티비티는 재성성됩니다. 이 때 상태 또한 초기화가 되고 저장된 상태는 삭제됩니다.

 

`remember` 함수를 사용해 리컴포지션간에 상태를 유지했지만, 이러한 상황에서는 사용할 수 없습니다. 그렇기에 이러한 상황에서는 `rememberSaveable` 함수를 사용해야 합니다.

 

`rememberSaveable` 함수는 `Bundle`에 저장할 수 있는 모든 값을 자동으로 저장합니다. 이러한 동작은 일반 액티비티를 사용할 때의 `onSaveInstanceState()` API를 사용할 때와 유사합니다.

 

사용법은 `rememberSaveable` 함수로 바꿔주기만 하면 됩니다.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
        ...
        var count by rememberSaveable { mutableStateOf(0) }
        ...
}

 

 

 

 

상태 호이스팅


아까전에 @Composable 함수내에서 `State` 및 `MutableState` 유형을 사용하면, 해당 @Composable 함수는 상태를 가지게 된다고 했습니다. 이런 @Composable 함수의 상태를 스테이트풀(Stateful)이라고 합니다.

 

상태를 가지지 않는 @Composable 함수의 상태는 스테이트리스(Stateless)이라고 합니다.

 

 

 

흠.. 근데 잠깐만요!

 

Compose의 장점은 UI를 작은 조각으로 나눠서 시켜서 재사용할 수 있다는 점이 있습니다. 그런데 만약 각각의 함수가 상태를 가지고 있다면 이를 재사용하기는 매우 어려워 질 것 같습니다.

 

이에 대한 해결책으로 Jetpack Compose는 상태 호이스팅을 사용하라 말합니다. 상태 호이스팅은 컴포저블을 스테이트리스(Stateless)로 만들기 위해 상태를 컴포저블의 매개변수로 옮기는 패턴입니다.

 

이를 위해서는 상태 변수를 다음 두 개의 매개변수로 바꿔야 합니다.

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

 

사용법

상태 호이스팅을 사용하는 방법을 이제 설명하겠습니다. 상태 호이스팅을 위해서는 (1) 상태를 소유하는 역할을 하는 함수와 이를 (2) 끌어올리는 함수가 필요합니다.

 

 

먼저 상태를 소유하는 역할을 하는 함수를 만듭니다. 

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

 

 

상태가 내려가고 이벤트가 올라가는 패턴을 단방향 데이터 흐름이라고 합니다. 이 경우 상태는 HelloScreen에서 HelloContent로 내려가고 이벤트는 HelloContent에서 HelloScreen으로 올라갑니다. 단방향 데이터 흐름을 따르면 UI에 상태를 표시하는 컴포저블과 상태를 저장하고 변경하는 앱 부분을 서로 분리할 수 있습니다.

 


핵심 사항: 상태를 끌어올릴 때 상태의 이동 위치를 쉽게 파악할 수 있는 세 가지 규칙이 있습니다.

 1. 상태는 적어도 그 상태를 사용하는 모든 컴포저블의 가장 낮은 공통 상위 요소로 끌어올려야 합니다(읽기).
 2. 상태는 최소한 변경될 수 있는 가장 높은 수준으로 끌어올려야 합니다(쓰기).
 3. 동일한 이벤트에 대한 응답으로 두 상태가 변경되는 경우 두 상태를 함께 끌어올려야 합니다.

이러한 규칙에서 요구하는 것보다 상태를 더 높은 수준으로 끌어올릴 수 있습니다. 하지만 상태를 끌어내리면 단방향 데이터 흐름을 따르기가 어렵거나 불가능할 수 있습니다.

단방향 데이터 흐름(UDF)은 상태는 아래로 이동하고 이벤트는 위로 이동하는 디자인 패턴입니다. 단방향 데이터 흐름을 따라 UI에 상태를 표시하는 컴포저블과 상태를 저장하고 변경하는 앱 부분을 서로 분리할 수 있습니다.

단방향 데이터 흐름을 사용하는 앱의 UI 업데이트 루프는 다음과 같습니다.

 - 이벤트: UI의 일부가 이벤트를 생성하여 위쪽으로 전달하거나(예: 처리하기 위해 ViewModel에 전달되는 버튼 클릭) 앱의 다른 레이어에서 이벤트가 전달됩니다(예: 사용자 세션이 종료되었음을 표시).
 - 상태 업데이트: 이벤트 핸들러가 상태를 변경할 수도 있습니다.
 - 상태 표시: 상태 홀더가 상태를 아래로 전달하고 UI가 상태를 표시합니다.

 

 

 

이제 아래와 같이 스테이트리스(Stateless) 컴포저블을 재사용할 수 있습니다.

@Composable
fun StatefulCounter() {
    var waterCount by remember { mutableStateOf(0) }

    var juiceCount by remember { mutableStateOf(0) }

    StatelessCounter(waterCount, { waterCount++ })
    StatelessCounter(juiceCount, { juiceCount++ })
}

 

 

 

 

 

리스트의 상태


아래와 그림과 같은 Item을 가진 리스트를 만든다고 생각해 보겠습니다.

 

 

이를 구현하기 위한 코드는 아래와 같습니다.

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

 

 

이전에 배웠던 대로 상태 호이스팅을 사용해 이 함수를 스테이트리스(Stateless) 하게 만들기 위해 동일한 이름을 가진 스테이트풀(Stateful) 함수를 만듭니다.

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

 

 

아이템의 데이터 클래스를 만들고, 가짜 데이터를 생성하는 메서드를 추가합니다.

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

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

 

 

마지막으로 목록을 만드는 함수를 추가합니다.

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

 

 

이전에서 살펴본 것처럼 스크롤을 해 아이템이 화면 밖으로 나간다면, 컴포저블이 종료돼 기억된 상태가 삭제된다는 문제가 있습니다. 그렇기에 `rememberSaveable` 함수를 다시 사용합니다.

var checkedState by rememberSaveable { mutableStateOf(false) }

 



LazyColumn 또는 LazyRow와 같은 지연 구성요소는 스크롤 위치, 항목 레이아웃 변경사항, 목록의 상태와 관련된 기타 이벤트에 반응하고 이를 수신 대기해야 합니다.

@Composablefun
LazyColumn(
...   
   state: LazyListState = rememberLazyListState(),
...


그렇기에 아래와 같이 LazyListState를 끌어올려 이것을 지원합니다.

 

 

리스트의 항목 변경

현재 리스트는 불변 객체입니다. 그렇기에 이 항목을 변경하고 Compose에 알리기 위해서는 확장 함수 toMutableStateList()를 사용하면 됩니다.

 

 

이전에 가짜 데이터를 만들었던 함수의 List를 toMutableStateList()를 사용해 변경 가능하고 관찰 가능한 MutableList로 만듭니다.

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

       val list = remember { getWellnessTasks().toMutableStateList() }
       WellnessTasksList(list = list, onCloseTask = { task -> list.remove(task) })
   }
}

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

경고: 대신 mutableStateListOf API를 사용하여 목록을 만들 수 있습니다. 그러나 이를 사용하는 방식으로 인해 예기치 않은 리컴포지션이 발생하고 UI 성능이 최적화되지 않을 수 있습니다.

목록을 정의하고 작업을 다른 작업에 추가하면 모든 리컴포지션에 중복된 항목이 추가됩니다.
// Don't do this!
val list = remember { mutableStateListOf<WellnessTask>() }
list.addAll(getWellnessTasks())

대신 단일 작업으로 초깃값을 사용하여 목록을 만든 후 다음과 같이 remember 함수에 전달합니다.

// Do this instead. Don't need to copy
val list = remember {
mutableStateListOf<WellnessTask>().apply { addAll(getWellnessTasks()) }
}

 

 

한가지 더 해야할 일이 있습니다. 각 아이템의 상태는 기본적으로는 아이템의 위치를 기준으로 키가 지정됩니다. 그렇기에 리스트를 변경한다면 문제가 발생하게 됩니다.

 

아래와 같이 id를 항목의 키로 지정하면 이런 문제를 해결할 수 있습니다.

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

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

 

 

이런 과정을 거치면 아이템에서 X를 눌렀을 때 이벤트가 상태를 가지고 있는 리스트까지 이동하고, 거기서 아이템을 삭제할 수 있게 됩니다.

 

 

주의할 점

아래와 같이 리스트를 만들 때 `rememberSaveable` 함수를 사용한다면 어떻게 될까요??

val list = remember { getWellnessTasks().toMutableStateList() }

 

 

런타임 예외가 발생합니다. 그 이유는 `rememberSaveable` 함수에서는 직렬화 또는 역직렬화가 필요한 복잡한 데이터 구조나 대량의 데이터를 저장하는 데 별도의 맞춤 설정이 필요하기 때문입니다.

 

 

 

 

마무리하며


이렇게해서 Compose에서의 상태는 어떻게 사용되고 있는지 알아보았습니다. Compose를 공부하다보면 정말 엄청난 매력을 가지고 있다는 것이 느껴집니다. Compose를 사용하면 상태를 무조건 같이 다뤄야 하기 때문에 이번 게시글을 통해 다름에 Compose를 사용할 때 도움이 되었으면 좋겠습니다.

 

 

 

Reference

 

상태 및 Jetpack Compose  |  Android Developers

상태 및 Jetpack Compose 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 앱의 상태는 시간이 지남에 따라 변할 수 있는 값을 의미합니다. 이는 매우 광범위한 정

developer.android.com

 

Compose UI 설계  |  Jetpack Compose  |  Android Developers

Compose UI 설계 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Compose의 UI는 변경할 수 없습니다. UI를 설계한 후 업데이트할 수 없습니다. UI 상태는 제어할 수

developer.android.com

 

Jetpack Compose의 상태  |  Android Developers

이 Codelab에서는 상태를 관리하여 다양한 기능의 대화형 Compose 애플리케이션을 빌드하는 방법을 알아봅니다.

developer.android.com

 

profile

Developing Myself Everyday

@배준형

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!