이 글은 JetpackCompose 공식문서 - 부수 효과를 읽어보면서 정리한 글 입니다.
컴포저블에는 부수 효과(Side-effect)가 없어야 합니다. 하지만 Compose의 동작이 외부의 상태를 변경해야 경우 컴포저블의 수명 주기를 인식하는 관리된 환경에서 부수 효과를 호출해야 합니다.
부수효과란 Composable 함수의 범위 밖에서 앱 상태에 대한 변경사항을 뜻합니다. Composable에는 부수효과가 없어야 하지만, 상태를 변경할 때 필요한 경우가 있습니다. 이번글에서는 Composable에서의 부수 효과에 대한 API들에 대해 정리해보도록 하겠습니다😌.
부수 효과를 사용할 때는 부수 효과가 예측 가능한 방식으로 실행될 수 있도록 Effect API를 사용해야합니다.
Effect: UI를 내보내지 않으며 컴포지션이 완료될 때 부수효과를 실행하는 Composable함수
Composable에서 안전하게 정지 함수(Suspend)를 호출하려면 LauncedEffect Composable을 사용해야합니다.
@Composable
fun MyScreen(
state: UiState<List<Movie>>,
scaffoldState: ScaffoldState = rememberScaffoldState()
) {
if (state.hasError) {
LaunchedEffect(scaffoldState.snackbarHostState) {
scaffoldState.snackbarHostState.showSnackbar(
message = "Error message",
actionLabel = "Retry message"
)
}
}
Scaffold(scaffoldState = scaffoldState) {
/* ... */
}
}
위 함수에서 state.hasError이 true면 코루틴(showSnackbar)이 트리거 되고, 그렇지 않으면 코루틴이 취소됩니다.
LaunchedEffect는 Composable의 수명 주기를 따릅니다.
실행시점 | 종료시점 | 재실행되는 조건 |
---|---|---|
Composition Enter | Composition Leave | param의 변경 |
LaunchedEffect의 매개변수에 넣은 값이 변경되면 기존 코루틴을 종료하고 새 코루틴에서 suspend 함수를 실행합니다.
LaunchedEffect는 composable 함수이기 때문에 Composable 함수 내에서만 호출할 수 있습니다. 그래서 Composable 이외의 부분에서 coroutine scope를 얻기 위해서는 rememberCoroutineScope를 사용해야합니다.
rememberCoroutineScope는 호출한 Composable의 수명주기에 따라 시작/종료됩니다.
실행시점 | 종료시점 | 재실행되는 조건 |
---|---|---|
Composition Enter | Composition Leave | X |
@Composable
fun Screen(scaffoldState: ScaffoldState = rememberScaffoldState()) {
val scope = rememberCoroutineScope()
Scaffold(scaffoldState = scaffoldState) {
Column {
Button(
onClick = {
scope.launch { // snackBar를 표시하기 위해 새로운 coroutine을 생성합니다.
scaffoldState.snackbarHostState.showSnackbar("Something happened!")
}
}
) {
Text("Press me")
}
}
}
}
Screen의 Composition이 종료되면 scope에 있는 coroutine도 취소됩니다. 따라서 Screen을 떠나면 snackbar도 취소됩니다.
LaunchedEffect는 내부 블록의 값이 변경되면 기존 coroutine을 취소하고 새로운 coroutine을 실행합니다.
LaunchedEffect 내부의 값을 변경해야하지만 새로운 coroutine을 실행하지 않아야 하거나 effect가 오랜시간 걸릴 때 작업을 유지해야 할 경우rememberUpdatedState를 사용합니다.
@Composable
fun ShowSnackBar(scaffoldState: ScaffoldState = rememberScaffoldState(), timeoutText: String) {
val currentTimeoutText by rememberUpdatedState(timeoutText)
LaunchedEffect(true) {
try {
delay(3000L)
scaffoldState.snackbarHostState.showSnackbar(currentTimeoutText)
} catch (ce: CancellationException) {
}
}
}
timeText가 변하더라도 LaunchedEffect는 재시작하지 않고(매개변수로 true를 고정해두었기 때문) 3초 이후에 snackbar를 띄웁니다.
만약 rememberUpdatedState를 사용하지 않는다면 timeoutText가 변해도 똑같은 Snackbar가 표시될 것 입니다.(lambda capturing으로 final형태로 값이 들어가기 때문)
DisposableEffect는 LaunchedEffect와 동작이 동일합니다. 하지만 DinsposableEffectsms Compose leave로 종료할 때 항상onDispose{..} 가 호출됩니다.
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit,
onStop: () -> Unit
) {
// 새로운 람다식이 들어왔을 때 안전하게 실행할 수 있도록 rememberUpdatedState를 사용합니다.
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// lifecycleOwner가 변경되면 DisposableEffect내에 있는 코루틴이 다시 시작됩니다.
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
// Effect가 Composition을 떠날 때 observer를 삭제합니다.
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
/* Home screen content */
}
DisposableEffect는 onDispose 절을 코드 블록의 최종 문장으로 적어둬야합니다. 그러지 않으면 IDE에 오류가 표시됩니다.
Compose에서는 State<T>
를 통해 상태를 관찰하고 Composable을 업데이트 합니다. 따라서 Flow
, LiveData
, RxJava
등의 관찰가능한 타입들을 Compose에서 사용하려면 State로 변환하는 과정이 필요한데, produceState가 그 역할을 합니다.
produceState는 관찰하고 있는 값이 변경되면 State<T>
를 반환합니다.
Compose에서는 Flow, LiveData를 위해 State로 변환하는 함수를 지원합니다.
LiveData<T>
: observeAsState(initial: R) observeAsState를 사용하기 위해서는 gradle에
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
를 추가해야합니다.
Flow<T>
: collectAsState(initial: R, context: CoroutineContext)이 함수는 다른 State에서 파생된 State를 원할 때 사용됩니다.
@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {
val todoTasks = remember { mutableStateListOf<String>() }
val highPriorityTasks by remember(highPriorityKeywords) {
derivedStateOf { todoTasks.filter { it.containsWord(highPriorityKeywords) } }
}
Box(Modifier.fillMaxSize()) {
LazyColumn {
items(highPriorityTasks) { /* ... */ }
items(todoTasks) { /* ... */ }
}
}
}
위 코드에서
derivedStateOf {}
는 todoTasks가 변경될 때 마다 highPriorityTask의 계산이 실행되고, UI가 업데이트됨을 보장합니다.
highPriorityTasks를 계산하는 비용이 많이 들기 때문에 highPriorityKeywords가 변경될 때만 계산해야합니다. 따라서 highPriorityTask를 derivedStateOf로 묶었습니다.
snapshotFlow를 사용하여 State<T>
객체를 콜드 Flow로 변환합니다.
snapshotFlow 블록 내에서 읽은 State 객체 하나가 변경되었을 때 새 값이 이전에 내보낸 값과 같지 않은 경우 Flow에서 새 값을 수집기에 내보냅니다.
val listState = rememberLazyListState()
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } // Flow<T>를 반환합니다.
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
LaunchedEffect
,produceState
,DisposableEffect
와 같은 Compose의 일부 효과는 실행중인 효과를 취소하고 새로운 효과를 실행하기 위해 여러개의 매개변수를 받습니다. 매개변수가 변경되면 새로운 효과가 실행됩니다.
효과를 다시 실행할 때는 다음 두가지를 고려해야합니다.
변수를 변경해도 효과가 다시 시작되지 않아야 하는 경우 변수를 rememberUpdatedState에 래핑해야 합니다.
효과에 사용되는 변수는 효과 컴포저블의 매개변수로 추가하거나 rememberUpdatedState를 사용해야 합니다.
키에는 상수(true등)를 넣을 수 있습니다. 이렇게 되면 LaunchedEffect의 경우 단 한번만 coroutine을 실행하게 됩니다. 따라서 상수를 사용할 때는 신중해야합니다.
이렇게 해서 Compose의 효과에 대해 알아보았습니다. 다음 글에서는 Compose의 단계에 대해 다뤄보도록 하겠습니다. 끝까지 읽어주셔서 감사하고 즐거운 개발 되세요🤗.