읽기 전

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

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

+ Recent posts