읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose의 State 코드랩Jetpack Compose State 공식문서의 내용을 정리합니다. Compose에서 컴포지션 종료와 재생성 간 상태를 어떻게 보관하는지를 Compose에서의 목록 사용과 연계해서 알아봅니다.

관련 자료

Compose State 복원은 Android | Jetpack Compose Remember State에서 이어집니다.

Compose 목록 사용은 Android | Jetpack Compose State Hoisting에서 이어집니다.

액티비티 재구성에 따른 상태 변화

Compose 상태가 바뀌면 리컴포지션이 발생하면서 변화된 상태를 반영했었다. 그리고 변경된 상태를 리컴포지션에도 유지하기 위해 remember API를 사용하여 저장한다. 그렇다면, 화면 회전이나 언어 변경, 라이트/다크 모드 전환과 같이 Config Change로 인해 실행 중인 Activity가 Android 시스템에 의해 닫혔다가 다시 생성되는 경우엔 어떻게 될까?

앱을 실행한 뒤 Add one 버튼을 눌러 count 변수를 0에서 1로 바꾼 뒤 기기를 회전시켜 화면을 세로모드에서 가로모드로 바꿔보자.

Android_Compose_State_Restore_001

Activity는 Config Change(구성 변경, 이 경우는 방향) 후 재생성되므로 저장된 상태를 삭제된다. 따라서, count 변수는 다시 초기값인 0으로 되돌아가며 카운터가 사라진다.

remember를 사용하면 리컴포지션 간 상태를 유지할 수 있지만 Config Change로 인한 구성 변경 간에는 유지되지 않는다. 이를 위한 새로운 rememberSaveable이라는 API를 사용해야 한다.

상태 복원에 쓰이는 rememberSaveable

rememberSaveableBundle에 저장할 수 있는 모든 값을 자동으로 저장한다. 다른 값의 경우 커스텀 Saver 객체를 전달할 수 있다.

Bundle에 저장할 수 없는 항목 저장

Parcelize 주석 추가

가장 간단한 방법으로 @Parcelize 주석을 추가하는 것이다. 그러면 객체가 parceable이 되며 번들로 제공된다. 다음의 코드는 City 데이터 클래스를 parceable로 만들어 상태에 저장한다.

@Parcelize
data class City(val name: String, val country: String) : Parceable
@Composable
fun CityScreen() {
var selectedCity = rememberSaveable {
mutableStateOf(City("Madrid", "Spain"))
}
}

MapSaver API 사용

key-value 집합으로 객체를 변환하는 규칙을 정의하여 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 API 사용

list 형태로도 stateSaver를 만들 수 있다.

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

rememberSaveable 적용

기존 WaterCounter에서 rememberrememberSaveable로 바꿔보자.

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
//var count by remember { mutableStateOf(0) }
var count by rememberSaveable { 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")
}
}
}

Android_Compose_State_Restore_002

결국 앱의 UX 상황에 맞춰서 remember를 사용할 것인지 rememberSaveable을 사용할 것인지 결정해야 한다.

Compose에서의 목록 사용

Android | Jetpack Compose State Hoisting에 이어 목록 사용을 위해 앱에 두 가지 작업을 추가한다.

  • 작업을 완료로 표시하기 위해 목록 항목 선택 기능
  • 완료할 필요 없는 작업을 목록에서 삭제 기능

목록 item 정의

목록에 보여줄 아이템을 정의해야 한다. 이전에 Android | Jetpack Compose Remember State에서 정의했던 WellnessTaskItem을 재사용하여 체크박스와 닫기 버튼을 추가해주자.

WellnessTaskItem.kt

@Composable
fun WellnessTaskItem(
taskName: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier, verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
text = taskName
)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}

Checkbox 컴포저블과 IconButton 컴포저블을 추가하여 체크와 닫기 기능을 지원할 수 있도록 UI를 구성하였고 컴포저블 파라미터로 checked, onCheckedChange, onClose를 입력하여 Stateless 컴포저블이 될 수 있도록 한다.

목록 item의 상태 정의

목록 item을 stateless하게 정의했으므로 외부에서 상태를 주입해야 한다. checkedStated를 정의하는 동일한 이름의 Stateful 컴포저블을 우선 정의해둔다.

WellnessTaskItem.kt

@Composable
fun WellnessTaskItem(taskName: String, modifier: Modifier = Modifier) {
var checkedState by remember { mutableStateOf(false) }
WellnessTaskItem(
taskName = taskName,
checked = checkedState,
onCheckedChange = { newValue -> checkedState = newValue },
onClose = {}, // we will implement this later!
modifier = modifier,
)
}

Task의 내용을 담을 데이터 클래스 정의

WellnessTask.kt파일을 만들어 ID와 라벨이 포함된 Task 모델을 데이터 클래스로 정의한다.

WellnessTask.kt

data class WellnessTask(val id: Int, val label: String)

Task 목록을 반환할 함수 추가 정의

WellnessTasksList.kt라는 파일을 만들어 더미 데이터를 반환하는 함수를 정의한다.

WellnessTasksList.kt

private fun getWellnessTasks() = List(30) { i ->
WellnessTask(i, "Task # $i")
}

가짜 데이터를 반환하고 있지만 실제 서비스에서는 데이터 영역에서 조회하거나 네트워크를 통해 데이터를 받아서 전달한다.

WellnessTasksList.kt에서 목록 정의

WellnessTasksList.kt에서 목록을 만드는 컴포저블 함수를 정의한다. LazyColumn으로 목록을 구현하고 삽입할 item으로 앞서 정의했던 WellnessTaskItem을 활용하면 되겠다.

@Composable
fun WellnessTasksList(
modifier: Modifier = Modifier,
list: List<WellnessTask> = remember { getWellnessTasks() }
) {
LazyColumn(
modifier = modifier
) {
items(list) { task ->
WellnessTaskItem(taskName = task.label)
}
}
}

목록을 WellnessScreen에 추가

Task들을 보여줄 목록을 정의했으므로 화면에 출력하기 위해 WellnessTaskList 컴포저블을 WellnessScreen에 추가한다.

WellnessScreen.kt

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
StatefulCounter()
WellnessTasksList()
}
}

이제 앱을 실행해보면 체크박스 선택이 가능해진다.

Android_Compose_State_Restore_003

다만 삭제하는 로직은 빈 람다함수를 파라미터로 넣었기 때문에 아직 동작하지 않는다. 그리고 WellnessTaskItem의 상태로 remember API를 사용했었는데 이와 관련해서 어떤 부작용이 있는지 확인해보자.

LazyList에서의 상태 복원

checkedState는 각 WellnessTaskItem 컴포저블에 독립적으로 속한다. checkedState가 변경되면 WellnessTaskItem의 해당 인스턴스만 재구성되며 LazyColumn의 모든 WellnessTaskItem 인스턴스에 리컴포지션이 트리거되진 않는다. 현대 WellnessTaskItem 코드를 보면 remember API를 사용하여 checkedState 변수를 정의하였다. 앱을 켜고 다음의 절차를 따라해보면 의도와는 다르게 동작함을 확인할 수 있다.

  1. 해당 목록 상단의 item 체크박스를 선택
  2. 선택한 item들이 화면 밖으로 나가도록 스크롤
  3. 앞서 선택한 item이 화면 안으로 들어오도록 스크롤
  4. 선택했던 item들의 체크박스가 해제되어 있음

remember의 문제점으로 컴포지션에서 호출되지 않으면 기억된 상태를 삭제한다는 점이 있었다. LazyColumn 컴포저블의 경우 스크롤하면서 항목을 지나치면 해당 item에 해당 컴포지션을 완전히 종료하기 때문에 UI 컴포지션에서 삭제되어 더 이상 항목이 표시되지 않는다.

Android_Compose_State_Restore_004

이 문제를 해결하기 위해선 `rememberSaveable을 사용하면 된다. 저장된 값은 저장된 인스턴스 상태 메커니즘을 통해 Activity나 프로세스 재생성 시에도 유지되기 때문에 컴포지션이 종료될 때도 유지된다.

Statefull하게 정의한 WellnessTaskItem의 코드에서 rememberrememberSaveable로 바꿔보자.

WellnessTaskItem.kt

@Composable
fun WellnessTaskItem(taskName: String, modifier: Modifier = Modifier) {
var checkedState by rememberSaveable { mutableStateOf(false) }
WellnessTaskItem(
taskName = taskName,
checked = checkedState,
onCheckedChange = { newValue -> checkedState = newValue },
onClose = {}, // we will implement this later!
modifier = modifier,
)
}

코드를 수정한 뒤 앱을 실행해보면 체크 상태가 유지되고 있음을 확인할 수 있다.

Android_Compose_State_Restore_005

Compose의 일반적 패턴

LazyColumn의 내부 구현을 보면 state를 다음과 같이 정의해두고 있다.

LazyDsl.kt

@Composable
fun LazyColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
...
) {
...
}

내부 상태 정의를 위해 LazyListState 타입을 반환하는 rememberLazyListState()함수를 호출함을 확인할 수 있다. rememberLazyListState 함수의 내부 구현을 더 들어가보자.

LazyListSTate.kt

@Composable
fun rememberLazyListState(
initialFirstVisibleItemIndex: Int = 0,
initialFirstVisibleItemScrollOffset: Int = 0
): LazyListState {
return rememberSaveable(saver = LazyListState.Saver) {
LazyListState(
initialFirstVisibleItemIndex,
initialFirstVisibleItemScrollOffset
)
}
}

rememberLazyListState 컴포저블 함수는 rememberSaveable을 사용하여 목록의 초기 상태를 구현한다. 그렇기 때문에 Activity가 재생성 되더라도 스크롤 상태는 어떠한 조치를 취하지 않아도 유지된다.

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #8. Compose에서 상태 복원
  2. Google Codelab - Jetpack Compose의 상태 #10. 목록 사용
  3. Android Developers - 상태 및 Jetpack Compose

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose의 State 코드랩Jetpack Compose State 공식문서의 내용을 정리합니다. Stateful과 Stateless 컴포저블의 차이를 확인하고 상태를 상단으로 끌어올리는 Hoisting에 대해 알아봅니다.

관련 자료

Android | Jetpack Compose Remember State에서 이어지는 포스트입니다.

Hoisting(호이스팅)이란?

주로 제시하는 설명은 "변수의 선언과 초기화를 분리한 후 선언 부분만 코드의 최상단으로 옮기는 행위"라고 한다. 왜 이러한 과정을 수행하는지 알아보자.

상태 호이스팅

remember를 사용하여 객체를 저장하는 컴포저블은 내부 상태를 포함하고 있어 Stateful하다고 볼 수 있다. 이는 호출하는 곳에서 상태를 제어할 필요가 없고 상태를 직접 관리하지 않아도 내부적으로 상태를 사용할 수 있는 상황에서 유용하다. 그러나, 내부 상태를 갖는 컴포저블은 재사용 가능성이 적고 테스트하기가 더 어렵다. 그래서 상태를 바깥 호출하는 위치에서 선언함으로써 호출된 컴포저블 내부에서는 주입된 상태를 다루는 방식 즉, Stateless한 컴포저블을 사용할 수 있다.

Compose에서의 상태 호이스팅은 컴포저블을 Stateful에서 Stateless로 만들기 위해 상태를 컴포저블의 호출자로 옮기는 패턴을 의미한다. 일반적으로 상태 관련된 변수를 다음 두 개의 매개변수로 바꿈으로써 이루어진다.

  • value: T - 컴포저블이 다룰 상태 값
  • onValueChange: (T) -> Unit - 상태의 값을 변경하도록 요청하는 이벤트이며 T는 컴포저블에 제안할 새로운 값이다.

이렇듯 상태가 내려가고(UI에 표시할 상태가 컴포저블로 내려감) 이벤트가 올라가는(하위 컴포저블에서 발생된 이벤트가 상위 컴포저블로 올라감) 패턴을 단방향 데이터 흐름(UDF)라고 하고 상태 호이스팅은 해당 아키텍쳐를 Compose에서 구현하는 방법이라고 설명한다.

상태를 호이스팅함으로써 갖는 속성으론 다음이 있다.

  • 단일 소스 저장소 : 상태를 복제하는 대신 옮겼기에 소스 저장소가 하나만 존재하여 버그 방지에 도움
  • 공유 가능 : 호이스팅한 상태를 여러 컴포저블과 공유할 수 있음
  • 캡슐화됨 : Stateful한 컴포저블만 상태를 수정할 수 있음
  • 가로채기 가능 : Stateless 컴포저블의 호출자는 상태를 변경하기 전 이벤트를 무시할 것인지 반영할 것인지 결정가능
  • 분리됨 : Stateless 컴포저블에서 다루는 상태를 어디에나 저장할 수 있게 된다. (ex. ViewModel)

StatefulStateless 비교

컴포저블 함수에서 모든 상태를 외부로 추출하여 만들어진 컴포저블 함수는 Stateless하다고 볼 수 있다.

  • Stateless 컴포저블 : 상태를 소유하지 않는 컴포저블로 새로운 상태를 갖거나 정의하거나 수정하지 않음
  • Statefull 컴포저블 : 시간이 지남에 따라 변하는 상태를 갖는 컴포저블

모든 세부 컴포저블이 Stateless할 수는 없지만 가급적 적게 상태를 갖게끔 설계하여 상태를 호이스팅함이 바람직하다고 한다.

기존 WaterCounter 컴포저블을 리팩토링하여 StatefulCounterStatelessCounter로 구분해보자.

StatelessCounter 컴포저블은 count를 표시하고 count를 늘릴 때의 함수를 호출한다. 앞서 작성하였듯이 valueonValueChange를 정의해야 하므로 상태 변수인 count와 그 값을 증가시키는 onIncrement 람다 함수를 매개변수로 받도록 선언한다.

WaterCounter.kt - StatelessCounter

@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
if (count > 0) {
Text("You've had $count glasses.")
}
Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = count < 10) {
Text("Add one")
}
}
}

StatefulCounter 컴포저블은 상태를 소유하므로 count의 상태를 갖고 StatelessCounter컴포저블을 호출할 때 해당 상태를 매개변수로 입력하며 수정합니다.

WaterCounter.kt - StatefulCounter

@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
var count by rememberSaveable { mutableStateOf(0) }
StatelessCounter(count, { count++ }, modifier)
}

이렇게 기존 WaterCounter를 두 개의 컴포저블인 StatefulCounterStatelessCounter로 분리함으로써 상태를 성공적으로 호이스팅하였다. 이제 WaterCounter를 호출하던 코드를 수정하면 되겠다.

WellnessScreen.kt

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
StatefulCounter(modifier)
}

호이스팅 시 주의할 점

  1. 상태는 적어도 해당 상태를 사용하는 모든 커포저블의 가장 낮은 공통 상위 Element로 끌어올려야 한다. (읽기)
  2. 상태는 최소한 변경될 수 있는 가장 높은 수준으로 끌어올려야 한다. (쓰기)
  3. 두 상태가 동일한 이벤트에 대한 응답으로 변경되는 경우 두 상태는 동일한 수준으로 끌어올려야 한다.

위 규칙에 따라 상태를 적절한 수준으로 끌어올리지 않으면 단방향 데이터 흐름을 구현하지 못할 수 있다.

현재 상태를 호이스팅하느라 두 개의 컴포저블로 분리하였는데, 이 경우 몇가지 이점이 있다.

  1. Stateless 컴포저블 재사용

만약 마신 물과 함께 주스의 잔 개수도 함께 계산하려면 WaterCounter와 유사한 JuiceCounter를 생성하지 않고도 각각 waterCountJuiceCount 변수를 기억하고 StatlessCounter 컴포저블 함수를 사용하여 두 개의 독립 상태를 출력할 수 있다.

WaterCounter.kt - StatefulCounter

@Composable
fun StatefulCounter() {
var waterCount by remember { mutableStateOf(0) }
var juiceCount by remember { mutableStateOf(0) }
StatelessCounter(waterCount, { waterCount++ })
StatelessCounter(juiceCount, { juiceCount++ })
}

앱을 실행하면 컴포지션 구조는 아래 그림과 같이 waterCount를 표시하는 StatelessCounterjuiceCount를 표시하는 StatelessCounter 두 개의 컴포저블이 있다.

Android_Compose_State_Hoisting_001

만약 juiceCount가 수정되면 StatefulCounter가 재구성되며 리컴포지션 중에 Compose가 juiceCount만 변경됨을 인지하고 해당 상태를 사용하는 컴포저블 함수에 대해서만 리컴포지션을 트리거한다.

Android_Compose_State_Hoisting_002

사용자가 juiceCount를 늘리면 StatefulCounter가 재구성되고 juiceCount를 읽는 StatelessCounter도 재구성된다. 그러나, waterCount를 읽는 StatelessCounter는 재구성되지 않는다.

Android_Compose_State_Hoisting_003

  1. Stateful 컴포저블 함수는 여러 컴포저블에 동일한 상태를 제공할 수 있음
@Composable
fun StatefulCounter() {
var count by remember { mutableStateOf(0) }
StatelessCounter(count, { count++ })
AnotherStatelessMethod(count, { count *= 2 })
}

임시로 두 컴포저블이 하나의 상태롤 바라보게끔 작성하였다. 이 경우 count 변수가 StatelessCounterAnotherStatelessMethod 함수에 의해 변경되면 두 컴포저블이 하나의 공통 상태를 관찰하고 있기 때문에 모든 항목이 재구성된다.

호이스팅된 상태는 공유할 수 있으므로 리컴포지션을 방지하고 재사용성을 높이기 위해 컴포저블에 필요한 상태만 전달함이 좋다. 리컴포지션이 자주 발생하면 앱의 성능에 안좋은 영향이 발생할 수 있기 때문에 필요한 경우에만 매개변수로 상태를 입력해야 한다.

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #9 상태 호이스팅
  2. Android Developers - 상태 및 Jetpack Compose

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

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의 StateMutableState를 사용하여 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가 변경되면 countvalue를 읽는 Composable함수의 Recomposition이 예약되므로 WaterCounter Composable 함수는 버튼을 클릭할 때마다 재구성된다. 그러나, 앱을 실행해보면 아무 일도 발생하지 않는다.

Android_Remember_State_001

Recomposition 예약은 버튼 클릭 리스너로 count 변수의 value를 증가시켰는데 왜 텍스트에 변화가 발생하지 않을까?

그 이유는 WaterCounter가 재구성되면서 count 변수에 다시 mutableStateOf(0) 재할당으로 인해 0으로 초기화되었기 때문이다. 따라서, Recomposition이 발생하더라도 해당 변수의 값을 유지시킬 방법이 필요하다.

Compose State 저장

Compose에서는 Composable Inline 함수인 remember API를 지원한다. remember로 계산된 값은 Initial Composition 중에 Composition에 저장되고 저장된 값은 Recomposition 발생 시 유지된다.

remembermutableStateOf를 감싸서 변수에 할당하면 다음과 같이 사용할 수 있다.

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 키워드로 변수를 정의한다. 앱을 실행해보면 아래와 같이 동작한다.

Android_Remember_State_002

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을 수행하거나 종료할 수 있다.

Android_Remember_State_003

즉, 위 그림에 따라 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의 트리를 볼 수 있다.

Android_Remember_State_004

앱을 실행해보면 아직 count가 0이므로 Text 컴포저블이 배치되지 않았다. 이제 에뮬레이터의 버튼을 누르면 다음의 과정을 거쳐 텍스트가 생성된다.

  • countvalue 값이 1증가하면서 상태가 변경됨
  • Recomposition 예약
  • 화면이 새로운 Element로 Recompose됨

Android_Remember_State_005

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

Android_Remember_State_006

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

앱을 실행하면 아래 그림과 같이 화면이 구성된다.

Android_Remember_State_007

Element 트리는 우측 그림과 같이 표현되는데 countshowTask는 현재 기억된 값이고 count가 0인 상태이므로 버튼이 담긴 Row 컴포저블만 컴포지션에 포함된다.

이후 앱에서 Add one 버튼을 클릭해보면 count가 증가하여 리컴포지션이 발생하고 WellnessTaskItem과 물의 양을 카운트해주는 Text 컴포저블이 모두 표시된다.

Android_Remember_State_008

새로 출력된 WellnessTaskItem의 X 버튼을 누르면 showTask 변수 상태에 변화가 생겼으므로 다른 리컴포지션이 발생하고 WellnessTaskItem은 더 이상 표시되지 않는다.

Android_Remember_State_009

다시 Add one 버튼을 클릭하여 사용자가 마신 물의 양을 증가시키면 리컴포지션이 발생한다. 다만, showTask 변수는 리컴포지션이 발생하여도 false임을 기억하고 있기에 WellnessTaskItem이 출력되지 않는다.

Android_Remember_State_010

Clear water count버튼을 클릭하여 count를 0으로 만들면 리컴포지션이 발생한다. count가 0이 되었으므로 이후 count를 표시하는 Text 컴포저블과 WellnessTaskItem과 관련된 모든 코드를 호출하지 않고 컴포지션을 종료한다.

Android_Remember_State_011

showTaskremember하는 코드가 호출되지 않았으므로 앞서 설명했듯이 showTask를 저장하는 객체가 컴포지션에서 삭제되고 첫 번째 단계로 복귀한다.

Android_Remember_State_012

다시 Add one 버튼을 눌러 count를 0에서 1로 만들면 리컴포지션이 발생한다.

Android_Remember_State_013

WellnessTaskItem 컴포저블이 다시 출력됨을 확인할 수 있다. showTaskremember하는 코드가 호출되지 않아 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 사용 시 코드의 위치도 염두에 두어야 한다.

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #5 구성 가능한 함수의 메모리
  2. Google Codelab - Jetpack Compose의 상태 #6 상태 기반 UI
  3. Google Codelab - Jetpack Compose의 상태 #7 컴포지션의 Remember
  4. Android Developers - 상태 및 Jetpack Compose

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 실습하면서 배운 점을 정리한 글입니다,

Jetpack Compose의 State 코드랩Jetpack Compose State 공식문서의 내용을 정리합니다. UI를 결정짓는 State와 State 변경을 위해 트리거되는 Event에 대해 정의합니다.

Composition 관련 용어

  • 컴포지션(Composition): Jetpack Compose가 Composable 함수를 실행할 때 빌드한 UI
  • 초기 첨포지션(Initial Composition): 처음 Composable을 실행하여 Composition 생성
  • 리컴포지션(Recomposition): 상태 변경 시 Composition을 업데이트하기 위해 Composable을 다시 실행하는 과정

State란?

시간이 지남에 따라 변할 수 있는 값으로 광범위하게 정의되어 있다. Room DB 데이터부터 클래스 변수까지 포괄하기 때문이다. 요약하자면, 상태에 따라 특정 시점에 앱 UI에 표시되는 항목이 결정된다고 이해하면 된다.

  • State 예시
    • 채팅앱 최근 수신 메세지
    • 사용자의 프로필 사진
    • 목록의 스크롤 위치

State & Event & Composition

Compose는 선언형이므로 Compose 업데이트를 위해서는 새로운 인수로 동일 컴포저블을 호출해야 한다. 그리고 해당 "인수"는 UI 상태를 표현한다. 상태가 업데이트되면 재구성(Recomposition)이 발생한다.

우선 특정 카운트를 표시하는 Composable 함수를 생성하고 Activity에 설정한다.

WaterCounter.kt

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
val count = 0
Text(
text = "You've had $count glasses.",
modifier = modifier.padding(16.dp)
)
}

몇 잔의 물을 마셨는지 출력하는 텍스트 컴포저블을 정의하였다.

WellnessScreen.kt

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
WaterCounter(modifier)
}

얼마나 마셨는지 출력하는 WaterCounter 컴포저블 함수를 출력하는 Screen 함수이다.

MainActivity.kt

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// 프로젝트 이름에 따라 변경되는 Theme
BasicStateCodelabTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
WellnessScreen()
}
}
}
}
}

실행결과

Android_Jetpack_Compose_State_Event_001

물을 얼마나 마셨는지 표시하는 WaterCounter 함수는 count 변수의 상태에 따라 결정된다. 버튼을 정의해서 count 변수를 증가시켜 보자.

Compose Event

상태의 업데이트는 이벤트에 대한 응답 기반으로 이루어진다. 흔히 버튼 누르기 등 UI 요소와 사용자 간의 상호작용이나 네트워크 응답 등으로 트리거된다. 이에 공식문서는 다음과 같이 상태와 이벤트를 표현한다.

상태는 존재하고 이벤트는 발생한다.

Android는 다음 그림으로 표현되는 핵심 UI 업데이트 루프를 갖는다.

Android_Jetpack_Compose_State Event_002

  • 이벤트: 사용자 및 기타 요인에 의해 발생
  • 상태 업데이트: 이벤트 핸들러가 UI에서 사용하는 상태를 변경
  • 상태 표시: 새로운 상태를 표시하도록 UI가 업데이트 됨

사용자가 상호작용할 수 있도록 버튼을 정의하여 count 변수가 증가게끔 설정한다.

WaterCounter.kt

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count = 0
Text("You've had $count glasses.")
Button(
onClick = { count++ },
Modifier.padding(top = 8.dp)
) {
Text("Add one")
}
}
}

사용자가 이벤트를 발생시키도록 버튼을 정의하였고 클릭 시 상태인 count 변수를 증가시켰으나 아무 일도 일어나지 않는다.

Android_Jetpack_Compose_State Event_003

count 변수에 다른 값이 설정되어도 Compose에서 해당 변수의 값 변경을 "상태 변경"으로 감지하지 않았기에 UI 업데이트(컴포저블 함수의 재구성 즉, Recomposition)가 일어나지 않는다. 상태가 변경될 때 Compose에 화면을 다시 그려야 한다고 알리지 않았기 때문이다.

참고자료

  1. Google Codelab - Jetpack Compose의 상태 #3 Compose에서의 상태
  2. Google Codelab - Jetpack Compose의 상태 #4 Compose에서의 이벤트
  3. Android Developers - 상태 및 Jetpack Compose

9월 24일 토요일에 치뤄졌다. 오후에 14시부터 19시까지 5시간 동안 진행되었다. 작년에 LINE 코테와 중복된 날째로 진행된 것과는 달리 다른 날에 열렸다. 총 7문제로 난이도는 역시 카카오는 카카오. 이미 합격해서 간절함이 없어서일까 3솔로 마무리했다. 예상 합격컷은 4솔

1번은 문제를 저수준으로 치환할 수 있으면 바로 풀리는 문제였다. 데이터를 억지로 주어진 대로만 가공하려 했다면 꽤나 많은 시간을 소요했을 것이다. 2번은 그?리디 느낌이 나는 반복문 문제였다. 1번과 2번을 해결하면서 느낀 점은 더 이상 12번에서 개날먹 문제를 던져주지는 않는다는 점이다. 이전까지는 말 그대로 지문을 읽으면 슉 해결되었다면 지금은 지문을 읽고 이해한 뒤 나름대로의 해석을 해야 코드를 작성할 수 있다. 확실히 고급 문제에서 난이도를 올리기보단 초급 문제를 조금 어렵게 출제하여 컷을 조금 높히겠다는 느낌이 들었다. 3번은 완전탐색인데 python유저가 아니면 꽤나 애를 먹었겠다 싶은 문제가 나왔다. 필자도 문제를 3차원 배열로 치환해서 생각하느라 조금 헤맸다.

4번부터 사실상 카카오 코테의 메인디쉬인데 이미 직장에 다니고 있는 중이라서 그런가 피지컬이 떨어져 제대로 먹지도 못하고 테스트를 종료했다. 문제 유형은 늘 그렇듯이 dp, 트리 등 고급 알고리즘 기법 위주로 나왔다. 4번은 포화 이진트리라는 점과 중위탐색이라는 점을 참고해서 실마리가 비교적 빨리 잡히나 싶었는데 테스트케이스만 통과하고 본 케이스는 통과되지 못했다. 아마 문제에 대해 제대로 이해하지 못한 결과였으리라. 사실 난이도가 너무 확 올라가서 흥미도 식고 실력도 조금 떨어진 게 체감이 되었다. 가끔은 문제를 풀어주긴 해야겠다.

결론적으로 초급문제는 자료구조에 대한 이해, 문제를 분할하고 긴 호흡의 지문을 소화할 수 있는 역량이 중요했다. 고급문제는 여전히 카카오 공채의 난이도가 상당함을 보여줬고 고급 알고리즘을 얼마나 자유자재로 변형해서 적용할 수 있는지를 물어봤다는 생각이 든다.

체감상 123번은 반드시 해결을 해야만 했고 4번을 해결하거나 5번의 정확성 파트를 해결해야 2차 코테 응시 기회가 주어지지 않을까 싶다.

이번 네이버 하반기 공채 코테도 꽤나 어려운 거 같던데 아무래도 자료구조 구현이나 기본 알고리즘은 외워서 적용하면 풀리는 문제들이 나오지 않아서인지 점점 난이도가 올라가는 느낌이다. 다만, 코테에서 문해력의 중요성이 더 올라가는 흐름은 환영할 만한 일이다. 이제 코테 준비하려면 책도 읽어둬야 하는 시대가 올지도 모르겠다 ㅋㅋ

+ Recent posts