Android

[Jetpack Compose] Compose의 Side Effect (부작용)

도우 2024. 9. 10. 19:17
반응형

 

Jetpack Compose에서 Side Effect(부작용)는 컴포저블 함수의 스코프 밖에서 애플리케이션 상태가 변경될 때 발생하는 변화를 의미한다. 컴포저블 함수는 주기적으로 재구성(Recomposition)되며, 이러한 재구성은 예측하기 어려운 순서로 실행되거나 폐기될 수 있기에, 컴포저블 함수 내에서 부작용이 발생하면 불안정한 동작을 유발할 수 있다. 따라서 컴포저블 함수는 기본적으로 부작용이 없는 것이 이상적이다.

하지만 부작용이 필요한 경우가 있다. 특정 상태에 따라 SnackBar를 표시하거나 다른 화면으로 전환하는 등의 작업이 필요할 때는 부작용이 발생할 수밖에 없다. 이런 작업들은 컴포저블 함수의 생명 주기를 제어할 수 있는 환경에서 수행되어야 한다. 이를 위해서 Jetpack Compose는 다양한 부작용(Side Effect) 처리 API를 제공한다.

 

상태 및 API 사용 사례

컴포저블에는 Side Effect가 없어야 한다. 앱 상태를 변경해야 하는 경우 이러한 Side Effect가 예측 가능한 방식으로 실행되도록 Effect API를 사용해야 한다.

효과 : UI를 내보내지 않으며 컴포지션이 완료될 때 부수 효과를 실행하는 구성 가능한 함수

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

 

1. LaunchedEffect : 컴포저블 내에서 suspend 함수 실행

LaunchedEffect는 컴포저블의 생명 주기를 따라 작업을 수행하고, 그 과정에서 suspend 함수를 호출할 수 있게 해준다. LaunchedEffect가 컴포지션에 진입하면 코루틴을 시작하며, 이는 LaunchedEffect가 컴포지션에서 벗어나면 자동으로 취소된다. 또한, LaunchedEffect가 다른 키값으로 재구성되면 기존 코루틴은 취소되고 새로운 코루틴이 시작된다.

var pulseRateMs by remember { mutableStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) {
    while (isActive) {
        delay(pulseRateMs)
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}

위 코드에서 애니메이션은 정지 함수를 사용한다. delay 드림 설정된 시간 동안 대기한다. 그런 다음 순차적으로 알파 버전을 0으로 다시 되돌린다. animateTo 이 작업은 컴포저블 수명 동안 반복된다.

 

 

2. rememberCoroutineScope : 컴포저블 외부에서 코루틴 실행

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

 rememberCoroutineScope는 호출되는 컴포지션의 지점에 바인딩된 CoroutineScope를 반환하는 구성 가능한 함수이다. 호출이 컴포지션을 종료하면 범위가 취소된다. 아래는 Button을 탭할 때 이 코드를 사용하여 Snackbar를 표시할 수 있다.

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    scope.launch {
                        snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

 

 

3. rememberUpdatedState : 값이 변경되어도 효과가 재시작되지 않게 처리

LaunchedEffect는 키 값이 변경되면 재시작된다. 하지만 일부 경우에는 값이 변경되어도 효과가 재시작되지 않도록 하고 싶을 때가 있다. 이때 rememberUpdatedState를 사용해 값을 참조하여 효과가 재시작되지 않도록 처리할 수 있다. 아래 코드에서는 LandingScreen이 일정 시간이 지나면 사라지는 상황에서, 재구성되더라도 딜레이가 다시 시작되지 않도록 하는 예제이다.

@Composable
fun LandingScreen(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }
}

 

 

4.  DisposableEffect : 정리 작업이 필요한 경우

DisposableEffect는 컴포지션이 벗어나거나 키 값이 변경될 때 정리 작업이 필요한 부작용을 처리하는 데 사용된다. 키가 변경되면 현재 효과가 정리되고 새로운 효과가 실행된다.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit,
    onStop: () -> Unit
) {
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)

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

 

 

5. SideEffect : Compose 상태를 Compose 외부 코드로 전달

SideEffect는 컴포지션이 성공적으로 완료된 후 실행되어, Compose 상태를 Compose가 관리하지 않는 외부 코드와 공유할 때 사용된다.

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember { FirebaseAnalytics() }
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

 

6. produceState : Compose 외부의 상태를 Compose 상태로 변환

produceState는 컴포지션 범위 내에서 값을 생성하고 상태로 반환한다. 예를 들어, Flow나 LiveData와 같은 외부의 비동기 상태를 Compose 상태로 변환할 때 유용하다.

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
    return produceState(initialValue = Result.Loading, url, imageRepository) {
        val image = imageRepository.load(url)
        value = if (image == null) Result.Error else Result.Success(image)
    }
}

 

7. derivedStatedOf : 상태를 변환하여 불필요한 재구성 방지

derivedStatedOf는 여러 상태 객체가 자주 변하지만 UI가 그에 비례해 재구성될 필요가 없을 때 사용된다.

@Composable
fun MessageList(messages: List<Message>) {
    val listState = rememberLazyListState()
    LazyColumn(state = listState) { /* ... */ }

    val showButton by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 0 }
    }

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

 

8. snapshotFlow : Compose의 상태를 Flow로 변환

snapshotFlow는 State 객체를 Flow로 변환하여 활용할 수 있게 해준다. 이때 Flow의 연산자를 사용할 수 있다.

val listState = rememberLazyListState()

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

 

 

반응형