목차- 1. Composable의 라이프사이클
-
- Composable의 부수 효과
- 2.1 SideEffect
- 2.2 DisposableEffect
- 2.3 LaunchedEffect
- 2.4 rememberCoroutineScope
- 2.5 rememberUpdatedState
- 2.6 derivedStateOf
- 2.7 snapshotFlow
- 2.8 produceState
-
- 요약
- Composable의 라이프사이클 ===================
Composable 컴포넌트는 모두 함수이며, Composable 함수 실행 시 뷰 트리가 생성됩니다. 각 Composable 컴포넌트는 뷰 트리의 노드에 해당합니다. Composable의 라이프사이클은 다음과 같이 정의됩니다:
- onActive(뷰 트리에 추가) Composable이 처음 실행될 때, 즉 뷰 트리에 해당 노드가 생성될 때
- onUpdate(재구성) Composable이 재구성에 따라 반복적으로 실행되어 뷰 트리의 노드를 업데이트
- onDispose(뷰 트리에서 제거) Composable이 더 이상 실행되지 않을 때, 해당 노드가 뷰 트리에서 제거될 때
Compose로 UI를 작성할 때, 페이지의 변화는 상태 변화에 의존하며, Composable은 재구성을 통해 다른 페이지를 렌더링합니다. 페이지가 표시될 때 해당 노드가 뷰 트리에 추가되고, 페이지가 표시되지 않을 때 해당 노드가 뷰 트리에서 제거됩니다. 따라서 Activity에는 전면/후면의 개념이 있지만, Compose로 작성된 페이지에서는 Composable에 전면/후면 전환 개념이 없습니다. 페이지가 표시되지 않게 되면 해당 노드는 즉시 파괴되며, Activity나 Fragment처럼 백그라운드에서 인스턴스를 유지하지 않습니다.
- Composable의 부수 효과 ==================
이전 재구성 관련 글에서 언급했듯이, Composable 재구성 과정은 반복적으로 실행되며 중간에 중단될 수 있으며, 마지막 실행 상태만 보장됩니다. 만약 Composable 함수에서 Toast를 표시한다면, Composable이 재구성될 때 이 Toast가 몇 번이나 표시될지 제어할 수 없을 것입니다. 또한 Composable 함수 외부의 변수를 읽고 쓰거나, 파일을 읽고 쓰거나, 네트워크를 요청하는 등의 작업은 보장받을 수 없을 것입니다. 이와 같이 Composable 실행 과정에서 외부에 영향을 미치는 모든 작업은 부수 효과(side effect)에 해당합니다. Composable 재구성 과정에서 이러한 부수 효과는 예측 가능성을 보장받기 어렵습니다. 이 문제를 해결하기 위해 Compose는 특정 라이프사이클 단계에서만 부수 효과가 발생하도록 보장하는 일련의 부수 효과 함수를 제공합니다. 아래에서 이러한 부수 효과 함수의 사용 사례를 살펴보겠습니다.
2.1 SideEffect
SideEffect는 성공적인 재구성이 발생할 때마다 실행됩니다. Composable은 재구성 과정에서 반복적으로 실행되지만, 재구성이 항상 성공하는 것은 아닙니다. 일부는 중단되거나 실패할 수 있습니다. SideEffect는 재구성이 성공했을 때만 실행됩니다.
특징:
- 재구성이 성공해야만 실행됩니다.
- 여러 번 실행될 수 있습니다. 따라서 SideEffect 함수는 시간이 오래 걸리는 작업이나 단 한 번만 실행되어야 하는 작업에는 사용할 수 없습니다.
대표적인 사용 사례로 테마에서 상태 표시줄, 내비게이션 바 색상 설정 등이 있습니다.
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
2.2 DisposableEffect
DisposableEffect는 Composable의 onActive와 onDispose를 감지하여, 이 함수를 사용하여 사전 처리 및 정리 작업을 수행할 수 있습니다.
대표적인 사용 사례로 등록 및 등록 취소가 있습니다:
DisposableEffect(vararg keys: Any?) {
// register(callback)
onDispose {
// unregister(callback)
}
}
여기서 keys 매개변수는 keys가 변경될 때 DisposableEffect가 다시 실행됨을 의미합니다. 전체 라이프사이클 동안 한 번만 실행하려면 Unit을 전달하면 됩니다.
onDispose 코드 블록은 Composable이 onDispose에 진입할 때 실행됩니다.
2.3 LaunchedEffect
LaunchedEffect는 Composable에서 코루틴을 시작하는 데 사용됩니다. Composable이 onActive에 진입하면 LaunchedEffect는 자동으로 코루틴을 시작하여 block의 코드를 실행합니다. Composable이 onDispose에 진입하면 코루틴은 자동으로 취소됩니다. 사용 방법:
LaunchedEffect(vararg keys: Any?) {
// 비동기 작업 수행
}
마찬가지로 관찰 가능한 매개변수를 지원하며, key가 변경되면 현재 코루틴이 자동으로 종료되고 새 코루틴이 시작됩니다.
2.4 rememberCoroutineScope
LaunchedEffect는 Composable에서만 호출할 수 있습니다. 만약 Composable이 아닌 환경에서 코루틴을 사용하고 싶다면, 예를 들어 Button의 OnClick에서 코루틴을 시작하고 Composable이 onDispose에 진입할 때 자동으로 취소되기를 원한다면 rememberCoroutineScope를 사용할 수 있습니다. 사용법은 다음과 같습니다:
@Composable
fun Test() {
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
// 작업 수행
}
}
) {
Text("클릭하세요")
}
}
DisposableEffect와 rememberCoroutineScope를 함께 사용하면 LaunchedEffect와 동일한 효과를 얻을 수 있지만, 일반적으로 이렇게 하는 것은 의미가 없습니다.
2.5 rememberUpdatedState
rememberUpdatedState는 일반적으로 DisposableEffect나 LaunchedEffect와 함께 사용됩니다. DisposableEffect나 LaunchedEffect를 사용할 때 코드 블록에서 사용하는 값이 외부에서 업데이트될 경우, 최신 값을 어떻게 얻을 수 있을까요? 예를 들어, 게임에서 캐릭터를 미리 선택하고 10초 후에 최종 선택한 캐릭터를 표시하는 상황을 생각해 봅시다. 이때 선택한 캐릭터를 변경할 수 있습니다.
@Composable
fun ChooseCharacter() {
var selectedCharacter by remember {
mutableStateOf("티모")
}
Column {
Text(text = "선택한 캐릭터: $selectedCharacter")
Button(onClick = {
selectedCharacter = "가렌"
}) {
Text(text = "변경: 가렌")
}
FinalSelection(selectedCharacter)
}
}
@Composable
fun FinalSelection(character: String) {
var message by remember {
mutableStateOf("게임 시작까지: 10s")
}
LaunchedEffect(key1 = Unit) {
delay(10000)
message = "최종 선택한 캐릭터: $character"
}
Text(text = message)
}
코드 실행 결과는 다음과 같습니다:
우리는 티모를 선택했지만, 카운트다운 중에 버튼을 클릭하여 가렌으로 변경했음에도 불구하고, 최종 선택한 캐릭터는 티모로 표시됩니다.
원인 분석: FinalSelection의 매개변수 character는 외부에서 전달되며, 그 값이 변경되면 재구성이 트리거되지만, LaunchedEffect 함수의 key가 Unit으로 설정되어 있어 재구성 과정에서 코루틴 코드 블록이 다시 실행되지 않아 외부의 변경을 감지하지 못합니다. 외부의 최신 값을 얻기 위해 한 가지 방법은 character를 LaunchedEffect의 관찰 가능한 매개변수로 사용하는 것입니다. 코드를 다음과 같이 수정합니다:
@Composable
fun FinalSelection(character: String) {
var message by remember {
mutableStateOf("게임 시작까지: 10s")
}
LaunchedEffect(key1 = character) {
delay(10000)
message = "최종 선택한 캐릭터: $character"
}
Text(text = message)
}
이제 다시 실행하면 카운트다운 중에 버튼을 클릭하여 캐릭터를 변경하면 결과가 정상적으로 표시되며, 최종 선택한 캐릭터는 가렌이 됩니다. 하지만 이 방법은 우리의 요구 사항에 맞지 않습니다. 앞서 설명했듯이, LaunchedEffect의 매개변수 key가 변경되면 코루틴이 취소되고 새 코루틴이 시작됩니다. 이는 카운트다운 중에 key를 변경하면 재시작된 코루틴이 변경된 값을 얻을 수 있지만, 카운트다운도 다시 시작된다는 의미이므로 이는 우리가 원하는 결과가 아닙니다.
rememberUpdatedState는 이러한 상황을 해결하기 위해 사용됩니다. 코루틴을 중단하지 않으면서 항상 최신 값을 얻을 수 있도록 합니다. rememberUpdatedState의 사용법을 살펴보겠습니다.
LaunchedEffect의 매개변수 key를 다시 Unit으로 되돌리고, rememberUpdatedState를 사용하여 currentCharacter를 정의합니다.
@Composable
fun FinalSelection(character: String) {
var message by remember {
mutableStateOf("게임 시작까지: 10s")
}
val currentCharacter by rememberUpdatedState(newValue = character)
LaunchedEffect(key1 = Unit) {
delay(10000)
message = "최종 선택한 캐릭터: $currentCharacter"
}
Text(text = message)
}
이렇게 하면 실행 결과가 우리의 예상과 일치합니다.
2.6 derivedStateOf
위의 예제에서는 게임 카운트다운 시간이 업데이트되지 않는다는 불완전한 점이 있습니다. 이 기능을 derivedStateOf를 사용하여 개선해 보겠습니다.
@Composable
fun FinalSelection(character: String) {
var time by remember {
mutableIntStateOf(10)
}
val message by remember {
derivedStateOf {
"게임 시작까지: ${time}s"
}
}
LaunchedEffect(key1 = Unit) {
repeat(10) {
delay(1000)
time--
}
}
Text(
text = if (time == 0) {
"최종 선택한 캐릭터: $character"
} else {
message
}
)
}
이제 훨씬 좋아졌습니다. 여기서는 더 이상 rememberUpdatedState가 필요 없습니다. 먼저 시간을 정의하고, 이는 Int 타입의 State입니다. 그런 다음 derivedStateOf를 사용하여 message를 정의합니다. 이는 String 타입의 State입니다.
derivedStateOf의 역할은 하나 이상의 State에서 다른 State를 파생시키는 것입니다. 특정 상태가 다른 상태 객체에서 계산되거나 파생된 경우, derivedStateOf를 사용할 수 있습니다. 이 함수를 사용하면 계산에 사용된 상태 중 하나가 변경될 때만 계산이 수행되도록 보장할 수 있습니다.
derivedStateOf의 사용은 어렵지 않지만, remember와 함께 사용하면 다양한 시나리오에 적응할 수 있는 많은 활용법이 있습니다. 주요 초점은 재구성을 트리거하는 조건에 있으며, 이는 실제 시나리오와 성능을 종합적으로 고려하여 key를 사용하여 재구성을 트리거할지 상태 참조 변경을 통해 트리거할지 결정해야 합니다.
2.7 snapshotFlow
앞서 rememberUpdatedState를 사용하면 LaunchedEffect에서 항상 외부 상태의 최신 값을 얻을 수 있습니다. 하지만 상태 변화를 감지할 수는 없습니다. 즉, 외부 상태가 변경되어도 LaunchedEffect의 코드가 즉시 알림을 받지 못합니다. snapshotFlow를 사용하면 이 문제를 해결할 수 있습니다.
snapshotFlow는 State<T>를 코루틴의 Flow로 변환하는 데 사용됩니다. snapshotFlow 블록에서 읽은 State 객체 중 하나가 변경될 때, 새 값이 이전에 발행된 값과 다르면 Flow가 수집기에 최신 값을 발행합니다(이 동작은 Flow.distinctUntilChanged과 유사합니다).
사용 방법을 살펴보겠습니다:
@Composable
fun FinalSelection(character: String) {
var time by remember {
mutableIntStateOf(10)
}
var message by remember {
mutableStateOf("게임 시작까지: 10s")
}
LaunchedEffect(key1 = Unit) {
launch {
repeat(10) {
delay(1000)
time--
}
}
launch {
snapshotFlow { time }.collect {
message = "게임 시작까지: ${it}s"
}
}
}
Text(
text = if (time == 0) {
"최종 선택한 캐릭터: $character"
} else {
message
}
)
}
실행 결과는 이전과 동일합니다. 여기서는 더 이상 derivedStateOf를 사용하지 않고, 두 개의 코루틴을 시작합니다. 하나의 코루틴은 카운트다운을 처리하고, 다른 코루틴은 time이라는 State를 Flow로 변환한 후 수집하여 message를 업데이트합니다.
2.8 produceState
produceState는 임의의 외부 데이터 소스를 State로 변환하는 데 사용됩니다.
예를 들어, 위의 예제에서 카운트다운 시간을 ViewModel에 정의하고, 카운트다운 로직을 ViewModel에서 구현하면, UI에서 produceState를 사용하여 이를 구현할 수 있습니다.
@Composable
fun FinalSelection(character: String) {
val time = viewModel.time
val message by produceState<String>(initialValue = "게임 시작까지: 10s") {
value = "게임 시작까지: ${time}s"
awaitDispose {
// 정리 작업 수행
}
}
Text(
text = if (time == 0) {
"최종 선택한 캐릭터: $character"
} else {
message
}
)
}
produceState의 소스 코드 구현을 살펴보겠습니다:
@Composable
fun <T> produceState(
initialValue: T,
vararg keys: Any?,
producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val result = remember { mutableStateOf(initialValue) }
@Suppress("CHANGING_ARGUMENTS_EXECUTION_ORDER_FOR_NAMED_VARARGS")
LaunchedEffect(keys = keys) {
ProduceStateScopeImpl(result, coroutineContext).producer()
}
return result
}
이해하기 쉽습니다. State를 정의하고 코루틴을 시작하여 코루틴에서 State의 값을 업데이트합니다. 매개변수 key가 변경되면 코루틴이 취소되고 새로 시작되어 새로운 State가 생성됩니다.
또한 produceState에서 awaitDispose{ } 메서드를 사용하여 정리 작업을 수행할 수 있다는 점에 주목하세요. 이는 callbackFlow의 사용 사례와 매우 유사합니다. 예를 들어, BroadcastReceiver, ContentProvider, 네트워크 요청 등과 같은 콜백 기반 인터페이스 구현을 callbackFlow를 사용하여 코루틴의 Flow로 쉽게 변환할 수 있으며, produceState를 통해 Compose의 State로 변환할 수 있습니다.
val currentPerson by produceState<Person?>(null, viewModel) {
val disposable = viewModel.registerPersonObserver { person ->
value = person
}
awaitDispose {
disposable.dispose()
}
}
네트워크 요청 예제를 살펴보겠습니다:
@Composable
fun GetApi(url: String, repository: Repository): Recomposer.State<Result<Data>> {
return produceState(initialValue = Result.Loading, url, repository) {
val data = repository.load(url)
value = if (data == null) {
Result.Error
} else {
Result.Success(data)
}
}
}
- 요약 ====
본 글에서는 Composable의 라이프사이클과 일반적인 부수 효과 함수를 소개했습니다. 재구성 과정에서는 부수 효과가 발생하는 것을 최대한 피해야 합니다. 시나리오에 따라 적절한 부수 효과 함수를 사용하세요.