[android] Compose Side-effects
https://developer.android.com/jetpack/compose/side-effects
#
LaunchedEffect : composable 안에서 suspend function 실행
composition 에 들어가면 LaunchedEffect 의 block 코드가 새로운 coroutine 에서 실행됨.
composition 에서 벗어나면 LaunchedEffect 의 block 코드는 cancel 됨.
Recomposition 에서는 LaunchedEffect 의 key 가 변경되는 경우 기존 coroutine cancel 하고 relaunch 됨.
if (state.hasError) {
// `LaunchedEffect` will cancel and re-launch if `scaffoldState.snackbarHostState` changes
LaunchedEffect(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.
snackbarHostState.showSnackbar(
message = "Error message",
actionLabel = "Retry message"
)
}
}
#
rememberCoroutineScope : composable 바깥에서 composition-aware scope 를 얻음
composable 바깥에서 coroutine 실행하지만, composition 에서 벗어날 때 cancel 하고 싶은 경우 사용함.
rememberCoroutineScope 는 CoroutineScope 를 return 하며, 이는 호출장소의 composition 에 bound 됨.
이 scope 은 bound 된 composition 에서 벗어나면 cancel 된다.
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
// Creates a CoroutineScope bound to the MoviesScreen lifecycle
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
Column(Modifier.padding(contentPadding)) {
Button(onClick = {
// Create a new coroutine in the event handler to show a snackbar
scope.launch {
snackbarHostState.showSnackbar("Something happened!")
}
}) {
Text("Press me")
}
}
}
}
#
rememberUpdatedState : 변경되었을 때 effect 를 restart 시키지 않으면서 effect 에서 사용되는 value 참조
Effect 내부에서 참조되는 value 인데, effect 를 새로 launch 시키고 싶지 않을 때 사용하는데,
보통 장기간 살아있어야 하는 작업이나, 재시도하기에 비싼 effect 등에 활용된다.
@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 should not start again.
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}
...
}
LaunchedEffect(true) 같은 코드는 위험하므로 조심해서 써야 함.
#
DisposableEffect: cleanup 이 필요한 effect
key 가 변경되거나, composition 에서 나갈 때 clean up 이 필요할 때 이 녀석을 쓰면 됨.
이 녀석의 마지막에는 onDispose 구문을 써줘야 하며, 여기를 비워두는 건 좋지 않음.
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)
}
}
#
SideEffect : Compose state 를 non-compose code 에 발행
recomposition 마다 코드가 실행됨.
#
produceState : Non compose state 를 compose state 로 전환
Flow, LiveData, RxJava 등을 composition 에서 사용하는 State 로 전환하는 작업을 함.
producer 의 코드는 produceState 가 composition 에 진입하면 수행되고, composition 에서 나가면 cancel 됨.
return 되는 State 는 conflates 로 이는 StateFlow 처럼 동일 value 발행은 recomposition 을 야기하지 않음.
produceState 는 coroutine 을 만들지만, non-suspending source 를 observe 하는데 사용될 수 있음.
이 경우 awaitDispose 를 사용하여 observe 를 해지시킬 수 있음.
@Composable
fun loadNetworkImage(
url: String,
imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
// Creates a State<T> with Result.Loading as initial value
// If either `url` or `imageRepository` changes, the running producer
// will cancel and will be re-launched with the new inputs.
return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
// In a coroutine, can make suspend calls
val image = imageRepository.load(url)
// Update State with either an Error or Success result.
// This will trigger a recomposition where this State is read
value = if (image == null) {
Result.Error
} else {
Result.Success(image)
}
}
}
#
derivedStateOf: 1개 이상의 state 를 모아서 다른 state 로
다른 state 를 활용하여 계산하거나 유도된 state 를 말함.
이 계산이나 유도 과정에서 사용된 state 중 하나라도 변경되면 다시 계산 또는 유도 로직이 돌게 됨.
derivedStateOf 는 에 대한 update 자체가 recompose 를 야기하진 않음. 사용처가 있어야 함.
@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 { task ->
highPriorityKeywords.any { keyword ->
task.contains(keyword)
}
}
}
}
Box(Modifier.fillMaxSize()) {
LazyColumn {
items(highPriorityTasks) { /* ... */ }
items(todoTasks) { /* ... */ }
}
/* Rest of the UI where users can add elements to the list */
}
}
#
snapshotFlow: Compose state 를 Flow 로 전환
State 를 cold Flow 로 변경함.
snapshotFlow 의 block 은 collect 가 발생할 때 돌게 됨. 이 block 안에서 참조하는 State 값이 변경될 때, flow 는 collector 에 새로운 값을 emit 하며, 동일값은 발행하지 않음.
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
#
Restarting effects
LaunchedEffect, produceState, DisposableEffect 과 같은 compose 의 effect 들은 실행중인 effect 를 취소하고 새로운 key 를 기반으로 재시작하기 위해 몇개의 key 를 받을 수 있음.
기대한 것보다 적은 effect restarting 은 버그를 생산하기 쉽고, 기대한 것보다 많은 effect restarting 은 비효율적임.
variable 이 restarting 을 야기하면 안 된다면, 해당 variable 은 rememberUpdatedState 로 감싸져야 함.
key 없는 remember 에 감싸져서 variable 이 절대 변하지 않는다면, 해당 variable 도 effect 에 넘기면 안 됨.
끝