ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Android] Compose Side Effect
    안드로이드 2021. 8. 20. 17:09
    반응형

    개요

    Compose에서 Side Effect를 어떻게 사용하는지 알아보자

     

    Side Effect란

    Composable 외부에서 발생하는 앱의 상태 변경사항을 말한다.

     

    Effect란

    UI에 방출하지 않으며 Composition이 완료될 때 Side Effect를 실행하는 Composable 함수이다.

     

    State 및 Effect 사용 사례

    • Composable은 Side Effect에 Free 해야한다. 
    • 앱의 상태 변경이 필요할 때 Composable의 생명주기를 알고 있는 제어되는 환경으로 부터 호출해야 한다.
    • Compose에서 가능성 있는 Effect들을 열어두기 때문에 쉽게 과하게 사용할 수있다. 그래서 UI와 관련되고 단방향 데이터 플로우를 중단시켜서는 안된다.

    LaunchedEffect

    • LaunchedEffect는 Composable에서 suspend 함수를 안전하게 사용하기 위한 Composable이다.
    • LaunchedEffect가 Composition에 들어올 때 파라메터로 전달된 코루틴 코드 블록을 실행한다.
    • Composition에 LaunchedEffect가 없어지면 코루틴은 취소된다.
      예로, if문에 의해 LaunchedEffect가 생성되지 않는다면 Composition에 없어지게 된다.
    • LaunchedEffect가 다른 키로 재구성 된다면 기존의 코루틴은 취소되고 새로운 suspend 함수는 새로운 코루틴에서 실행될 것이다.
    @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) {
            /* ... */
        }
    }

    위 코드에서 보면 state.hasError에 의해 LaunchedEffect가 생성될지 말지 결정하고 있다. 만약 hasError가 true라면 LaunchedEffect에 의해 코루틴이 트리거되고 hasError가 false라면 LaunchedEffect는 Composition에서 없어지고 기존의 LaunchedEffect의 코루틴이 취소된다.

     

    rememberCoroutineScope

    • Composable 외부에서 코루틴을 실행하기 위한 Composition 인식 범위를 확보한다.
    • LaunchedEffect는 Composable 함수이므로 Composable 내에서만 사용할 수 있다. Composable 외부에서 코루틴을 실행하기 위해서 Composition이 종료된 후 자동으로 취소되는 범위를 지정해야한다. 그 때 rememberCoroutineScope를 사용한다.
    • 예를 들어 사용자 이벤트가 발생하면 애니메이션을 취소하는 것 처럼 하나 이상의 코루틴의 생명주기를 수동으로 관리하기 위해서도 rememberCoroutineScope를 사용한다.
    • rememberCoroutineScope가 호출되는 Composition 지점에 바인딩 되고 CoroutineScope를 반환한다. 호출이 Composition을 종료하면 범위는 종료된다.

    아래는 rememberCoroutinScope의 예제 코드이다.

    @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")
                }
            }
        }
    }

    위 예제를 보면 MovieScreen에서 rememberCoroutineScope를 호출하여 MovieScreen의 생명주기에 바인딩된 CoroutineScope를 반환하고 이를 하위 Composable인 Button에서 onClick시 코루틴을 실행하도록 되어있다. 이렇게 설정 하면서 Button Composable의 Composition이 종료되어도 MovieScreen의 Composition이 종료되지 않는 이상 MovieScreen의 생명주기에 따라 코루틴은 남아있게 된다.

     

    rememberUpdateState

    • 값이 변경되었을 때 재시작 되지 않아야할 효과의 값 참조
    • LaunchedEffect는 key 파라메터가 변경되면 재시작된다. 하지만 이 key 값을 변경하여 효과를 재시작하지 않고 값을 가져오거나 수정하고 싶을 경우가 있는데 이럴 경우 rememberUpdateState를 사용한다.
    • 재생성, 재시작하는데 비용이 비싸거나 금지되는 오래 남아있는 작업을 포함하는 효과에 도움을 준다.

    아래는 몇 초 후에 LandingScreen이 사라지는 예제 코드이다. LandingScreen이 재구성된다면 다시 대기하는 효과가 실행되면 안된다.

    @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 */
    }

    LaunchedEffect를 재구성 하지 않고 call site의 생명주기에 맞는 효과를 생성하기 위해 Unit이나 true와 같이 변하지 않는 상수를 파라메터로 전달한다. 위의 코드에서 보면 LaunchedEffect(true)를 사용했다. LandingScreen 재구성 시 전달되는 onTimeout 람다를 최신 상태로 유지하려면 rememberUpdateState 함수로 래핑해야하고 currentOnTimeout()은 효과에 사용되어야 한다.

     

    *** 주의 : LauncedEffect(true)는 while(true) 만큼 의심스럽다. 유용한 사례가 있더라도 항상 잠시 멈춰서 필요한 작업인지 확인해야 한다.

     

    DisposableEffect

    • Side Effect가 key 변화 후, Composition에서 Composable이 없어진 경우 정리가 필요할 때 DisposableEffect를 사용한다.
    • DisposableEffect의 키가 변경되면 Composable이 현재 효과를 삭제(정리)하고 효과를 다시 호출하여 설정한다.

    아래의 예제를 보면 OnBackPressedDispatcher에서 뒤로가기 버튼을 누르는 동작을 수신하기 위해 OnBackPressedCallback을 등록해야한다. Compose에서 이 이벤트를 수신하기 위해 DisposableEffect를 사용해서 필요에 따라 callback을 등록 및 해제한다.

    @Composable
    fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {
    
        // Safely update the current `onBack` lambda when a new one is provided
        val currentOnBack by rememberUpdatedState(onBack)
    
        // Remember in Composition a back callback that calls the `onBack` lambda
        val backCallback = remember {
            // Always intercept back events. See the SideEffect for
            // a more complete version
            object : OnBackPressedCallback(true) {
                override fun handleOnBackPressed() {
                    currentOnBack()
                }
            }
        }
    
        // If `backDispatcher` changes, dispose and reset the effect
        DisposableEffect(backDispatcher) {
            // Add callback to the backDispatcher
            backDispatcher.addCallback(backCallback)
    
            // When the effect leaves the Composition, remove the callback
            onDispose {
                backCallback.remove()
            }
        }
    }

    위 코드에서 보면 remember된 backCallback을 backDispatcher에 콜백으로 추가했다. backDispatcher가 변경되면 효과는 제거되고 다시 재시작된다.

     

    DisposableEffect는 onDispose를 코드 블럭의 마지막에 포함해야한다. 그렇지 않으면 빌드 시 에러가 발생한다. onDispose에 빈 코드를 포함하는 것은 좋지 않은 방법이니 사용하는데 적합한지 고민을 해야한다.

     

    SideEffect

    • compose에 의해 관리되지 않는 객체를 가지는 Compose 상태를 공유하기 위해 Recomposition시 마다 성공적으로 호출되는 SideEffect를 사용한다.

    이전의 예제인 BackHandler 예제에서 callback을 enable 할지 말지 통신하기 위해 SideEffect를 사용해서 값을 업데이트한다.

    @Composable
    fun BackHandler(
        backDispatcher: OnBackPressedDispatcher,
        enabled: Boolean = true, // Whether back events should be intercepted or not
        onBack: () -> Unit
    ) {
        /* ... */
        val backCallback = remember { /* ... */ }
    
        // On every successful composition, update the callback with the `enabled` value
        // to tell `backCallback` whether back events should be intercepted or not
        SideEffect {
            backCallback.isEnabled = enabled
        }
    
        /* Rest of the code */
    }

    위 예를 보면 backCallback은 Compose에 의해 관리되지 않는 객체이다. 파라메터의 enabled가 변경되어 Recomposition되도 Compose에 의해 관리가 되지 않기 때문에 변경된 enabled의 값을 변경할 수 없다. 이를 해결하기 위해 매 Recomposition 시 호출되는 SideEffect를 사용하여 backCallback의 enable 값을 변경할 수 있다.

     

    produceState

    • 비 Compose 상태를 Compose 상태로 변환한다.
    • Flow, LiveData, RxJava와 같이 외부 구독 기반 State를 Composition으로 변환하려면 produceState를 사용하면 된다.
    • produceState가 Composition에 들어올 때 프로듀셔는 실행되고 Composition에서 없어지면 취소된다. 
    • State를 반환한다.
    • 같은 값이면 Recomposition이 트리거 되지 않는다.
    • produceState가 코루틴을 생성해도 정지되지 않는(non-suspending) 데이터 소스를 관찰하는데 사용할 수 있다. 소스의 구독을 삭제하려면 awaitDispose 함수를 사용하면 된다.

    아래의 예제는 네트워크로 이미지를 로드하기 위해 produceState를 어떻게 사용하는지 보여주는 예제이다. loadNetworkImage Composable 함수는 다른 Composable에서 사용될 수 있는 State를 반환한다.

    @Composable
    fun loadNetworkImage(
        url: String,
        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 keys.
        return produceState(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)
            }
        }
    }

     내부적으로 produceState는 다른 효과를 사용한다. remember { mutableStateOf(initialValue) } 를 사용하는 result 변수를 가지고 LaunchedEffect에서 produce 블록을 트리거 한다. producer 블록에서 value가 업데이트될 때 마다 result 상태가 새 값으로 변경된다.

     

    derivedStateOf

    • 다른 State 객체로 부터 계산되거나 파생된 State가 포함될 때 사용한다.
    • 이 함수를 사용하면 계산에 사용된 상태 중 하나가 변경될 때 마다 계산을 다시 한다.

    아래 예는 사용자가 정의한 높은 우선순위의 키워드가 있는 작업이 먼저 표시되는 TODO리스트를 보여주는 예제이다.

    @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(todoTasks, 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 */
        }
    }

    위 코드에서 보면 derivedStateOf는 todoTasks나 highPriorityKeywords가 변경될 때마다 highPriorityTasks 계산이 실행되고 그에 따라 UI가 변경된다. highPriorityTasks를 계산하기 위한 필터링은 비용이 많이 들 수 있으므로 매 Recomposition 마다 실행하는게 아니라 목록이 변경될 때 만 실행해야 한다.

    derivedStateOf에 의해 상태가 변경되어도 업데이트가 선언된 Composable이 재구성 되지 않는다. Compose는 위 코드의 LazyColumn 내에서 return된 State를 읽는 위치의 Composable만 재구성한다.

     

    snapshotFlow

    • State<T>를 콜드(cold) Flow로 변환한다.
    • collect 될 때 snapshotFlow block을 실행하고 읽은 State 결과를 collect에 방출한다.
    • snapshotFlow 블록 내에서 읽은 값 중 한 값이 변경되면 새 값이 이전 값과 같지 않을 경우 Flow에서 새 값을 collect에 방출한다. 이 동작은 Flow.distinctUntilChanged의 동작과 비슷하다.

    아래의 예는 사용자가 리스트에서 첫번재 아이템을 지나 스크롤할때 분석에 기록하는 예제이다.

    val listState = rememberLazyListState()
    
    LazyColumn(state = listState) {
        // ...
    }
    
    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .map { index -> index > 0 }
            .distinctUntilChanged()
            .filter { it == true }
            .collect {
                MyAnalyticsService.sendScrolledPastFirstItemEvent()
            }
    }

    위 코드에서 보면 listState.firstVisibleItemIndex는 Flow의 연산자의 이점을 활용할 수 있는 Flow로 변환된다.

     

    Effect 다시 시작

    • LaunchedEffect, produceState, DisposableEffect와 같은 몇몇 Effect들은 실행중인 Effect를 취소하는데 인수의 갯수가 변하는 키를 가지고 새로운 키로 새로운 Effect를 시작한다.
    • 이 API의 일반적인 형태는 다음과 같다.
      EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }​
    • 동작을 다시 시작하는데 사용되는 파라메터가 올바른 파라메터가 아닌 경우 아래와 같은 문제가 발생할 수 있다.
      • 필요한 것 보다 적은 Effect를 다시 시작하는 경우 버그가 발생할 수 있다.
      • 필요한 것 보다 많은 Effect를 다시 시작하는 경우 비효율적일 수 있다.
    • 대체적으로 Effect 코드 블록에 사용되는 변수는 변경할 수 있는 것과 변경할 수 없는 것을 파라메터로 추가해야한다.
    • Effect를 강제로 다시 시작하려면 더 많은 파라메터를 추가하면 된다.
    • 변수를 변경해도 Effect가 다시 시작되지 않으려면 변수를 rememberUpdatedState에 래핑해야한다.
    • 변수가 키가 없는 remember에 래핑되어 변경되지 않으면 이 변수를 Effect에 키로 전달할 필요가 없다.
    • Effect에 사용되는 변수는 rememberUpdatedState를 사용하거나 매개변수로 추가해야한다.

    아래의 예제는 DisposableEffect에서 봤던 예제코드 중 일부이다.

    @Composable
    fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {
        /* ... */
        val backCallback = remember { /* ... */ }
    
        DisposableEffect(backDispatcher) {
            backDispatcher.addCallback(backCallback)
            onDispose {
                backCallback.remove()
            }
        }
    }

    위 코드에서 보면 backCallback은 키가 없는 remember에 래핑된 변수이므로 변수가 변하지 않기 때문에 DisposableEffect의 key로 사용할 필요가 없다. 만약 backDispatcher가 DisposableEffect에 키로 전달되지 않았으면 backDispatcher의 값이 변경되어도 DisposableEffect는 종료되지 않고 다시 시작되지 않는다. 그렇게 때문에 backDispatcher가 키로 전달되지 않는 부분이 문제의 원인이 될 수 있다.

     

    key로 사용되는 상수

    • true와 같은 상수를 키로 사용하면 call site의 생명주기를 따르도록 할 수 있다.
    • rememberUpdatedState에서의 올바른 예가 있지만 사용하기 전에 정말 필요한 작업인지 신중하게 생각하고 사용해야 한다.

    마치며

    Compose의 Side Effect(부수효과)에 대해 알아보았다. Side Effect를 사용하는 방법이 생각보다 복잡한 것 같다. 실제로 프로젝트를 진행하면서 사용해봐야 익숙해질 것 같다.

    반응형

    댓글

Designed by Tistory.