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()
        }
}

© 2021. All rights reserved.

Powered by Hydejack v9.1.6