Jetpack Compose Side-Effect
Jetpack Compose의 Composable은 side-effect가 없어야 한다. 그러나 스낵바 표시, 특정 상태에 따라 화면 전환 등의 일회성 트리거같은 경우 side-effect를 활용해야 한다.
side-effect가 필요할 경우 Compose에서 지원하는 API를 사용해야 한다.
Compose Effect APIs
LaunchedEffect
LaunchedEffect
는 컴포지션이 시작되면 코루틴 코드 블록이 실행된다. LaunchedEffect
키가 변경되면 실행 중인 코루틴을 취소하고 새 코루틴을 시작한다.
@Composable
fun MyScreen(
state: UiState<List<Movie>>,
scaffoldState: ScaffoldState = rememberScaffoldState()
) {
// If the UI state contains an error, show snackbar
if (state.hasError) {
// `LaunchedEffect` will cancel and re-launch if
// `scaffoldState.snackbarHostState` changes
LaunchedEffect(scaffoldState.snackbarHostState) {
// Show snackbar using a coroutine, when the coroutine is cancelled the
// snackbar will automatically dismiss. This coroutine will cancel whenever
// `state.hasError` is false, and only start when `state.hasError` is true
// (due to the above if-check), or if `scaffoldState.snackbarHostState` changes.
scaffoldState.snackbarHostState.showSnackbar(
message = "Error message",
actionLabel = "Retry message"
)
}
}
Scaffold(scaffoldState = scaffoldState) {
/* ... */
}
}
rememberCoroutineScope
Composable의 lifecycle을 따르는 CoroutineScope를 반환하는 함수이다.
Composable 내부에서 코루틴을 수행할 경우 Recomposition 시 이를 취소하지 않으면 코루틴이 여러 개 생성되어 동시에 동작할 수 있다. Recomposition은 자주 일어나는 동작이기 때문에 수십, 수백개의 코루틴이 동작할 수 있기 때문에 Recomposition이 발생할 시 취소 가능한 coroutine scope에서 코루틴을 수행해야 안전하다.
@Composable
fun MoviesScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {
// Creates a CoroutineScope bound to the MoviesScreen's lifecycle
val scope = rememberCoroutineScope()
Scaffold(scaffoldState = scaffoldState) {
Column {
/* ... */
Button(
onClick = {
// Create a new coroutine in the event handler to show a snackbar
scope.launch {
scaffoldState.snackbarHostState.showSnackbar("Something happened!")
}
}
) {
Text("Press me")
}
}
}
}
rememberUpdatedState
LaunchedEffect
block 내부에서 접근해야 하나 LaunchedEffect
를 재시작시키지 말아야할 경우 해당 값을rememberUpdatedState
로 wrapping해야 합니다.
effect가 오래 기다려야 하는 작업을 유지하거나, 무거운 연산이나, recomposition에 의해 재시작되지 않도록 할 때 유용하게 사용할 수 있다.
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// This will always refer to the latest onTimeout function that
// LandingScreen was recomposed with
val currentOnTimeout by rememberUpdatedState(onTimeout)
// Create an effect that matches the lifecycle of LandingScreen.
// If LandingScreen recomposes, the delay shouldn't start again.
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}
/* Landing screen content */
}
DisposableEffect
LaunchedEffect
와 비슷하지만 취소 시 onDispose
구문이 호출된다.
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Send the 'started' analytics event
onStop: () -> Unit // Send the 'stopped' analytics event
) {
// Safely update the current lambdas when a new one is provided
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// If `lifecycleOwner` changes, dispose and reset the effect
DisposableEffect(lifecycleOwner) {
// Create an observer that triggers our remembered callbacks
// for sending analytics events
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
// Add the observer to the lifecycle
lifecycleOwner.lifecycle.addObserver(observer)
// When the effect leaves the Composition, remove the observer
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
/* Home screen content */
}
SideEffect
Compose에서 관리하는 객체가 아닌 객체에 compose의 상태를 공유하기 위한 용도로 사용되며 Composition이 완료되면 진행할 동작을 예약할 때 사용한다.
이 함수는 recomposition이 일어날 때마다 호출된다. 따라서 효율적인 사용을 위해 특정 시점에 사용할 때는 LaunchedEffect
를, 자원 해제가 필요한 경우에는 DisposableEffect
를 사용하는 것이 좋다.
@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher,
isEnable: Boolean = false,
onBack: () -> Unit) {
..
// Remember in Composition a back callback that calls the `onBack` lambda
val backCallback = remember {
..
}
SideEffect {
backCallback.isEnabled = isEnable
}
// If `backDispatcher` changes, dispose and reset the effect
DisposableEffect(backDispatcher) {
..
}
}
produceState
일반적인 값을 State<T>
로 변환할 수 있다.
@Composable
fun loadNetworkImage(
url: String,
imageRepository: ImageRepository
): State<Result<Image>> {
// 초기값으로 Result.Loading을 세팅하고, url, imageRepository가 변경되면
// 실행중인 producer(coroutine)이 취소되고 재시작됨
return produceState(initialValue = Result.Loading, url, imageRepository) {
// coroutine scope 내부이므로 suspend function을 호출할 수 있습니다.
val image = imageRepository.load(url)
// 진행 결과에 따라 value에 값을 세팅하여 emit 합니다.
value = if (image == null) {
Result.Error
} else {
Result.Success(image)
}
}
}
내부 동작은 LaunchedEffect
를 사용한다.
derivedStateOf
다른 상태 객체에서 특정 상태가 파생될 경우 사용한다. 이 함수를 사용하면 계산에서 사용하는 상태 중 하나가 변경될 때 계산이 실행된다.
@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {
val todoTasks = remember { mutableStateListOf<String>() }
// Calculate high priority tasks only when the todoTasks or highPriorityKeywords
// change, not on every recomposition
val highPriorityTasks by remember(highPriorityKeywords) {
derivedStateOf { todoTasks.filter { it.containsWord(highPriorityKeywords) } }
}
Box(Modifier.fillMaxSize()) {
LazyColumn {
items(highPriorityTasks) { /* ... */ }
items(todoTasks) { /* ... */ }
}
/* Rest of the UI where users can add elements to the list */
}
}
snapshotFlow
State<T>
를 Flow
로 변환한다.
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}