읽기 전
- 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
- 개인적으로 실습하면서 배운 점을 정리한 글입니다,
Jetpack Compose의 State 코드랩과 Jetpack Compose State 공식문서의 내용을 정리합니다. Compose가 상태를 추적하여 UI를 변경하게끔 코드를 작성하고 해당 상태를 저장합니다. Recomposition이 발생하여 Compose가 재생성되며 기존 상태를 복원하는 코드를 작성합니다.
관련 자료
Android | Jetpack Compose State & Event에서 이어지는 포스트입니다.
Compose State 추적
상태가 변경되면 영향을 받는 Composable 함수를 새 상태로 재실행하여 새로운 업데이트 UI가 생성되며 이를 Recomposition이 발생한다고 표현한다. 매번 모든 Composable 코드를 재실행할 수는 없기에 영향을 받지 않는 요소들은 건너뛰고 개별 Composable에 필요한 데이터만을 확인하여 업데이트 한다. 그렇기에 Compose가 UI 업데이트를 위해 어떤 상태를 추적해야 하는지 알아야 한다. 그래야 해당 상태가 업데이트 될 때 Recomposition을 예약할 수 있기 때문이다.
Compose에는 특정 상태를 읽는 Composable의 Recomposition을 예약할 수 있는 특정 상태 추적 시스템이 있다. 이를 통해 Compose가 전체 UI를 변경하지 않고 일부 Composable만 재구성할 수 있다. 해당 작업은 쓰기(상태 변경)뿐만 아니라 상태에 대한 읽기도 추적하여 실행된다.
Compose의 State
와 MutableState
를 사용하여 Compose에서 상태를 관찰할 수 있다. 상태 value
속성을 읽는 각 Composable을 추적하고 해당 value
가 변경되면 Recomposition을 트리거한다. mutableStateOf
함수를 사용하여 관찰 가능한 MutableState
를 만들 수도 있다. 해당 함수는 initial 값을 매개변수로 받아 State 객체에 래핑하여 상태의 value
값을 관찰 가능한 상태로 만든다.
count
변수가 0을 초기값으로 갖도록 mutableStateOf
함수를 적용하면 mutableStateOf
함수는 MutableState
타입을 반환한다. 따라서, value
를 업데이트하면 상태가 업데이트되며 Compose는 value
를 읽는 Composable 함수에 Recomposition을 트리거한다.
WaterCounter.kt
@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")
}
}
}
count
가 변경되면 count
의 value
를 읽는 Composable함수의 Recomposition이 예약되므로 WaterCounter
Composable 함수는 버튼을 클릭할 때마다 재구성된다. 그러나, 앱을 실행해보면 아무 일도 발생하지 않는다.
Recomposition 예약은 버튼 클릭 리스너로 count
변수의 value
를 증가시켰는데 왜 텍스트에 변화가 발생하지 않을까?
그 이유는 WaterCounter
가 재구성되면서 count 변수에 다시 mutableStateOf(0)
재할당으로 인해 0으로 초기화되었기 때문이다. 따라서, Recomposition이 발생하더라도 해당 변수의 값을 유지시킬 방법이 필요하다.
Compose State 저장
Compose에서는 Composable Inline 함수인 remember
API를 지원한다. remember
로 계산된 값은 Initial Composition 중에 Composition에 저장되고 저장된 값은 Recomposition 발생 시 유지된다.
remember
로 mutableStateOf
를 감싸서 변수에 할당하면 다음과 같이 사용할 수 있다.
WaterCounter.kt
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
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")
}
}
}
다만, 이렇게 할당을 해버리면 매번 value
속성을 찾을 때마다 직접 getter 호출을 해야 한다. Kotlin에서 지원하는 Delegated Properties(위임된 속성)을 사용하여 value
속성 호출을 간소화할 수 있다.
WaterCounter.kt
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
Text("You've had $count glasses.")
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
by 키워드를 사용하여 속성을 위임한 뒤 버튼 클릭 시 데이터가 변경되므로 var
키워드로 변수를 정의한다. 앱을 실행해보면 아래와 같이 동작한다.
Compose 밖에서 상태 저장
앞선 예제에선 Compose에서 상태를 저장한 뒤 읽었는데 크기가 비교적 큰 데이터는 UI 코드로 저장하기엔 부담이 있다. 따라서, ViewModel 등으로 분리하여 데이터를 보관하는데 해당 데이터가 변경될 때 인지하여 UI를 변경해야 하는 상황이 필요하다. 일반적으로 LiveData, StateFlow, Flow, Observable(RxJava) 등 관찰 가능한 유형을 사용하여 상태를 앱에 저장할 수 있다. 이들은 단순히 관찰 가능한 데이터일 뿐, Compose가 해당 데이터 객체를 관찰하고 상태가 변경될 때 자동으로 재구성되도록 하기 위해선 State로 매핑해야 한다.
예시코드 - LiveData
class ExampleViewModel: ViewModel() {
private val _exampleData: MutableLiveData<Tasks> = MutableLiveData()
val exampleData: LiveData<Tasks> = _data
}
@Composable
fun ExampleComposable(
exampleViewModel: ExampleViewModel = viewModel()
) {
val exampleData by exampleViewModel.exampleData.observeAsState()
}
observeAsState
를 사용하여 State로 매핑하였다. 이제 Compose는 ExampleViewModel
에 선언된 LiveData
객체가 갱신되면 상태 변화로 인지하여 Recomposition을 수행할 것이다.
저장된 상태 기반 UI
Compose는 선언한 UI 프레임워크이므로 상태 변경 시 UI 구성요소를 삭제하거나 공개 상태를 변경하지 않고 특정 상태의 조건에서 UI가 어떻게 존재하는 지 설명한다. Recomposition 발생 이후 UI가 업데이트된 결과로 Composable이 Composition을 수행하거나 종료할 수 있다.
즉, 위 그림에 따라 UI를 설명하는 "Composition"이 존재하고 Composable이 Composition으로 진입한 뒤 현재 상태에 따라 n회 Recompose된다. 이후 조건에 맞지 않으면 Composition에서 사라져 종료된다. 결국 Composable 함수가 Initial Composition이나 Recomposition에서 호출되는 경우에만 Composition에 Composable 함수가 위치하고 호출되지 않는다면 Composable 함수는 더 이상 Composition에 존재하지 않음을 의미한다.
상태에 따른 UI 출력
버튼 클릭 시 텍스트 값이 1씩 증가하도록 코드를 작성하였다. 만약 count
변수의 value
가 0보다 클 때만 텍스트를 보여주려 한다면 다음과 같이 코드를 작성할 수 있겠다.
WaterCounter.kt
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
if (count > 0) {
// This text is present if the button has been clicked
// at least once; absent otherwise
Text("You've had $count glasses.")
}
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
Layout Inspector로 Composition의 트리를 볼 수 있다.
앱을 실행해보면 아직 count
가 0이므로 Text 컴포저블이 배치되지 않았다. 이제 에뮬레이터의 버튼을 누르면 다음의 과정을 거쳐 텍스트가 생성된다.
count
의value
값이 1증가하면서 상태가 변경됨- Recomposition 예약
- 화면이 새로운 Element로 Recompose됨
다시 Layout Inspector로 Element트리를 확인하면 Text 컴포저블도 추가되었음을 확인할 수 있다.
상태는 특정 순간에 UI에 표시되는 요소를 결정
만약 count
변수의 값이 10이 될 때까지 버튼을 활성화하고 그 이후로는 사용할 수 없도록 만들고 싶다면 버튼 컴포저블의 enabled 매개변수를 사용하면 된다.
WaterCounter.kt
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
if (count > 0) {
Text("You've had $count glasses.")
}
Button(
onClick = { count++ },
enabled = count < 10,
Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
Composition의 Remember
remember
는 컴포지션에 객체를 저장한 뒤, remember
가 호출되는 소스 위치가 리컴포지션 중에 재호출되지 않으면 객체를 삭제한다. 해당 문장이 어떤 뜻인지 시각적인 확인을 위해 추가로 수행할 동작을 아래와 같이 정의한다.
- 사용자가 물을 한 잔 이상 마셨을 때 사용자가 할 Wellness 작업을 표시하고 닫을 수 있도록 제공
- 사용자가 마신 물을 0으로 초기화하는 기능 제공
WellnessTaskItem.kt
@Composable
fun WellnessTaskItem(
taskName: String,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier, verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.weight(1f).padding(start = 16.dp),
text = taskName
)
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}
표시할 task 이름과 닫기 버튼 클릭 시 수행할 람다 함수를 파라미터로 받는 Composable 함수를 정의하였다.
이제 count
가 0보다 크면 WellnessTaskItem
이 표시되도록 WaterCounter
를 수정한다. 또한, 닫기 기능을 지원하기로 했으므로 showTask
가 true인 경우 WellnessTaskItem
을 표시하도록 새로운 if 문을 추가하고 remember
API를 사용하여 리컴포지션이 발생하더라도 값이 유지되도록 한다. showTask
변수를 선언했으므로 닫기 클릭 시 showTask
변수를 false로 변경하도록 이벤트를 파라미터로 넘겨준다.
마지막으로 count
변수를 0으로 초기화해주는 버튼을 추가해서 UI를 초기 상태로 되돌리는 기능을 제공한다.
WaterCounter.kt
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
if (count > 0) {
var showTask by remember { mutableStateOf(true) }
if (showTask) {
WellnessTaskItem(
onClose = { showTask = false },
taskName = "Have you taken your 15 minute walk today?"
)
}
Text("You've had $count glasses.")
}
Row(Modifier.padding(top = 8.dp)) {
Button(onClick = { count++ }, enabled = count < 10) {
Text("Add one")
}
Button(onClick = { count = 0 }, Modifier.padding(start = 8.dp)) {
Text("Clear water count")
}
}
}
}
앱을 실행하면 아래 그림과 같이 화면이 구성된다.
Element 트리는 우측 그림과 같이 표현되는데 count
와 showTask
는 현재 기억된 값이고 count
가 0인 상태이므로 버튼이 담긴 Row 컴포저블만 컴포지션에 포함된다.
이후 앱에서 Add one
버튼을 클릭해보면 count
가 증가하여 리컴포지션이 발생하고 WellnessTaskItem
과 물의 양을 카운트해주는 Text
컴포저블이 모두 표시된다.
새로 출력된 WellnessTaskItem
의 X 버튼을 누르면 showTask
변수 상태에 변화가 생겼으므로 다른 리컴포지션이 발생하고 WellnessTaskItem
은 더 이상 표시되지 않는다.
다시 Add one
버튼을 클릭하여 사용자가 마신 물의 양을 증가시키면 리컴포지션이 발생한다. 다만, showTask
변수는 리컴포지션이 발생하여도 false임을 기억하고 있기에 WellnessTaskItem
이 출력되지 않는다.
Clear water count
버튼을 클릭하여 count
를 0으로 만들면 리컴포지션이 발생한다. count
가 0이 되었으므로 이후 count
를 표시하는 Text 컴포저블과 WellnessTaskItem
과 관련된 모든 코드를 호출하지 않고 컴포지션을 종료한다.
showTask
를 remember
하는 코드가 호출되지 않았으므로 앞서 설명했듯이 showTask
를 저장하는 객체가 컴포지션에서 삭제되고 첫 번째 단계로 복귀한다.
다시 Add one
버튼을 눌러 count
를 0에서 1로 만들면 리컴포지션이 발생한다.
WellnessTaskItem
컴포저블이 다시 출력됨을 확인할 수 있다. showTask
를 remember
하는 코드가 호출되지 않아 showTask
를 저장하는 객체가 컴포지션에서 삭제되었기 때문이다.
만약 showTask
변수의 값을 저장하는 코드의 위치를 바꾸면 리컴포지션 발생 시 어떤 차이가 있는지 확인해보자.
상태 코드 선언 위치에 따른 차이
WaterCounter.kt
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
var showTask by remember { mutableStateOf(true) }
if (count > 0) {
//var showTask by remember { mutableStateOf(true) }
if (showTask) {
WellnessTaskItem(
onClose = { showTask = false },
taskName = "Have you taken your 15 minute walk today?"
)
}
Text("You've had $count glasses.")
}
Row(Modifier.padding(top = 8.dp)) {
Button(onClick = { count++ }, enabled = count < 10) {
Text("Add one")
}
Button(onClick = { count = 0 }, Modifier.padding(start = 8.dp)) {
Text("Clear water count")
}
}
}
}
showTask
변수의 상태 저장 코드를 count
변수 값 확인 코드 이전에 작성하였다. 이전 코드는 remember
코드가 count
변수 분기문 다음에 작성되어 있어 아래와 같이 동작했다.
showTask
변수를 remember
하는 코드가 분기문 안에 있을 때 동작 흐름
count
변수가 0 -> 1로 전환될 시showTask
변수를 컴포지션에 넣으면서WellnessTaskItem
출력- 닫기 버튼 클릭하여
showTask
값 false로 전환 count
변수 0으로 초기화 시showTask
변수를remember
하는 위치까지 도달하지 않아 컴포지션에서 삭제- 다시
count
변수를 0 -> 1로 전환 시 이전showTask
변수의 값이 삭제되어WellnessTaskItem
출력
이제 위 코드대로 showTask
변수를 remember
하는 코드를 count
변수 분기문 바깥으로 빼고 실행해보면 동작 과정이 사뭇 달라진다.
showTask
변수를 remember
하는 코드가 분기문 밖에 있을 때 동작 흐름
count
변수와showTask
변수를 각각 0과 true로 초기화하여remember
count
변수가 0 -> 1로 전환될 시showTask
변수가 이미 true이므로WellnessTaskItem
출력- 닫기 버튼 클릭하여
showTask
값 false로 전환 count
변수 0으로 초기화 시 showTask 변수를remember
하는 코드가count
분기 밖이어서 도달됨- 다시
count
변수를 0 -> 1로 전환 시 이전showTask
변수의 값이 존재하기에WellnessTaskItem
이 출력되지 않음
흔히 개발을 하다보면 상태 관련 코드는 가급적 함수 상단에 몰아 넣는 경우가 많다. 그게 아니더라도 딱히 코드 선언 위치에 대해 고려하지 않는다. 그러나, 리컴포지션 시 도달하지 못한 remember
객체가 삭제된다는 점을 참고하면 Compose 사용 시 코드의 위치도 염두에 두어야 한다.
참고자료
'Android' 카테고리의 다른 글
Android | Jetpack Compose State 복원, 목록 사용 (0) | 2022.12.13 |
---|---|
Android | Jetpack Compose State Hoisting (0) | 2022.12.08 |
Android | Jetpack Compose State & Event (0) | 2022.12.05 |
Android | Activity의 개념과 생명주기(Life Cycle) (0) | 2022.09.22 |
Codelab | Jetpack Compose layout - Intrinsic Measure (0) | 2022.07.24 |