[Jetpack Compose] 공식문서 읽기 Side-effects in Compose Part 1
들어가며
LaunchedEffect, DisposableEffect 를 많이 사용하는데 제대로 알고 사용하자는 의미로 공식문서를 읽어보며 SideEffect 에 대해 공부해보겠습니다. 제 생각과 의역이 들어가기 때문에 정확한 정보를 얻고 싶다면 본문을 읽는걸 추천드립니다.
https://developer.android.com/develop/ui/compose/side-effects
side-effects 는 composable function 범위 밖에서 발생한 앱의 상태 변화입니다. side-Effects는 예상하지 못한 리컴포지션, 리컴포지션 간 순서 변경, 리컴포지션 취소로 이어질 수 있습니다. 때문에 side-effects 는 이상적으로 없어야합니다. 하지만 스낵바를 보여주거나 화면을 이동하는 이벤트 등 side-effects 가 필요한 경우도 있습니다. 이 경우 이벤트가 컴포저블의 lifecycle 에 맞춰서 실행되어야합니다. 그때 사용해야하는 것이 Effect Api 입니다. 이 글에서 Jetpack compose 의 side-effects API 들에 대해 공부해봅시다.
State and effect use cases
An effect is a composable function that doesn't emit UI and causes side effects to run when a composition completes.
문서에 따르면 "effect 는 Composable 함수이지만 UI 를 emit 하지않는다. 컴포지션이 완료될 때 side effects 를 실행한다." 라고 얘기합니다. 화면을 그리는 것이 아니라 Side effects 를 실행하기 위한 함수라고 이해하면 될 것 같습니다. Compose 에서 effect 는 남용되기 쉽습니다. 특히 effect 함수를 사용할 때는 Unidirectional data flow (단방향 데이터 흐름) 이 깨지지 않도록 주의해야합니다.
참고: 컴포즈에서 반응형 UI 는 비동기로 발생하며 콜백 대신 코루틴을 이용합니다.
1. LaunchedEffect : run suspend functions in the scope of a composable
먼저 가장 많이 보는 LaunchedEffect 입니다. Composable scope 내에서 전체 생명주기에 걸쳐 suspend 함수를 실행하려면 LaunchedEffect 를 사용해야합니다. LaunchedEffect 가 컴포지션을 시작하면 LaunchedEffect 의 매개변수로 전달된 coroutine block 이 실행되며 LaunchedEffect 의 composition 이 끝나면 coroutine block 이 cancel 됩니다. 만약 LaunchedEffect 가 전달받은 key 가 변경되면 이미 실행중인 coroutine 은 cancel 되고 새로운 coroutine 이 다시 실행됩니다.
var pulseRateMs by remember { mutableStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // pulseRateMs 의 값이 변경되면 코루틴을 취소하고 변경된 값으로 다시 실행됨
while (isActive) {
delay(pulseRateMs) // pulseRateMs 시간마다 alpha 가 -> 0 -> 1
alpha.animateTo(0f)
alpha.animateTo(1f)
}
}
코드를 보면 pulseRateMs 가 있는데 이 값을 LaunchedEffect key 로 전달했기 때문에 이 값이 바뀌면 LaunchedEffect 코루틴이 취소되고 다시 실행됩니다. 초기에 3초(3000)에 한 번씩 alpha 가 변하다가 pulseRateMs 가 1000 이 된다면 1초에 한번씩 alpha 가 변경 되는 것이죠. 이는 Composable 생명주기가 끝날 때까지 반복됩니다.
2. rememberCoroutineScope : obtain a composition-aware scope to launch a coroutine outside a composable
LaunchedEffect 는 Composable 함수라 다른 composable 함수 내에서만 실행할 수 있습니다. Composable 외부에 있지만 Composition 종료 후 자동으로 취소되게 하려면 rememberCoroutineScope 를 사용해야합니다. 또한 코루틴 생명주기를 수동으로 관리해야할 때 사용할 수 있습니다. 버튼 클릭 같은 이벤트 핸들링을 할 때 유용하게 사용할 수 있습니다.
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
// MoviesScreen Composable 함수의 생명주기에 바인딩된 CoroutineScope 구성
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
Column(Modifier.padding(contentPadding)) {
Button(
onClick = {
// showSnackbar 하기 위해 새로운 코루틴 생성
scope.launch {
snackbarHostState.showSnackbar("Something happened!")
}
}
) {
Text("Press me")
}
}
}
}
3. rememberUpdatedState: reference a value in an effect that shouldn't restart if the value changes
LaunchedEffect 는 key 매개변수 중 하나가 변경되면 다시 시작합니다. 그러나 경우에 따라서 값이 변경되더라도 다시 시작하지 않게 하고 싶을 수 있습니다. 이 경우 rememberUpdatedState 를 사용할 수 있습니다. 이 방법은 비용이 많이 들거나 다시 시작해서는 안되는 작업에 사용할 때 유용합니다.
예를 들어 시간이 지나면 사라지는 LandingScreen 이 있을 때, 컴포지션 이후 onTimeout 값이 변경되어 리컴포지션 되더라도 "일정시간 후에 대기 시간 경과 알림" 이라는 효과는 다시 시작해서는 안됩니다. 그렇다고 onTimeout 이 최초의 onTimeout 으로 실행되면 문제가 생길 수 있겠죠. 최신의 onTimeout 을 가리키게 하려면 rememberUpdatedState 를 사용하면 됩니다!
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// LandingScreen 이 리컴포지션 되면 항상 최근의 onTimeout function 을 가리킵니다.
val currentOnTimeout by rememberUpdatedState(onTimeout)
// 재실행하지 않고 다시 delay 할 필요없이 최신의 onTimeout 을 실행할 수 있다는 의미입니다.
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}
/* Landing screen content */
}
effect 를 호출 시점 생명주기 와 동일하게 생성하려면 변하지 않는 상수(Unit or true) 를 매개변수로 전달하면 됩니다.
4. DisposableEffect: effects that require cleanup
key 가 변경되거나 Composition 종료 후 정리해야하는 sideEffect 의 경우 DisposableEffect 를 사용해야합니다. DisposableEffect key가 변경되면 컴포저블이 현재 효과를 dispose (삭제, 정리) 하고 effect 를 다시 호출하여 reset 합니다.
예를 들어, LifecycleObserver 를 사용해 생명주기에 맞는 이벤트를 보내고 싶다면 DisposableEffect 를 사용해 관찰자를 등록, 등록 취소할 수 있습니다.
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Started 이벤트 전송
onStop: () -> Unit // stoppted 이벤트 전송
) {
// 새로운 값이 전달되면 최신 값을 가리킨다.
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// lifecycleOwner 값이 변경되면 effect 를 정리하고 리셋한다.
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
// onStart 라면 onStart 이벤트 전송
if (event == Lifecycle.Event.ON_START) {
// onStop 이라면 onStop 이벤트 전송
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
// lifecycle 옵저버 add
lifecycleOwner.lifecycle.addObserver(observer)
// Composition 이 종료될 때 observer 를 제거한다. (dispose)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
/* Home screen content */
}
마무리하며
오늘은 LaunchedEffect, rememberCoroutineScope, rememberUpdatedState, DisposableEffect 에 대해 학습해보았습니다.
단순 공식문서 읽기, 생각 남기기 활동이지만 모르고 사용하던 것들을 제대로 이해할 수 있는 시간을 가질 수 있었습니다.
다음 글에서 남은 SideEffect, produceState, derivedStateOf, snapshotFlow 에 대해 알아보겠습니다.