앱의 상태는 시간이 지남에 따라 변할 수 있는 값을 의미한다. 이는 매우 광범위한 정의로서 Room 데이터베이스부터 클래스 변수까지 모든 항목이 포함된다.
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에서의 컴포지션 및 리컴포지션 작동 방식 때문이다.
컴포지션
: Jetpack Compose가 컴포저블을 실행할 때, 빌드한 UI에 관한 설명
초기 컴포지션
: 처음 컴포저블을 실행하여 생성된 컴포지션
리컴포지션
: 데이터가 변경될 때 컴포지션을 업데이트하기 위해 컴포저블을 다시 실행하는 것
구성 가능한 함수는 remember
API를 사용하여 메모리에 객체를 저장할 수 있다. remember
에 의해 계산된 값은 초기 컴포지션 중에 컴포지션에 저장되고 저장된 값은 리컴포지션 중에 반환된다. remember
는 변경 가능한 객체뿐만 아니라 변경할 수 없는 객체를 저장하는데 사용할 수 있다.
remember는 객체를 컴포지션에 저장하고, remember를 호출한 컴포저블이 컴포지션에서 삭제되면 그 객체를 잊는다.
mutableStateOf
는 관찰 가능한 MutableState<T>
를 생성하는데, 이는 런타임시 Compose에 통합되는 관찰 가능한 유형이다.
interface MutableState<T> : State<T> {
override var value: T
}
value
가 변경되면 value
를 읽는 구성 가능한 함수의 리컴포지션이 예약된다. ExpandingCard
의 경우, expanded
가 변경될 때마다 ExpandingCard
가 재구성된다.
MutableState 객체를 선언하는 3가지 방법이 있다.
val mutableState = remember { mutableStateOf(default) }
var value by remember { mutableStateOf(default) }
val (value, setValue) = remember { mutableStateOf(default) }
기억된 값을 다른 컴포저블의 매개변수로 사용하거나 로직으로 사용하여 표시할 컴포저블을 변경할 수 있다.
@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
가 재구성 과정 전체에서 상태를 유지하는데 도움은 되지만, 구성 변경(Configuration Changes) 전반에서는 상태가 유지되지 않는다. 이 경우에는 rememberSaveable
을 사용해야 한다. rememberSaveable
은 Bundle
에 저장할 수 있는 모든 값을 자동으로 저장한다. 다른 값의 경우에는 맞춤 Saver 객체를 전달할 수 있다.
⛔️ 주의 : Compose에서 ArrayList 또는 mutableListOf() 같은 변경 가능한 객체를 상태로 사용하면 앱에 잘못되었거나 오래된 데이터가 표시될 수 있다. 변경 가능한 객체 중 ArrayList 또는 변경 가능한 데이터 클래스 같은 관찰 불가능한 객체는 Compose에서 관찰할 수 없으며, 객체가 변경될 때 리컴포지션을 트리거하지 않는다. 관찰 불가능하면서 변경 가능한 객체를 사용하는 대신
State<List<T>>
및 변경 불가능한 listOf() 같은 관찰 가능한 데이터 홀더를 사용하는게 좋다.
Jetpack Compost에서는 상태를 보존하기 위해 MutableState<T>
를 사용할 필요가 없다. Jetpack Compose는 관찰 가능한 다른 유형을 지원한다. 관찰 가능한 다른 유형을 읽으려면 상태를 State<T>
로 변환해야 한다. 그래야 상태가 변할 때, Jetpack Compose가 자동으로 재구성된다.
관찰 가능한 일반 유형에서 State<T>
를 만들 수 있는 함수가 내장되어 있으며, 의존성을 아래와 같이 추가해줘야 한다.
collectAsStateWithLifecycle()
collectAsStateWithLifecycle
은 수명 주기를 인식하는 방식으로 Flow의 값을 collect 하므로 앱에서 불필요한 앱 리소스를 절약할 수 있다. 이는 Compose State
를 통해 마지막으로 내보낸 값을 나타낸다. Android에서 Flow를 Collect 할 때 이 API를 사용하는게 좋다.dependencies {
...
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.0-beta01")
}
Flow : collectAsState()
collectAsState
도 Flow에서 값을 collect 하여 Compose State
로 변환한다는 점에서 collectAsStateWithLifecycle
과 유사하다. 플랫폼 제약이 없는 코드에는 collectAsState를 사용하고 Android에서는 Android 전용인 collectAsStateWithLifecycle을 사용하는게 권장된다.LiveData : observeAsState()
dependencies {
...
implementation("androidx.compose.runtime:runtime-livedata:1.3.2")
}
subscribeAsState()
subscribeAsState()
는 RxJava2, 3의 반응형 스트림(Single, Observable, Completable)을 Compose State
로 변환하는 확장 함수이다.dependencies {
...
// RxJava2
implementation("androidx.compose.runtime:runtime-rxjava2:1.3.2")
// RxJava3
implementation("androidx.compose.runtime:runtime-rxjava3:1.3.2")
}
Compose는 State 객체를 읽어오는 과정에서 자동으로 재구성된다. Compose에서 LiveData와 같은 관찰 가능한 다른 유형을 사용할 경우, 이를 읽어오려면 먼저 State로 변환해야 한다.
remember
를 사용하여 객체를 저장하는 컴포저블은 내부 상태를 생성하여 컴포저블을 Stateful로 만든다. 위 예제에서 HelloContent
는 내부적으로 name
상태를 보존하고 수정하므로 Stateful 컴포저블의 예시가 된다. 이는 호출자가 상태를 제어할 필요가 없고 상태를 직접 관리하지 않아도 상태를 사용할 수 있는 경우에 유용하다. 그러나 내부 상태를 갖는 컴포저블은 재사용 가능성이 적고 테스트하기가 어려운 경향이 있다.
Stateless 컴포저블은 상태를 갖지 않는 컴포저블이다. Stateless를 달성하는 한가지 쉬운 방법은 상태 호이스팅
을 사용하는 것이다.
재사용 가능한 컴포저블을 개발할 때는 동일한 컴포저블의 Stateful 버전과 Stateless 버전을 모두 노출해야 하는 경우가 있다. Stateful 버전은 상태를 염두에 두지 않는 호출자에게 편리하며, Stateless 버전은 상태를 제어하거나 끌어올려야 하는 호출자에게 필요하다.
Compose에서 상태 호이스팅은 컴포저블을 Stateless로 만들기 위해 상태를 컴포저블의 호출자로 옮기는 패턴이다. 상태 호이스팅을 하는 일반적인 패턴은 상태 변수를 다음 두 개의 매개변수로 바꾸는 것이다.
즉, 상태를 끌어올리기라는 뜻을 가진다. 상태 호스팅을 통해서 자식 Composable의 State를 해당 Composable을 호출하는 Composable 쪽으로 끌어올림으로써 자식 Composable을 Stateless하게 만드는 것이다. 요약하면 State Hoisting은 자식 Composable의 State를 호출부로 끌어올리는 것을 뜻한다.
value
: T로 표시할 현재 값onValueChange
: (T) -> Unit으로 T가 제안된 새 값인 경우 값을 변경하도록 요청하는 이벤트onValueChange
로만 제한되지 않는다. 컴포저블에 더 구체적인 이벤트가 어울리는 경우, ExpandingCard
가 onExpand
와 onCollapse
를 정의할 때와 같이 람다를 사용하여 그 이벤트를 정의해야 한다.
이러한 방식으로 끌어올린 상태에는 중요한 속성이 몇가지 있다.
아래 예제는 HelloContent
에서 name
과 onNameChange
를 추출한 다음, 이러한 항목을 트리 상단을 거쳐 HelloContent
를 호출하는 HelloScreen
컴포저블로 옮긴다.
// stateful
@Composable
fun HelloScreen() {
var name by rememberSaveable { mutableStateOf("") }
HelloContent(name = name, onNameChange = { name = it })
}
// stateless
@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에 상태를 표시하는 컴포저블과 상태를 저장하고 변경하는 앱 부분을 서로 분리할 수 있다.
💡 핵심 사항 : 상태를 끌어올릴 때, 상태의 이동 위치를 쉽게 파악할 수 있는 세 가지 규칙이 있다.
1. 상태는 적어도 그 상태를 사용하는 모든 컴포저블의 가장 낮은 공통 상위 요소로 끌어올려야 한다.(읽기)
2. 상태는 최소한 변경될 수 있는 가장 높은 수준으로 끌어올려야 한다.(쓰기)
3. 동일한 이벤트에 대한 응답으로 두 상태가 변경되는 경우, 두 상태를 함께 끌어올려야 한다.이러한 규칙에서 요구하는 것보다 상태를 더 높은 수준으로 끌어올릴 수 있다. 하지만, 상태를 끌어내리면 단방향 데이터 흐름을 따르기가 어렵거나 불가능할 수 있다.
Activity 또는 Process가 다시 생성된 이후 rememberSaveable
을 사용하여 UI 상태를 복원한다. rememberSaveable
는 재구성 과정 전체에서 상태를 유지한다. 또한, rememberSaveable
은 Activity 및 Process 재생성 전반에 걸쳐 상태를 유지한다.
Bundle
에 추가되는 모든 데이터 유형은 자동으로 저장된다. Bundle
에 추가할 수 없는 항목을 저장하려는 경우에는 몇가지 옵션이 있다.
객체에 @Parcelize
어노테이션을 추가하는 것이다. 그러면 객체가 parcelable
이 되며 번들로 제공될 수 있다.
ex)
@Parcelize
data class City(val name: String, val country: String) : Parcelable
@Composable
fun CityScreen() {
var selectedCity = rememberSaveable {
mutableStateOf(City("Madrid", "Spain"))
}
}
어떤 이유로 @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
를 사용하고 index를 키로 사용하면 맵의 키를 정의할 필요가 없다.
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"))
}
}
간단한 상태 끌어올리기는 구성 가능한 함수 자체에서 관리 가능하다. 그러나 추적할 상태의 양이 늘어나거나 구성 가능한 함수에서 실행할 로직이 발생하는 경우 로직과 상태 책임을 다른 클래스, 즉 상태 홀더에 위임하는게 좋다.
상태 홀더는 컴포저블의 로직과 상태를 관리한다. 참고로, 다른 자료에서는 상태 홀더를 호이스팅한 상태 객체라고도 한다.
remember API는 MutableState와 함께 자주 사용된다.
var name by remember { mutableStateOf("") }
remember 함수를 사용하면 리컴포지션 후에도 MutableState 값이 유지된다.
일반적으로 remember
는 calculation
람다 매개변수를 취한다. remember
처음 실행되면 calculation
람다를 호출하고 그 결과를 저장한다. 리컴포지션 중에 remember
는 마지막으로 저장된 값을 반환한다.
캐싱 상태 외에도 remember를 사용하여 초기화하거나 계산하는데 비용이 많이 드는 객체 또는 작업의 결과를 컴포지션 중에 저장할 수도 있다. 매 리컴포지션마다 이 계산을 반복하지 않는 것이 좋다. 아래처럼 비용이 많이드는 작업이 그 예시라고 볼 수 있다.
val brush = remember {
ShaderBrush(
BitmapShader(
ImageBitmap.imageResource(res, R.drawable.myDrawable).asAndroidBitmap(),
Shader.TileMode.REPEAT,
Shader.TileMode.REPEAT
)
)
}
보통 remember
는 컴포지션을 종료할 때까지 값을 저장한다. 하지만, 캐시된 값을 무효화하는 방법이 있다. remember
API는 key
또는 keys
매개변수를 갖는다. 지정한 키 중 하나라도 변경될 경우, 다음번에 함수가 재구성될 때 remember는 캐시를 무효화하고 계산 람다 블록을 다시 실행한다. 이러한 메커니즘을 통해 컴포지션 내 객체의 전체 기간을 제어할 수 있다. 계산은 remember
를 통해 저장된 값이 컴포지션을 종료할 때까지가 아니라 입력이 변경될 때까지 유효하다.
ex) ShaderBrush
가 생성되고 Box
컴포저블의 background에 사용된다. remember
는 ShaderBrush
인스턴스를 저장한다. 이 인스턴스를 다시 만드는데 비용이 많이 들기 때문이다. remember
는 avatarRes
를 선택된 배경 이미지인 key1 매개변수로 사용한다. avatarRes
가 변경되면 브러시는 새 이미지로 재구성되고 Box
에 다시 적용된다. 이는 사용자가 선택 도구에서 배경으로 할 다른 이미지를 선택할 때 발생할 수 있다.
@Composable
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)
) {
// ...
}
}
다음 스니펫에서는 상태가 일반 상태 홀더 클래스 MyAppState
로 호이스팅된다. 이 클래스는 rememberMyAppState
함수를 노출하여 remember
로 클래스의 인스턴스를 초기화한다. 이러한 함수를 노출하여 리컴포지션에도 유지되는 인스턴스를 만다는 것은 Compose의 일반적인 패턴이다.
rememberMyAppState
함수는 remember
의 key
매개변수 역할을 하는 windowSizeClass
를 받는다. 이 매개변수가 변경되면 앱은 최신 값으로 일반 상태 홀더 클래스를 다시 만들어야 한다. 예를 들어, 사용자가 기기를 회전하는 경우 이러한 상황이 발생할 수 있다.
@Composable
fun rememberMyAppState(
windowSizeClass: WindowSizeClass
): MyAppState {
return remember(windowSizeClass) {
MyAppState(windowSizeClass)
}
}
@Stable
class MyAppState(
private val windowSizeClass: WindowSizeClass
) { ... }
Compose는 클래스의 equals 구현을 사용하여 키가 변경되었는지 확인하고 저장된 값을 무효화한다.
remember를 사용하는 것이 derivedStateOf 같은 다른 Compose API를 사용하는 것과 유사하게 보일 수 있다. 관련해서 블로그 글을 참고하면 좋다.
rememberSaveable
API는 Bundle
에 데이터를 저장할 수 있는 remember
코드의 래퍼이다. 이 API를 사용하면 재구성뿐만 아니라 Activity 재생성 및 시스템에서 시작된 프로세스 종료 시에도 상태를 유지할 수 있다. rememberSaveable
도 remember
처럼 key
와 비슷하게 input
매개변수를 받는다. 입력이 변경되면 캐시는 무효화된다. 다음에 함수가 재구성될 경우 rememberSaveable
는 계산 람다 블록을 다시 실행한다.
참고 : remember API에서는 매개변수 이름 keys를 사용하고 rememberSaveable에서는 같은 용도로 inputs를 사용한다. 이러한 매개변수 중 하나라도 변경되면 캐시된 값을 무효화된다.
ex) rememberSaveable는 typedQuery가 변경될 때까지 userTypedQuery를 저장한다.
var userTypedQuery by
rememberSaveable(inputs = typedQuery, stateSaver = TextFieldValue.Saver) {
mutableStateOf(
TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
)
}