상태 및 Jetpack Compose

앱의 상태는 시간이 지남에 따라 변할 수 있는 값입니다. 이는 매우 광범위한 정의로서 Room 데이터베이스부터 클래스 변수까지 모든 항목이 포함됩니다.

모든 Android 앱에서는 사용자에게 상태가 표시됩니다. 다음은 Android 앱 상태의 몇 가지 예입니다.

  • 네트워크 연결을 설정할 수 없을 때 표시되는 스낵바
  • 블로그 게시물 및 관련 댓글
  • 사용자가 클릭하면 버튼에서 재생되는 물결 애니메이션
  • 사용자가 이미지 위에 그릴 수 있는 스티커

Jetpack Compose를 사용하면 Android 앱에서 상태를 저장하고 사용하는 위치와 방법을 명시적으로 나타낼 수 있습니다. 이 가이드에서는 상태와 컴포저블 간의 관계에 관해 그리고 보다 손쉬운 상태 처리를 위해 Jetpack Compose에서 제공되는 API에 관해 집중적으로 설명합니다.

상태 및 컴포지션

Compose는 선언적이므로 Compose를 업데이트하는 유일한 방법은 새 인수로 동일한 컴포저블을 호출하는 것입니다. 이러한 인수는 UI 상태를 표현합니다. 상태가 업데이트될 때마다 재구성이 실행됩니다. 따라서 TextField와 같은 항목은 명령형 XML 기반 뷰에서처럼 자동으로 업데이트되지 않습니다. 컴포저블이 새 상태에 따라 업데이트되려면 새 상태를 명시적으로 알려야 합니다.

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       Text(
           text = "Hello!",
           modifier = Modifier.padding(bottom = 8.dp),
           style = MaterialTheme.typography.h5
       )
       OutlinedTextField(
           value = "",
           onValueChange = { },
           label = { Text("Name") }
       )
   }
}

이 코드를 실행하면 아무 일도 일어나지 않습니다. TextField가 자체적으로 업데이트되지 않기 때문입니다. value 매개변수가 변경될 때 업데이트됩니다. 이는 Compose에서의 컴포지션 및 리컴포지션 작동 방식 때문입니다.

초기 컴포지션 및 리컴포지션에 관한 자세한 내용은 Compose 이해를 참고하세요.

컴포저블의 상태

구성 가능한 함수는 remember 컴포저블을 사용하여 메모리에 단일 객체를 저장할 수 있습니다. remember에 의해 계산된 값은 초기 컴포지션 중에 컴포지션에 저장되고 저장된 값은 리컴포지션 중에 반환됩니다. remember는 변경 가능한 객체뿐만 아니라 변경할 수 없는 객체를 저장하는 데 사용할 수 있습니다.

mutableStateOf는 관찰 가능한 MutableState<T>를 생성하고, 이는 런타임 시 Compose에 통합되는 관찰 가능 유형입니다.

interface MutableState<T> : State<T> {
    override var value: T
}

value가 변경되면 value를 읽는 구성 가능한 함수의 리컴포지션이 예약됩니다. ExpandingCard의 경우 expanded가 변경될 때마다 ExpandingCard가 재구성됩니다.

컴포저블에서 MutableState 객체를 선언하는 데는 세 가지 방법이 있습니다.

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

이러한 선언은 동일한 것이며 서로 다른 용도의 상태를 사용하기 위한 구문 설탕으로 제공됩니다. 작성 중인 컴포저블에서 가장 읽기 쉬운 코드를 생성하는 선언을 선택해야 합니다.

by 위임 구문에는 다음 가져오기가 필요합니다.

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

기억된 값을 다른 컴포저블의 매개변수로 사용하거나 구문의 로직으로 사용하여 표시할 컴포저블을 변경할 수 있습니다. 예를 들어 이름이 비어 있는 경우 인사말을 표시하지 않으려면 if 문에 상태를 사용합니다.

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       var name by remember { mutableStateOf("") }
       if (name.isNotEmpty()) {
           Text(
               text = "Hello, $name!",
               modifier = Modifier.padding(bottom = 8.dp),
               style = MaterialTheme.typography.h5
           )
       }
       OutlinedTextField(
           value = name,
           onValueChange = { name = it },
           label = { Text("Name") }
       )
   }
}

remember가 재구성 과정 전체에서 상태를 유지하는 데 도움은 되지만 구성 변경 전반에서는 상태가 유지되지 않습니다. 이 경우에는 rememberSaveable을 사용해야 합니다. rememberSaveableBundle에 저장할 수 있는 모든 값을 자동으로 저장합니다. 다른 값의 경우에는 맞춤 Saver 객체를 전달할 수 있습니다.

지원되는 기타 상태 유형

Jetpack Compose에서는 상태를 보존하기 위해 MutableState<T>를 사용할 필요가 없습니다. Jetpack Compose는 관찰 가능한 다른 유형을 지원합니다. Jetpack Compose에서 관찰 가능한 다른 유형을 읽으려면 상태를 State<T>로 변환해야 합니다. 그래야 상태가 변할 때 Jetpack Compose가 자동으로 재구성됩니다.

Compose에는 Android 앱에 사용되는 관찰 가능한 일반 유형에서 State<T>를 만들 수 있는 함수가 내장되어 있습니다.

앱에 관찰 가능한 맞춤 클래스를 사용하는 경우 관찰 가능한 다른 유형을 읽어오기 위한 Jetpack Compose용 확장 함수를 빌드할 수 있습니다. 이를 실행하는 방법의 예는 내장 구현을 참조하세요. 모든 변경사항을 수신하도록 Jetpack Compose에 허용하는 모든 객체를 State<T>로 변환하고 컴포저블을 통해 읽어올 수 있습니다.

스테이트풀(Stateful)과 스테이트리스(Stateless)

remember를 사용하여 객체를 저장하는 컴포저블은 내부 상태를 생성하여 컴포저블을 스테이트풀(Stateful)로 만듭니다. HelloContent는 내부적으로 name 상태를 보존하고 수정하므로 스테이트풀(Stateful) 컴포저블의 한 예가 됩니다. 이는 호출자가 상태를 제어할 필요가 없고 상태를 직접 관리하지 않아도 상태를 사용할 수 있는 경우에 유용합니다. 그러나 내부 상태를 갖는 컴포저블은 재사용 가능성이 적고 테스트하기가 더 어려운 경향이 있습니다.

스테이트리스(Stateless) 컴포저블은 상태를 갖지 않는 컴포저블입니다. 스테이트리스(Stateless)를 달성하는 한 가지 쉬운 방법은 상태 호이스팅을 사용하는 것입니다.

재사용 가능한 컴포저블을 개발할 때는 동일한 컴포저블의 스테이트풀(Stateful) 버전과 스테이트리스(Stateless) 버전을 모두 노출해야 하는 경우가 있습니다. 스테이트풀(Stateful) 버전은 상태를 염두에 두지 않는 호출자에 편리하며, 스테이트리스(Stateless) 버전은 상태를 제어하거나 끌어올려야 하는 호출자에 필요합니다.

상태 호이스팅

Compose에서 상태 끌어올리기는 컴포저블을 스테이트리스(Stateless)로 만들기 위해 상태를 컴포저블의 호출자로 옮기는 패턴입니다. Jetpack Compose에서 상태를 끌어올리기 위한 일반적 패턴은 상태 변수를 다음 두 개의 매개변수로 바꾸는 것입니다.

  • value: T: 표시할 현재 값
  • onValueChange: (T) -> Unit: T가 제안된 새 값인 경우 값을 변경하도록 요청하는 이벤트

하지만 onValueChange로만 제한되지 않습니다. 컴포저블에 더 특정한 이벤트가 어울리는 경우 ExpandingCardonExpandonCollapse를 정의할 때와 같이 람다를 사용하여 그 이벤트를 정의해야 합니다.

이러한 방식으로 끌어올린 상태에는 중요한 속성이 몇 가지 있습니다.

  • 단일 소스 저장소: 상태를 복제하는 대신 옮겼기 때문에 소스 저장소가 하나만 있습니다. 버그 방지에 도움이 됩니다.
  • 캡슐화됨: 스테이트풀(Stateful) 컴포저블만 상태를 수정할 수 있습니다. 철저히 내부적 속성입니다.
  • 공유 가능함: 호이스팅한 상태를 여러 컴포저블과 공유할 수 있습니다. 다른 컴포저블에서 name을 사용하려는 경우 호이스팅을 통해 그렇게 할 수 있습니다.
  • 가로채기 가능함: 스테이트리스(Stateless) 컴포저블의 호출자는 상태를 변경하기 전에 이벤트를 무시할지 수정할지 결정할 수 있습니다.
  • 분리됨: 스테이트리스(Stateless) ExpandingCard의 상태는 어디에나 저장할 수 있습니다. 예를 들어 이제는 nameViewModel로 옮길 수 있습니다.

이 예에서는 HelloContent에서 nameonValueChange를 추출한 다음, 이러한 항목을 트리 상단을 거쳐 HelloContent를 호출하는 HelloScreen 컴포저블로 옮깁니다.

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

HelloContent에서 상태를 끌어올리면 더 쉽게 컴포저블을 추론하고 여러 상황에서 재사용하며 테스트할 수 있습니다. HelloContent는 상태의 저장 방식과 분리됩니다. 분리된다는 것은 HelloScreen을 수정하거나 교체할 경우 HelloContent의 구현 방식을 변경할 필요가 없다는 의미입니다.

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

ViewModel 및 상태

ViewModel은 Compose UI 트리에서 위에 있는 컴포저블 또는 탐색 라이브러리의 대상인 컴포저블에 권장되는 상태 홀더입니다. ViewModel은 구성이 변경되어도 유지되므로 ViewModel을 사용하면 Compose 코드를 호스팅하는 활동이나 프래그먼트 수명 주기를 처리할 필요 없이 UI와 관련된 상태 및 이벤트를 캡슐화할 수 있습니다.

ViewModel은 LiveData 또는 StateFlow와 같이 식별 가능한 홀더에 상태를 노출해야 합니다. 컴포지션 중에 상태 객체를 읽는 경우 컴포지션의 현재 재구성 범위가 이 상태 객체의 업데이트에 따라 자동으로 변경됩니다.

식별 가능한 상태 홀더를 하나 이상 가질 수 있습니다. 각 상태 홀더는 개념적으로 관련되고 함께 변경되는 화면 부분의 상태를 유지해야 합니다. 이렇게 하면 상태가 여러 컴포저블에서 사용되더라도 단일 정보 소스를 유지할 수 있습니다.

Jetpack Compose에서 LiveDataViewModel을 사용하여 단방향 데이터 흐름을 구현할 수 있습니다. HelloScreen 예는 다음과 같이 ViewModel을 사용하여 구현됩니다.

class HelloViewModel : ViewModel() {

    // LiveData holds state which is observed by the UI
    // (state flows down from ViewModel)
    private val _name = MutableLiveData("")
    val name: LiveData<String> = _name

    // onNameChange is an event we're defining that the UI can invoke
    // (events flow up from UI)
    fun onNameChange(newName: String) {
        _name.value = newName
    }
}

@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
    // by default, viewModel() follows the Lifecycle as the Activity or Fragment
    // that calls HelloScreen(). This lifecycle can be modified by callers of HelloScreen.

    // name is the current value of [helloViewModel.name]
    // with an initial value of ""
    val name: String by helloViewModel.name.observeAsState("")
    HelloContent(name = name, onNameChange = { helloViewModel.onNameChange(it) })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

observeAsStateLiveData<T>를 관찰하고, LiveData가 변경될 때마다 업데이트되는 State<T> 객체를 반환합니다. State<T>는 Jetpack Compose가 직접 사용할 수 있는 관찰 가능한 유형입니다. observeAsState컴포지션에 있는 동안에만 LiveData를 관찰합니다.

다음 행은,

val name: String by helloViewModel.name.observeAsState("")

observeAsState에서 반환된 상태 객체를 자동으로 래핑 해제하는 구문 슈가입니다. 또한 할당 연산자(=)를 사용하여 상태 객체를 할당할 수 있습니다. 이 경우 String 대신 State<String>이 됩니다.

val nameState: State<String> = helloViewModel.name.observeAsState("")

Compose에서 상태 복원

활동 또는 프로세스가 다시 생성된 이후 rememberSaveable을 사용하여 UI 상태를 복원합니다. rememberSaveable은 재구성 과정 전체에서 상태를 유지합니다. 또한 rememberSaveable은 활동 및 프로세스 재생성 전반에 걸쳐 상태를 유지합니다.

상태를 저장하는 방법

Bundle에 추가되는 모든 데이터 유형은 자동으로 저장됩니다. Bundle에 추가할 수 없는 항목을 저장하려는 경우 몇 가지 옵션이 있습니다.

Parcelize

가장 간단한 방법은 객체에 @Parcelize 주석을 추가하는 것입니다. 그러면 객체가 parcelable이 되며 번들로 제공될 수 있습니다. 예를 들어 다음 코드는 parcelable City 데이터 유형을 만들어 상태에 저장합니다.

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

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

MapSaver

어떤 이유로 @Parcelize가 적합하지 않을 경우 mapSaver를 사용하여 시스템이 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

맵의 키를 정의해야 하는 상황이 발생하지 않도록 listSaver를 사용하고 색인을 키로 사용할 수 있습니다.

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

자세히 알아보기

상태 및 Jetpack Compose에 관한 자세한 내용은 Jetpack Compose Codelab에서 상태 사용을 참고하세요.