읽기 전

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

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차 코테 응시 기회가 주어지지 않을까 싶다.

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

읽기 전

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

안드로이드에 대해 공부해본 적 없는 안드로이드 개발자가 해당 포스팅을 작성합니다. 구글 안드로이드 개발자 문서와 몇몇 블로그, 도서를 참고하여 작성하였습니다. 이번 포스팅에서는 안드로이드의 액티비티가 무엇이고 생명주기는 어떻게 구성되는지 정리합니다.

Activity란?

Android의 주요 컴포넌트로 main 메소드를 사용하여 프로그램을 실행하던 방식과 달리 Android는 수명 주기 단계에 따라 특정 콜백 함수를 호출하여 Activity를 실행한다.

Activity의 개념

Activity는 앱과 사용자의 상호작용을 위한 진입점으로 앱이 UI를 그리는 창을 제공한다. 따라서, 기능 단위로 Activity가 나뉘는 경향을 볼 수 있는데 최근엔 Single Activity로 구현하되 화면은 Fragment로 구현하는 방식을 권장한다. Fragment는 차후 다루기로 하자.

Activity에서 다른 Activity를 실행할 수 있는데 같은 앱뿐만 아니라 다른 앱의 Activity도 실행할 수 있다. 필요에 따라 다른 앱 전체를 시작하지 않고 특정 Activity 실행이 가능하다.

Activity의 등록

앱이 Activity를 사용하려면 AndroidManifest.xml에 몇몇 속성을 선언해야 한다.

Activity 선언

    <manifest ... >
      <application ... >
          <activity android:name=".ExampleActivity" />
          ...
      </application ... >
      ...
    </manifest >

필수 항목은 Activity의 이름을 정의하는 name 속성이다. 그외 별도 테마, 런쳐 Activity 여부 등 다른 속성들도 지정할 수 있다.

Intent Filter 선언

    <activity android:name=".ExampleActivity" android:icon="@drawable/app_icon">
        <intent-filter>
            <action android:name="android.intent.action.SEND" />
            <category android:name="android.intent.category.DEFAULT" />
            <data android:mimeType="text/plain" />
        </intent-filter>
    </activity>

외부 앱으로부터 신호를 받아 특정 액션을 취할 수 있게끔 만들어주는 Intent Filter도 정의할 수 있다.

Permission 선언

    <manifest>
    <activity android:name="...."
       android:permission=”com.google.socialapp.permission.SHARE_POST”
    />

Activity의 동작이 특정 권한을 요구하는 경우 위와 같이 정의할 수 있다. 특정 Activity가 다른 앱으로부터 실행되어야 하는 경우 이렇게 권한을 설정했다면 다른 앱 또한 호출하고자 하는 Activity와 동일한 권한을 선언한 상태여야 한다.

    <manifest>
       <uses-permission android:name="com.google.socialapp.permission.SHARE_POST" />
    </manifest>

Activity Life Cycle

  • 액티비티 생명주기(life cycle) : 액티비티가 생성되어 소멸하기까지의 과정을 말하며, Activity 클래스는 액티비티의 상태를 관측, 조회할 수 있도록 여러 콜백 함수를 제공한다. 콜백 함수에 액티비티의 상태가 바뀔 때 앱이 어떻게 동작해야 하는지 구현한다.
    액티비티의 상태는 동작을 기준으로 크게 3가지로 구분한다.
  1. 활성 : 액티비티 화면이 출력되고 있으며 사용자가 이벤트를 발생시킬 수 있음
  2. 일시 정지 : 액티비티의 화면이 출력되고 있으나 사용자가 이벤트를 발생시킬 수 없음
  3. 비활성 : 액티비티의 화면이 출력되고 있지 않은 상태

액티비티의 시작, 중지, 종료까지의 일련의 과정을 도식화한 그림이다.

Android_Activity_LifeCycle_001.png

entire lifetime

액티비티가 처음 생성될 때 첫 번째로 호출되는 onCreate()부터 마지막에 파기되면서 한 번 호출되는 onDestroy()까지를 의미한다. onCreate()호출 시 액티비티는 global하게 상태를 설정하며 onDestroy() 호출 시 보유한 자원을 모두 release한다. 예를 들어, 액티비티가 네트워크로부터 데이터를 다운로드 받는 백그라운드 스레드를 갖고있다면 해당 스레드는 onCreate() 호출 시 생성되고 onDestroy() 호출 시 소멸한다.

visible lifetime

onStart()가 호출되고나서 onStop()이 호출될 때까지를 의미한다. 해당 기간 동안 사용자는 상호작용 가능 여부와는 상관없이 화면에서 액티비티를 볼 수 있다. 두 메소드 사이에서 개발자는 액티비티에 보여줄 리소스를 유지할 수 있으므로 onStart()호출 시 BroadcastReceiver를 등록해 UI를 변경하면서 onStop()이 호출되면 더 이상 화면에 보여지지 않기 때문에 UI를 갱신하기 위한 BroadcastReceiver를 해제할 수 있다. 이처럼 액티비티가 보여지거나 보여지지 않는 등 상황에 따라 여러 번 호출될 수 있으므로 개발자가 적절하게 사용할 수 있다.

foreground lifetime

onResume() 호출부터 onPause() 호출까지를 의미한다. 이 기간 동안 사용자는 액티비티를 볼 수 있고 액티비티가 동작하며 서로 상호작용 할 수 있다. 액티비티는 단말기가 수면상태로 진입하거나, 새로운 인텐트를 받는 등 자주 paused와 resumed 상태로 전환되므로 onPause(), onResume() 메소드에는 가급적 무거운 작업을 넣지 말아야 한다.

액티비티 콜백 메소드

onCreate()

액티비티 생성 시 최초로 호출되는 콜백 메소드이며 반드시 구현해야 하므로 UI를 표시하는 setContentView()를 여기서 호출한다. onCreate()가 완료되면 다음 콜백은 항상 onStart()이며 처음 실행된 액티비티는 onCreate() -> onStart() -> onResume()메소드까지 호출된다.

onStart()

onCreate()가 종료되고 액티비티는 ‘시작’ 상태로 전환되며 사용자에게 표시된다. 해당 콜백은 액티비티가 foreground로 나오고 상호작용하기 위해 준비하는 작업을 수행한다.

onResume()

액티비티가 사용자와 상호작용하기 직전 호출된다. onResume() 콜백이 호출된 시점에선 액티비티는 액티비티 스택 맨 위에 위치하며 모든 사용자 입력을 받는다. 따라서, 대부분 앱의 기능은 onResume() 메소드로 구현한다.

onPause()

액티비티가 포커스를 잃어 사용자와 상호작용을 할 수 없는 ‘일시중지’ 상태로 전환될 때 시스템이 호출한다. 예를 들어, backward 혹은 recent tray 버튼을 클릭한다던지 아니면 dialog가 발생해서 반투명하게 변할 때 발생한다. 만약 유저에게 UI 업데이트를 제공한다면 포커스를 잃는 ‘일시중지’ 상태에서도 계속 업데이트할 수 있다. 다만, 앱 혹은 사용자 데이터를 저장하거나 네트워크를 호출하거나 데이터베이스 트랜잭션 실행 시 onPause() 메소드에서 처리하면 안된다.

  • onPause()에서 상태 저장 및 복원을 하면 안되는 이유
    onPause()의 실행 시간은 굉장히 짧기에 저장 및 요청 작업을 처리하기엔 시간이 부족할 수 있다. 따라서, 메소드 실행이 끝나기 전에 완료되지 못할 수 있다. 네트워크 요청이나 DB 트랜잭션 실행, 데이터 저장은 실행 시간이 보장되어야 하는 경우가 있는데 이처럼 부하가 큰 종료 작업은 액티비티가 비활성 상태로 전환된 이후 실행되는 onStop() 메소드에서 처리해야 한다.
    onPause() 작업이 끝나고서 호출되는 다음 콜백은 ‘일시중지’ 상태로 전환된 뒤 발생하는 상황에 따라 onStop() 혹은 onResume()이다.

onStop()

액티비티가 더 이상 유저에게 표시되지 않으면 시스템은 onStop()을 호출한다. 현재 액티비티를 제거하거나, 새로운 액티비티를 시작하거나, 기존 Stopped 액티비티가 Resume되어 재진입하게 되는 상황에서 중지 작업을 수행한다.
액티비티가 사용자에게 보여지지 않는 상태이므로 필요없는 리소스를 해제하거나 조정해야 한다. 애니메이션을 일시적으로 중단하거나 위치 정보 업데이트 룰을 세밀함에서 대략적으로 변경한다는 방법을 취할 수 있다.
이후 호출되는 콜백은 액티비티가 다시 사용자와 상호작용을 위해 시작되면 onRestart()이며 액티비티가 완전히 종료되면 onDestroy()이다.

  • 멀티 윈도우 환경에서 액티비티의 UI 활동 보장
    멀티 윈도우에서는 두 개의 앱이 한 화면에 동시에 위치한다. 그리고 하나의 앱과 상호작용 하는 동안 다른 앱은 아직 전면에 노출된 상태이므로 onPause 상태가 된다. 만약 onPause()에서 자원을 release하면 앱이 화면에 있음에도 정상적으로 동작할 수 없다. 따라서, 멀티 윈도우 환경을 고려한다면 자원 release를 onStop()에서 정의하라고 가이드를 제시하고 있다. 다만, 대부분의 경우엔 onStop이나 onDestroy에서 자원을 release하고 있기에 크게 주요한 지식은 아니나 참고사항으로 알아두면 좋겠다. 추후 폴더블 기기등 멀티태스킹 환경에서의 수명 주기도 다뤄볼 예정이다.

onRestart()

‘중지’ 상태의 액티비티를 다시 시작할 때 호출되는 콜백 메소드이다. 액티비티가 중지된 시점부터의 상태를 복원하는 역할을 수행한다. 해당 콜백 뒤에는 항상 onStart() 메소드가 호출된다.

onDestroy()

액티비티가 제거되기 전에 호출되는 콜백이다. 호출되는 시나리오는 크게 두 가지로 요약할 수 있다.

  1. 사용자가 액티비티를 완전히 제거하거나 finish()가 호출되어 종료되는 경우
  2. 구성 변경(기기 회전, 멀티 윈도우 모드로 인한 창 크기 변경)으로 인해 시스템이 일시적으로 액티비티를 소멸시키는 경우
    onDestroy() 메소드는 액티비티가 마지막으로 수신하는 콜백으로 일반적인 경우, 액티비티와 액티비티가 포함된 프로세스가 제거될 때 액티비티의 모든 리소스를 release하도록 구현된다.
    다만, 구성 변경으로 인해 액티비티를 종료하는 경우 즉시 새로운 액티비티 인스턴스를 생성하므로 새로운 구성을 가지고 새로운 인스턴스에 관해 onCreate()를 호출한다.

참고자료

  1. Android Developers - The Activity Lifecycle
  2. Android Developers - Introduction to Activities

+ Recent posts