Compose의 부수 효과

부수 효과는 구성 가능한 함수의 범위 밖에서 발생하는 앱 상태에 관한 변경사항입니다. 컴포저블의 수명 주기 및 속성(예: 예측할 수 없는 리컴포지션 또는 다른 순서로 컴포저블의 리컴포지션 실행, 삭제할 수 있는 리컴포지션)으로 인해 컴포저블에는 부수 효과가 없는 것이 좋습니다.

그러나 부수 효과가 필요한 때도 있습니다. 예를 들어 스낵바를 표시하거나 특정 상태 조건에 따라 다른 화면으로 이동하는 등 일회성 이벤트를 트리거할 때입니다. 이러한 작업은 컴포저블의 수명 주기를 인식하는 관리된 환경에서 호출해야 합니다. 이 페이지에서는 Jetpack Compose가 제공하는 다양한 부수 효과 API에 관해 알아봅니다.

상태 및 효과 사용 사례

Compose 이해 문서에 설명된 대로 컴포저블에는 부수 효과가 없어야 합니다. 상태 관리 문서에 설명된 대로 앱 상태를 변경해야 하는 경우 이러한 부수 효과가 예측 가능한 방식으로 실행되도록 Effect API를 사용해야 합니다.

Compose에서 다양한 가능성 효과를 이용할 수 있기 때문에 과다하게 사용될 수 있습니다. 상태 관리 문서에 설명된 대로 효과에서 실행하는 작업이 UI와 관련되고 단방향 데이터 흐름을 중단하지 않아야 합니다.

LaunchedEffect: 컴포저블의 범위에서 정지 함수를 실행합니다.

컴포저블 내에서 안전하게 정지 함수를 호출하려면 LaunchedEffect 컴포저블을 사용하세요. LaunchedEffect가 컴포지션을 시작하면 매개변수로 전달된 코드 블록으로 코루틴이 실행됩니다. LaunchedEffect가 컴포지션을 종료하면 코루틴이 취소됩니다. LaunchedEffect가 다른 키로 재구성되면(아래 효과 다시 시작 섹션 참고) 기존 코루틴이 취소되고 새 코루틴에서 새 정지 함수가 실행됩니다.

예를 들어 ScaffoldSnackbar는 정지 함수인 SnackbarHostState.showSnackbar 함수를 사용하여 표시됩니다.

@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    snackbarHostState: SnackbarHostState
) {

    // If the UI state contains an error, show snackbar
    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"
            )
        }
    }

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        // ...
    }
}

위 코드에서는 상태에 오류가 포함되어 있으면 코루틴이 트리거되고 오류가 포함되어 있지 않으면 취소됩니다. LaunchedEffect 호출 사이트가 if 문 내에 있으므로 문장이 거짓일 때 LaunchedEffect가 컴포지션에 있으면 삭제되고 따라서 코루틴이 취소됩니다.

rememberCoroutineScope: 컴포지션 인식 범위를 가져와 컴포저블 외부에서 코루틴을 실행합니다.

LaunchedEffect는 구성 가능한 함수이므로 구성 가능한 다른 함수 내에서만 사용할 수 있습니다. 컴포저블 외부에 있지만 컴포지션을 종료한 후 자동으로 취소되도록 범위가 지정된 코루틴을 실행하려면 rememberCoroutineScope를 사용하세요. 또한 코루틴 하나 이상의 수명 주기를 수동으로 관리해야 할 때마다(예: 사용자 이벤트가 발생할 때 애니메이션을 취소해야 하는 경우) rememberCoroutineScope를 사용하세요.

rememberCoroutineScope는 호출되는 컴포지션의 지점에 바인딩된 CoroutineScope를 반환하는 구성 가능한 함수입니다. 호출이 컴포지션을 종료하면 범위가 취소됩니다.

이전 예에 따라 사용자가 Button을 탭할 때 이 코드를 사용하여 Snackbar를 표시할 수 있습니다.

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // Creates a CoroutineScope bound to the MoviesScreen's 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: 값이 변경되면 다시 시작되지 않아야 하는 효과의 값을 참조합니다.

주요 매개변수 중 하나가 변경되면 LaunchedEffect가 다시 시작됩니다. 하지만 경우에 따라 효과에서 값이 변경되면 효과를 다시 시작하지 않을 값을 캡처할 수 있습니다. 이렇게 하려면 rememberUpdatedState를 사용하여 캡처하고 업데이트할 수 있는 이 값의 참조를 만들어야 합니다. 이 접근 방식은 비용이 많이 들거나 다시 만들고 다시 시작할 수 없도록 금지된 오래 지속되는 작업이 포함된 효과에 유용합니다.

예를 들어 앱에 시간이 지나면 사라지는 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 */
}

호출 사이트의 수명 주기와 일치하는 효과를 만들기 위해 Unit 또는 true와 같이 변경되지 않는 상수가 매개변수로 전달됩니다. 위 코드에서는 LaunchedEffect(true)가 사용됩니다. onTimeout 람다에 LandingScreen이 재구성된 최신 값이 항상 포함되도록 하려면 rememberUpdatedStateonTimeout을 래핑해야 합니다. 코드에서 반환된 State, currentOnTimeout은 효과에서 사용해야 합니다.

DisposableEffect: 정리가 필요한 효과

키가 변경되거나 컴포저블이 컴포지션을 종료한 후 정리해야 하는 부수 효과의 경우 DisposableEffect를 사용하세요. DisposableEffect 키가 변경되면 컴포저블이 현재 효과를 삭제(정리)하고 효과를 다시 호출하여 재설정해야 합니다.

예를 들어 LifecycleObserver를 사용하여 Lifecycle 이벤트를 기반으로 애널리틱스 이벤트를 전송할 수 있습니다. Compose에서 이 이벤트를 수신 대기하려면 DisposableEffect를 사용하여 필요에 따라 관찰자를 등록하고 등록 취소하세요.

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

위의 코드에서는 효과가 observerlifecycleOwner에 추가합니다. lifecycleOwner가 변경되면 효과가 삭제되고 새 lifecycleOwner로 다시 시작됩니다.

DisposableEffectonDispose 절을 코드 블록의 최종 문장으로 포함해야 합니다. 그러지 않으면 IDE에 빌드 시간 오류가 표시됩니다.

SideEffect: Compose 상태를 Compose가 아닌 코드에 게시합니다.

Compose에서 Compose에서 관리하지 않는 객체와 Compose 상태를 공유하려면 SideEffect 컴포저블을 사용하세요. SideEffect를 사용하면 리컴포지션에 성공할 때마다 효과가 실행됩니다. 반면 컴포저블에 효과를 직접 작성하는 경우 성공적인 리컴포지션이 보장되기 전에 효과를 실행하는 것은 잘못되었습니다.

예를 들어 애널리틱스 라이브러리를 사용하면 커스텀 메타데이터(이 예에서는 '사용자 속성')를 이후의 모든 애널리틱스 이벤트에 연결하여 사용자 인구를 분류할 수 있습니다. 현재 사용자의 사용자 유형을 애널리틱스 라이브러리에 전달하려면 SideEffect를 사용하여 값을 업데이트합니다.

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

produceState: Compose가 아닌 상태를 Compose 상태로 변환

produceState는 반환된 State로 값을 푸시할 수 있는 컴포지션으로 범위가 지정된 코루틴을 실행합니다. 비 Compose 상태를 Compose 상태로 변환하려면, 예를 들어 Flow, LiveData 또는 RxJava와 같은 외부 구독 기반 상태를 컴포지션으로 변환하려면 이 코루틴을 사용하세요.

produceState가 컴포지션을 시작하면 프로듀서가 실행되고 컴포지션을 종료하면 취소됩니다. 반환된 State는 합성됩니다. 동일한 값을 설정해도 리컴포지션이 트리거되지 않습니다.

produceState가 코루틴을 만드는 경우에도 정지되지 않는 데이터 소스를 관찰하는 데 사용할 수 있습니다. 이 소스의 구독을 삭제하려면 awaitDispose 함수를 사용하세요.

다음 예에서는 produceState를 사용하여 네트워크에서 이미지를 로드하는 방법을 보여줍니다. loadNetworkImage 구성 가능한 함수는 다른 컴포저블에서 사용할 수 있는 State를 반환합니다.

@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: 하나 이상의 상태 객체를 다른 상태로 변환

Compose에서는 관찰된 상태 객체 또는 컴포저블 입력이 변경될 때마다 리컴포지션이 발생합니다. 상태 객체 또는 입력이 UI가 실제로 업데이트해야 하는 것보다 더 자주 변경되어 불필요한 리컴포지션이 발생할 수 있습니다.

컴포저블에 관한 입력이 재구성해야 하는 것보다 더 자주 변경되는 경우 derivedStateOf 함수를 사용해야 합니다. 이는 스크롤 위치와 같이 항목이 자주 변경되지만 컴포저블은 특정 기준점을 넘어야만 반응해야 할 때 발생합니다. derivedStateOf는 필요한 만큼만 업데이트되는 관찰 가능한 새로운 Compose 상태 객체를 만듭니다. 이러한 방식으로 Kotlin Flow distinctUntilChanged() 연산자와 비슷하게 작동합니다.

올바른 사용법

다음 스니펫은 derivedStateOf의 적절한 사용 사례를 보여줍니다.

@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

이 스니펫에서 firstVisibleItemIndex는 첫 번째로 표시되는 항목이 변경될 때마다 변경됩니다. 스크롤하면 값이 0, 1, 2, 3, 4, 5 등이 됩니다. 그러나 값이 0보다 큰 경우에만 리컴포지션이 발생해야 합니다. 이러한 업데이트 빈도의 불일치는 derivedStateOf에 적합한 사용 사례임을 의미합니다.

잘못된 사용

흔히 발생하는 실수는 Compose 상태 객체 두 개를 결합할 때 '상태를 파생'하므로 derivedStateOf를 사용해야 한다고 가정하는 것입니다. 그러나 이는 다음 스니펫과 같이 오버헤드일 뿐 필수는 아닙니다.

// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!!
val fullNameCorrect = "$firstName $lastName" // This is correct

이 스니펫에서 fullNamefirstNamelastName만큼 자주 업데이트해야 합니다. 따라서 초과 리컴포지션이 발생하지 않으며 derivedStateOf를 사용할 필요가 없습니다.

snapshotFlow: Compose의 상태를 Flow로 변환

snapshotFlow를 사용하여 State<T> 객체를 콜드 Flow로 변환합니다. snapshotFlow는 수집될 때 블록을 실행하고 읽은 State 객체의 결과를 내보냅니다. snapshotFlow 블록 내에서 읽은 State 객체의 하나가 변경되면 새 값이 이전에 내보낸 값과 같지 않은 경우 Flow에서 새 값을 수집기에 내보냅니다(이 동작은 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로 변환됩니다.

효과 다시 시작

LaunchedEffect, produceState, DisposableEffect와 같은 Compose의 일부 효과에서 실행 중인 효과를 취소하는 데 사용되는 가변적인 수의 인수를 취하고 새 키로 새 효과를 시작합니다.

이 API의 일반적인 형태는 다음과 같습니다.

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

이 동작은 미묘하므로 효과를 다시 시작하는 데 사용되는 매개변수가 올바른 매개변수가 아닌 경우 문제가 발생할 수 있습니다.

  • 필요한 것보다 적은 효과를 다시 시작하면 앱에 버그가 발생할 수 있습니다.
  • 필요한 것보다 많은 효과를 다시 시작하면 비효율적일 수 있습니다.

대체적으로 효과 코드 블록에 사용되는 변경할 수 있는 변수와 변경할 수 없는 변수는 효과 컴포저블에 매개변수로 추가해야 합니다. 이 매개변수 외에 효과를 강제로 다시 시작하도록 더 많은 매개변수를 추가할 수 있습니다. 변수를 변경해도 효과가 다시 시작되지 않아야 하는 경우 변수를 rememberUpdatedState에 래핑해야 합니다. 변수가 키가 없는 remember에 래핑되어 변경되지 않으면 변수를 효과에 키로 전달할 필요가 없습니다.

위에 표시된 DisposableEffect 코드에서 블록의 lifecycleOwner가 변경되면 효과가 다시 시작되어야 하므로 효과에서 이를 매개변수로 취합니다.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // These values never change in Composition
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            /* ... */
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

currentOnStartcurrentOnStopDisposableEffect 키로 필요하지 않습니다. rememberUpdatedState의 사용으로 컴포지션에서 이 키의 값이 변경되지 않기 때문입니다. lifecycleOwner가 매개변수로 전달되지 않고 변경되면 HomeScreen은 재구성되지만 DisposableEffect는 삭제되거나 다시 시작되지 않습니다. 이 시점부터 잘못된 lifecycleOwner가 사용되므로 문제가 발생합니다.

키로 사용되는 상수

true와 같은 상수를 호출 사이트의 수명 주기를 추적하는 효과 키로 사용할 수 있습니다. 위 LaunchedEffect 예와 같이 유효한 사용 사례가 있습니다. 그러나 사용하기 전에 신중하게 필요한 항목인지 확인하세요.