공식 문서 기반의 Compose 부수 효과 총정리

송규빈·2024년 7월 4일
1
post-thumbnail

부수 효과

부수 효과는 흔히 Side Effect라고도 하며 함수가 만들어진 목적과는 다른 효과 또는 부작용을 뜻한다.

부수 효과가 있다면 예측할 수 없는 일들이 벌어질 가능성이 있고, 이는 버그로 발생될 수도 있고 유지보수 비용을 증가 시킨다.

이러한 부수효과가 없는 함수는 순수함수(pure function), 부수효과가 있는 함수는 불순함수(impure function)라고 한다.

Compose에서의 부수 효과

컴포즈에서의 부수 효과도 같은 개념이지만 좀 더 자세히 설명하면, Composable 함수의 범위 밖에서 발생하는 앱 상태에서 관한 변경사항이다.

컴포저블의 수명 주기 및 속성으로 인해 컴포저블에는 부수 효과가 없는 것이 좋다

  • 예측할 수 없는 리컴포지션
  • 다른 순서로 컴포저블의 리컴포지션 실행
  • 삭제할 수 있는 리컴포지션

부수 효과의 필요성

하지만 부수 효과가 필요한 경우가 있음.

예를 들면 스낵바를 표시하거나 특정 상태 조건에 따라 다른 화면으로 이동하는 등 일회성 이벤트를 트리거할 때이다.

이러한 작업은 컴포저블의 수명 주기를 인식하는 관리된 환경에서 호출해야 한다.

State 및 Effect 사용 사례

💡 `Effect`는 UI를 내보내지 않고 컴포지션이 완료될 때 부수 효과를 실행하는 컴포저블 함수

LaunchedEffect: 컴포저블 Scope에서 suspend 함수 실행

컴포저블 내에서 안전하게 suspend 함수를 호출하려면 LaunchedEffect 컴포저블을 사용하면 된다.

LaunchedEffect 가 컴포지션을 시작하면 매개변수로 전달된 코드 블록으로 코루틴이 실행됨.

LaunchedEffect 가 컴포지션을 종료하면 코루틴이 취소된다.

LaunchedEffect 가 다른 키로 재구성되면 기존 코루틴이 취소되고 새 코루틴에서 새로운 suspend 함수가 실행됨

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

위 코드에서 snackbarHostState에 오류가 포함되어 있으면 코루틴이 트리거되고 오류가 포함되어 있지 않으면 취소된다.

LaunchedEffect 호출 부분이 if문 내에 있으므로 문장이 거짓일 때 LaunchedEffect가 컴포지션에 있으면 삭제되고 따라서 코루틴이 취소된다.

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

LaunchedEffect는 컴포저블 함수이므로 다른 컴포저블 함수 내에서만 사용할 수 있다.

컴포저블 외부에 있지만 컴포지션을 종료한 후 자동으로 취소되도록 범위가 지정된 코루틴을 실행하려면 rememberCoroutineScope를 사용하면 된다.

또한, 하나 이상의 코루틴 수명 주기를 수동으로 관리해야 할 때 (ex: 사용자 이벤트가 발생할 때 애니메이션을 취소해야하는 경우) rememberCoroutineScope를 사용하면 된다.

사용자가 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가 다시 시작된다.

하지만 경우에 따라 Effect에서 값이 변경되면 Effect를 다시 시작하지 않을 값을 캡처할 수 있다.

이렇게 하려면 rememberUpdatedState를 사용하여 캡처하고 업데이트할 수 잇는 이 값의 참조를 만들어야 한다.

이 접근 방식은 비용이 많이 들거나 다시 만들고 다시 시작할 수 없도록 금지된 오래 지속되는 작업이 포함된 Effect에 유용하다.

예를 들면 앱에 시간이 지나면 사라지는 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을 래핑해야 한다.

즉, rememberUpdatedState(onTimeout)을 사용하지 않고 onTimeout을 직접 사용한다면, LaunchedEffect 내에서 onTimeout 람다가 처음 실행될 때 캡처된다.

이는 LandingScreen이 리컴포지션되어 onTimeout의 새로운 람다를 받더라도, LaunchedEffect 내에서는 여전히 이전의 onTimeout 람다를 사용하게 된다.

이전의 onTimeout 람다를 사용했을 때의 문제

상황

  1. 초기 상태
  • LandingScreen이 처음 렌더링될 때 onTimeout 람다는 화면 전환 동작을 정의
  • 예를 들어, onTimeout = { navController.navigate("nextScreen") }로 설정
  1. 3초 후 리컴포지션 발생
  • 사용자 인터랙션 또는 상태 변경으로 인해 LandingScreen이 리컴포지션
  • 이 때 onTimeout 람다가 새로운 동작으로 변경
  • 예를 들어, onTimeout = { navController.navigate("alternativeScreen") }로 변경
  1. 5초 후
  • LaunchedEffect 내의 delay(5000)가 끝나고 onTimeout을 호출

문제 발생

rememberUpdatedState 사용 안 함:

  • LaunchedEffect는 초기 onTimeout 람다를 사용합니다.
  • 5초 후 navController.navigate("nextScreen")가 호출됩니다.
  • 최신 람다(예: navController.navigate("alternativeScreen"))는 호출되지 않습니다.

rememberUpdatedState 사용

  • LaunchedEffect는 항상 최신 onTimeout 람다를 참조합니다.
  • 5초 후 navController.navigate("alternativeScreen")가 호출됩니다.
  • 최신 상태에 맞는 동작이 실행됩니다.

DisposableEffect: 정리가 필요한 효과

키가 변경되거나 컴포저블이 컴포지션을 종료한 후 정리해야 하는 부수 효과의 경우 DisposableEffect를 사용하면 된다.

DisposableEffect 키가 변경되면 컴포저블이 현재 효과를 삭제(정리)하고 효과를 다시 호출하여 재설정해야 한다.

예를 들면 LifecycleObserver를 사용하여 Lifecycle 이벤트를 기반으로 애널리틱스 이벤트를 전송할 수 있다.

컴포즈에서 이 이벤트를 수신 대기하려면 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 */
}

위의 코드에서는 EffectobserverlifecycleOwner에 추가한다. lifecycleOwner가 변경되면 효과가 삭제되고 새 lifecycleOwner로 다시 시작된다.

DisposableEffectonDispose절을 코드 블록의 마지막으로 포함해야 한다.

SideEffect: Compose 상태를 Compose가 아닌 코드에 게시

컴포즈에서 컴포즈에서 관리하지 않는 객체와 컴포즈 상태를 공유할 때는 SideEffect 컴포저블을 사용하면 된다.

SideEffect를 사용하면 리컴포지션에 성공할 때마다 Effect가 실행된다.

반면 컴포저블에 Effect를 직접 작성하는 경우 성공적인 리컴포지션이 보장되기 전에 Effect를 실행하는 것을 잘못되었다.

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

간단하게 정리하자면 SideEffect는 리컴포지션이 성공적으로 완료될 때마다 실행되는 코드 블록을 정의하고, 주로 Compose 외부의 상태를 업데이트하거나 동기화하는 데 사용된다.

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

LaunchedEffect와 SideEffect의 차이

  1. 주 용도
  • LaunchedEffect: 비동기 작업이나 데이터 로딩 등의 작업을 실행하는 데 사용된다.
  • SideEffect: 리컴포지션이 완료된 후 Compose 외부의 상태를 업데이트하는 데 사용된다.
  1. 비동기 작업
  • LaunchedEffect: 코루틴을 사용하여 비동기 작업을 실행할 수 있다.
  • SideEffect: 비동기 작업을 직접 실행하지 않고, 주로 상태 업데이트에 사용된다.
  1. 재실행 조건
  • LaunchedEffect: 지정된 키가 변경될 때마다 블록 내의 작업이 다시 실행됩니다.
  • SideEffect: 리컴포지션이 완료될 때마다 블록 내의 작업이 실행됩니다.

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

produceState는 반환된 State로 값을 푸시할 수 있는 컴포지션으로 범위가 지정된 코루틴을 실행한다.

비 컴포즈 상태를 컴포즈 상태로 변환하려면, 예를 들어 Flow, LiveData 또는 RxJava와 같은 외부 구독 기반 상태를 컴포지션으로 변환하려면 이 코루틴을 사용해야 한다.

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

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

@Composable
fun loadNetworkImage(url: String): State<ImageBitmap?> {
    // produceState를 사용하여 네트워크에서 이미지를 로드하고 Compose 상태로 변환합니다.
    return produceState<ImageBitmap?>(initialValue = null, url) {
        // 네트워크에서 이미지를 로드하는 가상 함수
        value = loadImageFromNetwork(url)
        // 컴포지션이 종료되면 구독을 취소합니다.
        awaitDispose {
            // 여기에서 구독을 정리하는 코드
            println("Subscription disposed")
        }
    }
}

// 가상 네트워크 이미지 로드 함수
suspend fun loadImageFromNetwork(url: String): ImageBitmap? {
    // 네트워크에서 이미지를 로드하는 데 시간이 걸리는 작업 시뮬레이션
    delay(3000)
    // 실제 네트워크 로드 대신 null 반환 (여기서 실제 이미지 로드 로직이 들어감)
    return null
}

@Composable
fun ImageScreen(url: String) {
    val imageState by loadNetworkImage(url)

    // 이미지를 로드하는 동안 로딩 상태를 표시
    if (imageState == null) {
        Text("Loading image...")
    } else {
        // 이미지가 로드되면 이미지를 표시
        Image(bitmap = imageState!!, contentDescription = null)
    }
}
💡 리턴 타입이 있는 컴포저블은 일반 Kotlin 컨벤션처럼 소문자로 시작하는 이름을 지정해야 한다.

produceState 같은 경우에는 뷰모델을 사용하여 Compose 외부에서 상태를 관리하는 일반적인 방식으로 개발을 할 때는 딱히 많이 사용하는 사례는 없을 것 같다.

derivedStateOf: 하나 이상의 상태 객체를 다른 상태로 변환

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

컴포저블에 관한 입력이 리컴포지션해야 하는 것보다 더 자주 변경되는 경우 derivedStateOf 함수를 사용해야 한다. 이는 스크롤 위치와 같이 항목이 자주 변경되지만 컴포저블은 특정 기준점을 넘어야만 반응해야 할 때 발생한다. derivedStateOf는 필요한 만큼만 업데이트되는 관찰 가능한 새로운 Compose 상태 객체를 만든다.

즉, 상태가 변경되더라도 특정 조건이 충족될 떄만 관찰자에게 변경을 알리므로 불필요한 리컴포지션을 줄이는 데 유용하다.

@Composable
fun ScrollExample() {
    // 스크롤 상태를 기억합니다.
    val listState = rememberLazyListState()

    // 스크롤 위치가 특정 기준점 (예: 100dp) 을 넘었는지 여부를 derivedStateOf를 사용하여 파생합니다.
    val hasScrolledPastThreshold by derivedStateOf {
        listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 100
    }

    // UI 업데이트: 스크롤 위치가 기준점을 넘었을 때만 리컴포지션 됩니다.
    if (hasScrolledPastThreshold) {
        Text("Scrolled past threshold!")
    } else {
        Text("Scroll down to see the effect")
    }

    LazyColumn(state = listState) {
        items(100) { index ->
            Text("Item #$index")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewScrollExample() {
    ScrollExample()
}
  1. 스크롤 상태 관리
  • rememberLazyListState를 사용하여 LazyColumn의 스크롤 상태를 기억한다. 이는 현재 스크롤 위치와 관련된 상태를 관리한다.
  1. 파생 상태 생성
  • derivedStateOf를 사용하여 hasScrolledPastThreshold라는 파생 상태를 생성하고, 이 상태는 스크롤 위치가 특정 기준점을 넘었는지 여부를 나타낸다.
  • listState.firstVisibleItemIndexlistState.firstVisibleItemScrollOffset를 관찰하여 스크롤 위치가 기준점을 넘었는지 확인한다.
  1. UI 업데이트
  • hasScrolledPastThreshold 상태에 따라 텍스트를 표시한다.
  • 이 상태는 derivedStateOf를 사용하여 파생되었기 때문에, 스크롤 위치가 변경되더라도 기준점을 넘지 않는 한 리컴포지션이 발생하지 않는다.

snapshotFlow: Compose의 상태를 Flow로 변환

snapshotFlow를 사용하여 State<T>객체를 콜드 Flow로 변환한다. snapshotFlow는 수집될 때 블록을 실행하고 읽은 State 객체의 결과를 내보낸다. snapshotFlow 블록 내에서 읽은 State 객체의 하나가 변경되면 새 값이 이전에 내보낸 값과 같지 않은 경우 Flow에서 새 값을 collector에 내보낸다. ( 이 동작은 Flow.distictUntilChanged의 동작과 비슷함)

@Composable
fun ScrollAnalytics() {
    // 스크롤 상태를 기억합니다.
    val listState = rememberLazyListState()

    // 현재 컴포지션의 CoroutineScope를 얻습니다.
    val coroutineScope = rememberCoroutineScope()

    // 스크롤 위치를 관찰하여 Flow로 변환합니다.
    val scrollFlow = remember {
        snapshotFlow { listState.firstVisibleItemIndex }
    }

    // CoroutineScope 내에서 Flow를 수집하고 상태 변화를 관찰합니다.
    LaunchedEffect(Unit) {
        coroutineScope.launch {
            scrollFlow
                .filter { it > 0 } // 첫 번째 항목을 넘었는지 확인
                .distinctUntilChanged() // 동일한 값을 여러 번 내보내지 않음
                .collect {
                    // 첫 번째 항목을 넘었을 때 애널리틱스 이벤트 기록
                    AnalyticsLibrary.logEvent("Scrolled past first item")
                }
        }
    }

    // UI 구성
    LazyColumn(state = listState) {
        items(100) { index ->
            Text("Item #$index")
        }
    }
}

Effect 다시 시작

LaunchedEffect, produceState, DisposableEffect와 같은 컴포즈의 일부 Effect에서 실행 중인 Effect를 취소하는 데 사용되는 가변적인 수의 인수를 취하고 새 Key로 새 Effect를 시작한다.

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

대체적으로 Effect 코드 블럭에 사용되는 변경할 수 있는 변수와 변경할 수 없는 변수는 Effect 컴포저블에 매개변수로 추가해야 한다. 이 매개변수 외에 Effect를 강제로 다시 시작하도록 더 많은 매개변수를 추가할 수 있다.

변수를 변경해도 효과가 다시 시작되지 않아야 하는 경우 변수를 rememberUpdatedState에 래핑해야 한다.

변수가 키가 없는 remember에 래핑되어 변경되지 않으면 변수를 EffectKey로 전달할 필요가 없다.

@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는 삭제되거나 다시 시작되지 않는다.

참고

https://developer.android.com/develop/ui/compose/side-effects

profile
🚀 상상을 좋아하는 개발자

0개의 댓글