시배's Android

Compose Docs | State and Jetpack Compose 본문

Android/Compose Docs

Compose Docs | State and Jetpack Compose

si8ae 2023. 7. 31. 22:03
 

상태 및 Jetpack Compose  |  Android Developers

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

developer.android.com

State and composition

Compose 선언적이기 때문에 업데이트하는 유일한 방법은 동일한 컴포저블을 인수로 호출하는 것입니다. 이러한 인수들은 UI 상태의 표현입니다. 상태가 업데이트될 때마다 재구성이 발생합니다. 결과, TextField 같은 요소들은 명령형 XML 기반 뷰와 같이 자동으로 업데이트되지 않습니다. 컴포저블은 명시적으로 상태를 알려주어야만 해당 상태에 따라 업데이트됩니다.

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

코드를 실행하고 텍스트를 입력해보면 아무 일도 일어나지 않는 것을 있습니다. 이는 TextField 스스로 업데이트되지 않고, value 매개변수가 변경될 업데이트되기 때문입니다. 이는 Compose에서 구성과 재구성이 작동하는 방식 때문입니다.

State in composables

Composable 함수는 remember API를 사용하여 객체를 메모리에 저장할 수 있습니다. remember로 계산된 값은 초기 구성 중에 Composition에 저장되며, 재구성 중에는 저장된 값이 반환됩니다. remember는 가변 및 불변 객체 모두를 저장하는 데 사용할 수 있습니다.

 

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

 

값이 변경되면 value를 읽는 모든 composable 함수의 재구성이 예약됩니다. 예를 들어, ExpandingCard에서 expanded가 변경되면 ExpandingCard를 재구성하도록 만듭니다.

 

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

val mutableState = remember { mutableStateOf(default) }

var value by remember { mutableStateOf(default) }

val (value, setValue) = remember { mutableStateOf(default) }

이러한 선언들은 동일하며, 상태를 사용하는 다른 방식에 따라 구문 편의를 위해 제공됩니다. 작성 중인 컴포저블에서 가장 읽기 쉬운 코드를 생성할 수 있도록 선택하세요.

 

기억된 값은 다른 컴포저블의 매개변수로 사용하거나, 표시할 컴포저블을 변경하는 데에도 사용할 있습니다. 예를 들어, 이름이 비어있을 경우 인사말을 표시하지 않으려면 상태를 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.bodyMedium
            )
        }
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

remember 함수는 재구성 사이에서 상태를 유지하는 도움이 되지만, 구성 변경 사이에서는 상태를 유지하지 않습니다. 이를 위해 rememberSaveable 사용해야 합니다. rememberSaveable 자동으로 Bundle 저장할 있는 모든 값들을 자동으로 저장합니다. 다른 값들에 대해서는 사용자 정의 저장기(saver) 객체를 전달할 있습니다.

Stateful vs stateless

remember를 사용하여 객체를 저장하는 컴포저블은 내부 상태를 생성하여 상태를 유지합니다. HelloContent는 상태를 내부적으로 보유하고 수정하기 때문에 상태가 있는 컴포저블의 예시입니다. 이는 호출자가 상태를 제어하지 않아도 상태를 사용하고 관리하지 않아도 되는 상황에서 유용합니다. 그러나 내부 상태를 가진 컴포저블은 재사용성이 떨어지고 테스트하기 어려울 수 있습니다.

상태가 없는 컴포저블은 어떠한 상태도 보유하지 않는 컴포저블을 말합니다. 상태가 없는 컴포저블을 구현하기 위해 state hoisting을 사용하는 것이 간단한 방법입니다.

재사용 가능한 컴포저블을 개발할 동일한 컴포저블의 상태가 있는 버전과 상태가 없는 버전을 모두 노출시키는 것이 일반적입니다. 상태가 있는 버전은 상태를 신경 쓰지 않는 호출자에게 편리하며, 상태가 없는 버전은 상태를 제어하거나 hoist(옮기다)해야하는 호출자에게 필요합니다.

State hoisting

Compose에서의 State hoisting은 컴포저블의 상태를 없애기 위해 상태를 해당 컴포저블을 호출하는 곳으로 이동하는 패턴입니다. Jetpack Compose에서 State hoisting을 위한 일반적인 패턴은 상태 변수를 두 개의 매개변수로 대체하는 것입니다:

 

1. value: T - 현재 표시할 값을 나타냅니다.

2. onValueChange: (T) -> Unit - 값이 변경되길 요청하는 이벤트를 나타내며, T는 제안된 새 값입니다.

 

그러나 onValueChange로 제한되지 않습니다. 컴포저블에 더 구체적인 이벤트가 적합한 경우에는 ExpandingCard에서 onExpand와 onCollapse와 같이 람다를 사용하여 정의할 수 있습니다.

 

이렇게 hoisting된 상태는 다음과 같은 중요한 특성을 가지게 됩니다:

 

1. Single source of truth: 상태를 복제하는 대신 상태를 이동함으로써 단일 진실의 원천이 있음을 보장합니다. 이로써 버그를 피할 수 있습니다.

2. 캡슐화: 상태가 있는 컴포저블만이 상태를 수정할 수 있습니다. 이는 완전히 내부적인 것입니다.

3. 공유 가능: hoisting된 상태는 여러 컴포저블과 공유할 수 있습니다. 예를 들어, 다른 컴포저블에서 name을 읽고 싶다면, hoisting을 통해 이를 수행할 수 있습니다.

4. 인터셉트 가능: 상태가 없는 컴포저블의 호출자는 상태 변경 전에 이벤트를 무시하거나 수정할 수 있습니다.

5. 디커플링: 상태가 없는 ExpandingCard의 상태는 어디에든 저장될 수 있습니다. 예를 들어, name을 ViewModel로 옮기는 것이 가능합니다.

 

예시에서는 name onValueChange HelloContent에서 추출하고 이를 호출하는 HelloScreen 컴포저블로 이동시켰습니다. 이렇게 함으로써 상태를 hoisting하여 HelloContent 상태 없이 만들 있습니다.

@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.bodyMedium
        )
        OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
    }
}

HelloContent에서 상태를 hoisting함으로써 해당 컴포저블을 이해하기 쉽고, 다양한 상황에서 재사용하고 테스트할 수 있습니다. HelloContent는 상태가 어떻게 저장되는지와 독립적으로 작동합니다. 디커플링(decoupling)은 HelloScreen을 수정하거나 교체하더라도 HelloContent의 구현 방식을 변경할 필요가 없다는 것을 의미합니다.

 

상태가 하향으로 이동하고 이벤트가 상향으로 이동하는 패턴을 단방향 데이터 흐름(unidirectional data flow)이라고 합니다. 경우, 상태는 HelloScreen에서 HelloContent 하향으로 전달되고, 이벤트는 HelloContent에서 HelloScreen으로 상향으로 전달됩니다. 단방향 데이터 흐름을 따르면 UI 상태를 표시하는 컴포저블과 상태를 저장하고 변경하는 앱의 다른 부분들을 서로 독립적으로 결합할 있습니다.

Restoring state in Compose

rememberSaveable API는 remember와 유사한 방식으로 동작합니다. 재구성 사이에서 상태를 유지할 뿐만 아니라 화면 회전과 같은 액티비티나 프로세스 재생성 시에도 저장된 인스턴스 상태 메커니즘을 사용하여 상태를 유지합니다.

상태를 저장하는 방법들은 다음과 같습니다:

 

Bundle에 추가되는 모든 데이터 타입은 자동으로 저장됩니다. Bundle에 추가할 수 없는 것을 저장하려면 여러 가지 옵션이 있습니다.

 

Parcelize:

가장 간단한 해결책은 객체에 @Parcelize 주석을 추가하는 것입니다. 그러면 객체가 Parcelable 되어 Bundle 저장될 있습니다. 예를 들어, 다음 코드는 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를 사용하여 객체를 번들에 저장 가능한 값들의 집합으로 변환하는 규칙을 직접 정의할 수 있습니다.

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

Retrigger remember calculations when keys 

var name by remember { mutableStateOf("") }

여기서 remember 함수를 사용하면 MutableState 값이 재구성 사이에서 보존됩니다.

일반적으로 remember는 계산 람다 매개변수를 받습니다. remember가 처음 실행될 때, 계산 람다를 호출하고 그 결과를 저장합니다. 재구성 중에는 remember가 마지막으로 저장된 값을 반환합니다.

상태를 캐싱하는 외에도, remember 사용하여 초기화 또는 계산이 비용이 많이 드는 모든 객체나 작업의 결과를 Composition 저장할 있습니다. 모든 재구성에서 이러한 계산을 반복하고 싶지 않을 수도 있습니다. 예를 들어, 다음과 같이 ShaderBrush 객체를 생성하는 작업은 비용이 많이 드는 작업입니다:

val brush = remember {
    ShaderBrush(
        BitmapShader(
            ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
            Shader.TileMode.REPEAT,
            Shader.TileMode.REPEAT
        )
    )
}

remember는 값이 Composition을 벗어날 때까지 값을 저장합니다. 그러나 캐시된 값을 무효화하는 방법도 있습니다. remember API는 key 또는 keys 매개변수도 사용할 수 있습니다. 이러한 키 중 하나라도 변경되면, 함수가 다음으로 recompose될 때 remember가 캐시를 무효화하고 계산 람다 블록을 다시 실행합니다. 이 메커니즘을 사용하면 Composition 내에서 객체의 수명을 제어할 수 있습니다. 입력이 변경되기 전까지 계산 결과는 유효하며, remembered 값이 Composition을 벗어나는 시점까지만 유효한 것과 달리, 입력이 변경될 때까지 유효합니다.

 

다음 예시는 이 메커니즘이 어떻게 작동하는지를 보여줍니다.

 

코드 조각에서는 ShaderBrush 생성되고 Box composable 배경으로 사용됩니다. 앞에서 설명한 대로 ShaderBrush 인스턴스를 다시 생성하는 것은 비용이 많이 듭니다. remember 선택한 배경 이미지인 avatarRes key1 매개변수로 사용합니다. 만약 avatarRes 변경된다면, brush 이미지로 recompose되고 Box 다시 적용됩니다. 이는 사용자가 선택한 이미지를 피커에서 다른 이미지로 변경할 발생할 있습니다.

@Composable
private fun BackgroundBanner(
    @DrawableRes avatarRes: Int,
    modifier: Modifier = Modifier,
    res: Resources = LocalContext.current.resources
) {
    val brush = remember(key1 = avatarRes) {
        ShaderBrush(
            BitmapShader(
                ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
                Shader.TileMode.REPEAT,
                Shader.TileMode.REPEAT
            )
        )
    }

    Box(
        modifier = modifier.background(brush)
    ) {
        /* ... */
    }
}

다음 코드 조각에서는 state 일반적인 상태 홀더 클래스인 MyAppState 끌어올려집니다. MyAppState 인스턴스를 remember 사용하여 초기화하는 rememberMyAppState 함수를 노출합니다. 재구성을 유지하는 인스턴스를 생성하는 이러한 함수를 노출하는 것은 Compose에서 흔한 패턴입니다. rememberMyAppState 함수는 windowSizeClass 받습니다. 매개변수는 remember key 매개변수로 사용됩니다. 만약 매개변수가 변경되면, 앱은 최신 값을 사용하여 일반적인 상태 홀더 클래스를 다시 생성해야 합니다. 이는 예를 들어 사용자가 기기를 회전시키는 경우에 발생할 있습니다.

@Composable
private fun rememberMyAppState(
    windowSizeClass: WindowSizeClass
): MyAppState {
    return remember(windowSizeClass) {
        MyAppState(windowSizeClass)
    }
}

@Stable
class MyAppState(
    private val windowSizeClass: WindowSizeClass
) { /* ... */ }

Compose 클래스의 equals 구현을 사용하여 키가 변경되었는지를 판단하고 저장된 값을 무효화합니다.

Store state with keys beyond recomposition

rememberSaveable API remember 감싸는 래퍼로, 데이터를 Bundle 저장할 있게 해줍니다. API 상태가 재구성 뿐만 아니라 액티비티 재생성과 시스템에서 시작한 프로세스 종료까지도 생존할 있게 합니다. rememberSaveable remember key 받는 것과 같은 목적으로 입력 매개변수를 받습니다. 입력이 변경되면 캐시가 무효화됩니다. 함수가 다음으로 recompose , rememberSaveable 계산 람다 블록을 다시 실행합니다.

var userTypedQuery by rememberSaveable(typedQuery, stateSaver = TextFieldValue.Saver) {
    mutableStateOf(
        TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )
}

'Android > Compose Docs' 카테고리의 다른 글

Compose Docs | Jetpack Compose Phases  (0) 2023.08.10
Compose docs | Side-effects in Compose  (0) 2023.08.08
Compose Docs | Modifiers  (0) 2023.08.07
Compose Docs | Lifecycle of composables  (0) 2023.08.01
Compose Docs | Thinking in Compose  (0) 2023.07.31