Android/Compose

[Android] Jetpack Compose에서의 상태관리

seunghwaan 2024. 3. 17. 14:01
반응형

안녕하세요. 이번 포스트에서는 Jetpack Compose의 상태관리에 대해 알아보겠습니다.

 

Android 앱의 상태 및 Jetpack Compose

Android 앱의 상태는 시간에 따라 변할 수 있는 값을 의미합니다. -> 이것은 매우 광범위하게 정의되며 Room의 데이터베이스에서부터 Room의 변수까지 모든 것을 포함합니다.

 

모든 Android 앱에서는 User에게 상태를 표시합니다.

 

예를 들면 아래와 같은 상황이 있을 수 있습니다.

- 네트워크 연결을 설정할 수 없을 때 표시되는 스낵바

- 블로그 게시물 및 댓글

- 사용자가 클릭하면 버튼에서 재생되는 물결 애니메이션

- 사용자가 이미지 위에 그릴 수 있는 스티커

 

Jetpack Compose를 사용하면 이러한 Android 앱에서 어디에 상태를 저장하고 사용할 것인지 명확하게 할 수 있습니다.

 

상태(State)와 컴포지션(Composition)

Compose는 선언적이므로 Compose를 업데이트하는 유일한 방법은 인자로 동일한 Composable을 호출하는 것입니다. 이러한 인자는 UI 상태(State)를 나타냅니다. 상태가 업데이트 될 때마다 리컴포지션(Recomposition)이 실행됩니다. 따라서 TextField와 같은 항목은 명령형 XML기반의 뷰처럼 자동으로 업데이트 되지 않습니다. Composable이 새로운 상태에 따라 업데이트되려면 새로운 상태를 명시적으로 알려줘야 합니다.

 

@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 파라마터가 변경될 때 업데이트되기 때문입니다. 이는 Composition과 Recomposition이 작동하는 방식 때문입니다.

 

Compose를 설명하면서 계속 나오는 핵심 용어들을 정리하면 아래와 같습니다.

 

Composition -> Jetpack Compose가 Composable을 실행할 때 빌드한 UI

Initial Composition -> 첫 Composable을 통해 생성된 Composition

Recomposition -> 데이터가 변경될 때 Composition을 업데이트하기 위해 Composable을 다시 실행하는 것

 

컴포저블(Composable)의 상태(State)

컴포저블(Composable) 함수remember API를 사용하여 메모리에 객체를 저장할 수 있습니다. remember API에 의해 계산된 값은 Initial Composition 중에 Composition에 저장되고 저장된 값은 Recomposition 중에 반환됩니다. remember API는 변경 가능한 객체뿐만 아니라 변경할 수 없는 객체를 젖아하는 데 사용할 수 있습니다.

 

즉, remember는 객체를 Composition에 저장하고, remember를 호출한 Composable이 Composition에서 삭제되면 그 객체를 잊어버립니다.

 

mutableStateOf

mutableStateOf는 관찰할 수 있는 Observable인 MutableState<T>를 생성하는데, 이는 런타임 시 Compose에 통합되는 관찰 가능한 타입입니다.

 

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

 

 

value가 변경되면 value를 읽는 Composable 함수의 Recomposition이 예약됩니다.

Composable에서 MutableState 객체를 선언하는 세가지 방법이 있습니다.

 

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

위 세가지 방법은 동일한 것이며, 서로 다른 용도의 State를 사용하기 위한 syntax sugar로 제공됩니다. 작성 중인 Composable에서 가장 읽기 쉬운 코드를 생성하는 방법을 선택해야 합니다.

 

by 위임 구문에는 아래와 같은 import가 필요합니다.

 

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

 

기억된 값을 다른 Composable의 매개변수로 사용하거나 문의 로직으로 사용하여 표시할 Composable을 변경할 수 있습니다. 예를 들어 이름이 비어 있는 경우 "Hello, $name!" 텍스트를 표시하지 않으려면 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가 Recomposition 과정 전체에서 상태를 유지하는 데 도움은 되지만, Configuration change와 같은 경우에는 상태가 유지되지 않습니다. Configuration change와 같은 경우에 상태를 유지하기 위해서는 rememberSavable을 사용해야 합니다. rememberSavable은 Bundle에 저장할 수 있는 모든 값을 자동으로 저장합니다. 다른 타입의 경우에는 맞춤 Saver 객체에 저장하여 전달할 수 있습니다.

 

지원되는 기타 State 타입

Compose에서는 상태를 보유할 때 MutableState<T>를 사용할 필요가 없습니다. 이 항목은 관찰 가능한 Observable type 지원합니다. 또 다른 Observable type을 읽기 전 Compose를 사용하려면 Composable을 State<T>로 변환해야 합니다. 그러면 상태가 변경되면 자동으로 Recomposition이 됩니다.

 

Compose에는 이미 Android에서 자주 사용되는 Observable Type에서 State<T>로 바꿀 수 있는 함수가 내장되어 있습니다. 

 

Flow의 State로 변경 -> collectAsStateWithLifeCycle()

collectAsStateWithLifecycle()은 Flow를 수명 주기를 인식하는 방식으로 값을 수집하도록 구현하여 Compose의 State로 변환하며, 앱의 리소스를 절약할 수 있습니다. 이 값은 Compose의 State에서 방출된 가장 마지막 값입니다. 이 API를 사용하는 것이 안드로이드 앱에서 Flow를 수집하는 가장 좋은 방법입니다.

 

Flow의 State로 변경 -> collectAsState()

collectAsState()도 Flow에서 값을 수집하여 Compose의 State로 변환한다는 점에서 collectAsStateWithLifeCycle과 유사합니다.

그래서, 플랫폼 제약이 없는 코드에서는 Android 전용인 collectAsStateWithLifecycle 대신 collectAsState를 사용하면 됩니다.

 

LiveData의 State로 변경 -> observeAsState()

observeAsState()는 LiveData를 observe하고 State를 통해 값을 나타냅니다.

 

RxJava3의 생산자의 State로 변경 -> subscribeAsState()

subscribeAsState()는RxJava3의 반응형 스트림(Single, Observable, Completable)을 Compose의 State로 변환하는 확장함수 입니다.

 

Key가 변경될 경우 remember 계산 다시 트리거하기

remember APIMutableState와 함께 자주 사용됩니다.

var name by remember { mutableStateOf("") }

 

위 코드처럼 remember 함수를 사용하면 리컴포지션 후에도 MutableState 값이 유지됩니다.

 

일반적으로 remember 함수는 람다 매개변수인 calculation을 가지고 있습니다. remember가 처음 실행되면 calculation 람다를 호출하고 그 결과를 저장합니다. 리컴포지션 중에, remember는 마지막으로 저장된 값을 반환합니다.

 

캐싱 상태 외에도 remember 함수를 사용하여 초기화하거나 계산하는 데 비용이 많이 드는 객체 또는 작업의 결과를 컴포지션 중에 저장할 수 있습니다. 즉, 매번의 Recomposition마다 이 계산을 반복하지 않게 하는 것이 좋습니다.

 

한 가지 예시로 비용이 많이 드는 작업인 SideBrush 객체를 만드는 경우를 들 수 있습니다.

 

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

 

remember는 컴포지션을 종료할 때 까지 값을 저장합니다. 하지만 캐시된 값을 무효화하는 방법이 있습니다. remember API는 key 또는 keys 매개변수를 가지고 있습니다. 이러한 키 중 하나라도 변경될 경우 다음번 함수가 Recomposition될 때 remember는 캐시를 무효화하고 calculation 람다 블록을 다시 실행합니다. 이 메커니즘을 통해 컴포지션 내에 존재하는 객체의 생명 주기를 제어할 수 있습니다. 즉,  calculation은 기억된 값이 컴포지션을 종료할 때까지가 아니라 입력이 변경될 때까지 유효하게 합니다.

 

다음 예는 이 메커니즘의 작동 방식을 보여줍니다.

 

@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)
    ) {
        /* ... */
    }
}

 

위 코드에서 SideBrush가 생성되고 Box 컴포저블의 배경 페인트로 사용됩니다. remember는 앞에서 설명한 대로 SideBrush 인스턴스를 저장합니다. 이 인스턴스를 다시 만드는 데 비용이 많이 들기 때문에 선택된 배경 이미지 값인 avatarRes를 key1 매개변수로 사용합니다. avatarRes가 변경되면 브러시는 새 이미지로 Recomposition되고 Box에 다시 적용됩니다. 즉, 사용자가 선택창에서 배경으로 할 다른 이미지를 선택할 때 avatarRes가 변경되고 remember의 값이 Recomposition됩니다.

 

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

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

 

위 코드에서는 MyAppState의 상태가 일반 상태 홀더 클래로 호이스팅 됩니다. 이 클래스는 rememberMyAppState 함수를 통해 클래스의 인스턴스를 remember로 초기화 합니다. 이러한 함수를 통해 리컴포지션에도 유지되는 인스턴스를 만드는 것은 Compose의 일반적인 패턴입니다. rememberMyAppState 함수는 remember의 key 매개변수로 사용되는 windowSizeClass를 받습니다. 이 매개변수가 변경되면 앱은 최신 값으로 일반 상태 홀더 클래스를 다시 만들어야 합니다. 예를 들어 사용자가 기기를 회전하는 경우 이러한 상황이 발생할 수 있습니다.

 

Compose에서 상태 복원

rememberSavable API는 remember와 유사하게 동작합니다. 리컴포지션 간에 상태 유지뿐만 아니라 Activity나 프로세스 간에도 상태를 복원하여 유지하게 할 수 있습니다. 예를 들면, 인코더-디코더 입니다.

 

참고: 단, rememberSavable은 유저에 의해 현재 Activity를 스와이프하여 완전히 닫은 경우에는 상태를 유지하지 않습니다.

 

상태를 저장하는 방법

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

 

Parcelize

가장 간단한 해결책은 @Parcelize 주석을 추가하면 됩니다. 그러면 객체가 parcelable이 되며 번들에 추가할 수 있습니다. 예를 들면, 아래 코드는 City 데이터 타입을 만들어 parcelable로 만들어 상태에 저장하게 만듭니다.

 

@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를 사용하고 index를 key로 사용하면 Map의 key를 정의할 필요가 없어집니다.

 

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

 

리컴포지션 외에도 키와 함께 상태 저장

rememberSavable API는 Bundle 데이터를 저장할 수 있는 remember 코드의 래퍼입니다. 이 API를 사용하면 리컴포지션 뿐만 아니라 Activity 재생성 및 시스템에서 시작된 프로세스의 종료 시에도 상태를 유지할 수 있습니다. rememberSavable은 remember가 keys를 받는 것과 같은 목적으로 input 매개변수를 받습니다. 입력이 변경되면 캐시는 무효화됩니다. 다음에 함수가 리컴포지션될 경우 rememberSavable은 calculation 람다 블록을 다시 실행합니다.

 

*참고: API 이름 지정에는 유념해야 할 차이점이 있습니다. remember API에서는 매개변수 이름 keys를 사용하고, rememberSavable에서는 같은 용도로 inputs를 사용합니다. 이러한 매개변수 중 하나라도 변경되면 캐시된 값은 무효화됩니다.

 

아래 예시는 rememberSavable은 typedQuery가 변경될 때까지 userTypedQuery를 저장합니다.

 

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

 

References

https://developer.android.com/develop/ui/compose/state

 

상태 및 Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 상태 및 Jetpack Compose 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 앱의 상태는 시간이 지남에 따라

developer.android.com

 

 

반응형