티스토리 뷰

728x90

 

 

 

Compose Side Effect 이해하기! (1)

Side Effect란?컴포즈에서 Side Effect는 컴포저블 함수의 밖에서 발생하는 앱 상태에 대한 변경사항이다.예를 들어 사용자가 버튼을 클릭하면 다른 화면이 열리거나, 앱이 인터넷이 연결되어 있지 않

thdbs523.tistory.com

 

저번 포스팅에서 Side Effect이 어떤 건지 알아보고,

Side Effect와 관련된 컴포저블 함수 LaunchedEffect, DisposableEffect, SideEffect에 대해 알아봤다.

 

  • rememberCoroutineScope
  • rememberUpdatedState
  • derivedStateOf
  • produceState
  • collectAsStateWithLifecycle
  • snapshotFlow

이번엔 State를 더 효율적으로 관리하기 위한 여섯 개의 API를 알아보려고 한다!

 

 

rememberCoroutineScope

@Composable
inline fun rememberCoroutineScope(
    crossinline getContext: @DisallowComposableCalls () -> CoroutineContext = {
        EmptyCoroutineContext
    }
): CoroutineScope

rememberCoroutineScope는 함수를 호출한 컴포지션의 CoroutineScope를 반환하는 컴포저블 함수다.

따라서 컴포지션을 종료하면 코루틴 스코프도 취소된다.

컴포저블 외부에서 컴포지션의 생명주기와 동일한 코루틴을 실행하려는 경우 주로 사용한다.

 

rememberCoroutineScope 사용 예시

일부 Compose API는 suspend 함수다.

만약 이 Compose API를 컴포저블 함수 바깥에서 호출해야 한다면, LaunchedEffect를 사용할 수도 없을 것이다.

가장 대표적인 예시로 Snackbar가 있다.

 

val coroutineScope = rememberCoroutineScope()
val snackBarHostState = remember { SnackbarHostState() }

Scaffold(
    snackbarHost = { SnackbarHost(snackBarHostState) },
) { innerPadding ->
    Button(
        onClick = {
            coroutineScope.launch {
                snackBarHostState.showSnackbar("Button Click")
            }
        },
    ) {
        Text(text = "Snackbar Button")
    }

SnackBarHostState의 showSnackbar 함수는 suspend 함수다.

컴포저블이 아닌 버튼의 onClick 블록에서 실행되기 때문에, LaunchedEffect를 사용할 수 없다.

이때 컴포저블의 생명주기와 동일한 스코프를 통해 코루틴을 생성하면 showSnackbar를 호출할 수 있다.

컴포저블이 종료되면 해당 코루틴도 종료되기 때문에 안전하다.

 

LaunchedEffect vs rememberCoroutineScope

LaunchedEffect와 rememberCoroutineScope 모두 코루틴을 생성하고 suspend 함수를 호출하기 위해 사용한다.

그럼 LaunchedEffect를 대신해서 rememberCoroutineScope를 사용할 수 있을까?

결론부터 말하자면 그렇지 않다!

 

LaunchedEffect는 컴포저블 함수이므로 컴포저블 함수 내에서만 호출할 수 있다.

컴포지션과 연관된 Side Effect를 실행하고자 할 때 사용한다.

 

rememberCoroutineScope는 컴포저블과 동일한 코루틴 스코프를 생성하기 때문에, 컴포저블 외부에서 중단 함수를 호출할 수 있다.

코루틴 스코프로 생성한 코루틴은 컴포지션 여부와 무관하게 실행된다.

 

만약 컴포저블 함수에서 LauncedEffect 대신 scope.launch 를 사용했다면,

리컴포지션이 발생하여 컴포저블 함수가 호출될 때마다 코루틴이 계속 실행될 것이다.

따라서 리소스를 낭비하게 되며, LaunchedEffect를 대신해서 rememberCoroutineScope를 사용할 수 없다.

둘의 용도를 구분하여 적절히 사용해야 한다.

 

 

rememberUpdatedState

@Composable
fun <T : Any?> rememberUpdatedState(newValue: T): State<T>

rememberUpdatedState는 항상 최신 값으로 State를 업데이트하는 컴포저블 함수다.

 

@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
    mutableStateOf(newValue)
}.apply { value = newValue }

내부 구현도 매우 간단하다.

인자로 들어온 newValue로 MutableState를 생성하고, 인자로 들어온 값이 변경될 때마다 value에 새로운 값을 대입한다.

즉, 항상 최신의 값이 State에 반영된다.

 

rememberUpdatedState 사용 예시

먼저 rememberUpdatedState를 사용하지 않았을 때의 예시를 보자.

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime)
            onTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

위 예시에서는 필요한 데이터를 모두 로드한 후, onTimeout을 실행시켜 랜딩 화면을 종료시킨다. (데이터 로드를 delay로 대체했다)

하지만 위 코드는 문제가 있다.

데이터 로드나 delay와 같은 Side Effect가 실행되는 동안 onTimeout이 변경되면, Side Effect가 끝난 후 최신 상태의 onTimeout이 호출된다는 보장이 없다.

이를 해결하기 위해 rememberUpdatedState를 사용할 수 있다.

 

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        LaunchedEffect(Unit) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

rememberUpdatedState를 사용하여 onTimeout을 저장한다.

만약 Side Effect가 실행되는 동안 onTimeout이 변경되어도, rememberUpdatedState 내부에서 State를 업데이트한다.

 

만약 onTimeout이 rememberUpdatedState이 아닌 일반 State였다면 어떨까?

변경사항이 발생하더라도, value에 직접 최신 값을 대입해준 것이 아니기 때문에 이전 예제와 동일한 문제가 발생할 것이다.

반면에 rememberUpdatedState는 값이 변경될 때마다 value에 값을 대입하기 때문에 항상 최신 값이라는 것을 보장한다.

 

rememberUpdatedState는 람다가 Side Effect 동안 계속 유지되거나, 컴포지션으로 계산되는 값을 참조하는 경우에 사용한다.

LaunchedEffect에서 람다를 호출하는 경우 일반적으로 rememberUpdatedState를 사용한다.

 

 

derivedStateOf

@StateFactoryMarker
fun <T : Any?> derivedStateOf(calculation: () -> T): State<T>

derivedStateOf는 다른 State로 새로운 State를 계산하고 싶을 때 사용한다.

derivedStateOf의 calculation 블록은 블록 내부에서 사용된 State가 변경될 때마다 실행된다.

하지만 컴포저블 함수는 기존 값과 계산 결과값이 다를 때만 리컴포지션된다.

 

derivedStateOf 사용 예시

val showButton = listState.firstVisibleItemIndex > 0

firstVisibleItemIndex는 현재 화면에서 가장 첫 번째로 보이는 리스트 아이템의 인덱스를 나타낸다.

firstVisibleItemIndex는 스크롤할 때마다 변경되기 때문에, showButton도 그만큼 자주 변경되고, 자주 리컴포지션된다.

 

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

하지만 derivedStateOf를 사용하면 true나 false로 값이 변경될 때만 리컴포지션된다.

즉, firstVisibleItemIndex가 변경되어도, 실제 showButton의 값이 변경되었을 때만 리컴포지션되어 최적화할 수 있다.

 

 

produceState

@Composable
fun <T : Any?> produceState(
    initialValue: T, 
    producer: suspend ProduceStateScope<T>.() -> Unit
): State<T>

produceState를 사용하면 Compose가 아닌 상태를 Compose State로 변환할 수 있다.

예를 들어 Flow, LiveData, RxJava와 같은 Observable 객체를 State로 변환한다.

하나의 State를 반환하기 때문에, 동일한 값을 설정하면 리컴포지션이 발생하지 않는다.

  • initialValue를 통해 반환된 State의 초기값을 지정한다.
  • producer 블록 내에서는 State의 value를 사용해 State에 값을 지정한다.

 

val currentPerson by
    produceState<Person?>(null, viewModel) {
        val disposable = viewModel.registerPersonObserver { person -> value = person }
        awaitDispose { disposable.dispose() }
    }

코루틴을 사용하지만, 중단되지 않는 경우에도 produceState를 사용할 수 있다.

이 때 produceState 블록의 코루틴이 종료되는 경우 (컴포지션 종료, 데이터 변경, 오류 발생)  awaitDispose이 실행된다.

awaitDispose를 사용하여 자원을 해제한다.

 

@Composable
fun <T> produceState(
    initialValue: T,
    producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffect(Unit) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

내부 구현을 보면 MutableState와 LaunchedEffect를 사용하고 있는 것을 알 수 있다.

initialValue를 가진 mutableState를 생성하고, LauncedEffect 내에서 producer를 호출한다.

즉, producer 블록을 실행하는 코루틴은 컴포지션이 시작하면 실행되고, 컴포지션이 종료되면 취소된다.

 

produceState에 key 지정하기

@Composable
fun <T> produceState(
    initialValue: T,
    vararg keys: Any?,
    producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    LaunchedEffect(keys = keys) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

LaunchedEffect와 마찬가지로 produceState도 여러 key를 지정할 수 있다.

key들 중 한 값이 바뀔 때마다 producer 블록이 호출된다.

 

produceState 사용 예시

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }
}

위 예시에서는 ViewModel의 값을 통해 State<DetailsUiState>로 변환한다.

 

일반적으로 ViewModel에서는 Flow나 LiveData로 UiState를 가지고 있다. 또는 State를 직접 가지고 있는 경우도 있다.

보통 결과값에 따라 UiState를 달리하는 로직은 ViewModel에 존재하고, 컴포저블 함수에서는 이를 State로 변환하기만 한다.

따라서 매핑 로직을 작성하기에 적합한 produceState 함수를 사용할 일이 없다.

 

만약 ViewModel에서 Observable 객체가 아닌 값을 가지고 있거나, 컴포저블 함수에서 직접 데이터를 로드하여 UiState를 매핑해야 하는 경우 사용한다.

Flow나 LiveData를 State로 변환하는 컴포저블 함수도 내부적으로 produceState를 사용하고 있다.

 

 

collectAsStateWithLifecycle

@Composable
fun <T : Any?> Flow<T>.collectAsStateWithLifecycle(
    initialValue: T,
    lifecycle: Lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T>

collectAsStateWithLifecycle를 사용하면 Flow를 State로 변환할 수 있다.

Flow 값이 수집될 때마다 State가 변경되어 리컴포지션 된다.

 

@Composable
fun <T> Flow<T>.collectAsStateWithLifecycle(
    initialValue: T,
    lifecycle: Lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T> {
    return produceState(initialValue, this, lifecycle, minActiveState, context) {
        lifecycle.repeatOnLifecycle(minActiveState) {
            if (context == EmptyCoroutineContext) {
                this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
            } else withContext(context) {
                this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
            }
        }
    }
}

내부적으로는 위에서 설명했던 produceState를 사용하고 있다.

lifecycle.repeatOnLifecycle은 Android View와 MVVM을 사용하면서 많이 작성했을 코드다.

즉, repeatOnLifecycle을 통해 생명주기에 따라 Flow를 옵저빙해주고, Flow가 collect될 때마다 새로운 State의 value에 넣어주는 것이다.

 

collectAsState와의 차이

collectAsState는 collectAsStateWithLifecycle와 동일하게, State로 Flow의 값을 수집하는 함수다.

그렇다면 둘의 차이는 뭘까?

함수명에서 알 수 있듯이, collectAsStateWithLifecycle는 생명 주기를 인식한다는 것이다.

 

collectAsStateWithLifecycle의 매개변수 minActiveState는 기본적으로 Lifecycle.State.STARTED이다. 

현재 화면의 생명 주기가 최소 STARTED일 때 값을 수집하고, STOP 이후에는 값을 수집하지 않는다.

즉, 앱이 백그라운드에 있을 때는 값을 수집하지 않기 때문에 불필요한 리소스 낭비를 방지할 수 있다.

minActiveState를 사용해서 값을 수집하거나 중지할 최소 상태를 지정할 수도 있다.

 

@Composable
fun <T : R, R> Flow<T>.collectAsState(
    initial: R,
    context: CoroutineContext = EmptyCoroutineContext
): State<R> = produceState(initial, this, context) {
    if (context == EmptyCoroutineContext) {
        collect { value = it }
    } else withContext(context) {
        collect { value = it }
    }
}

반면에 collectAsState는 앱이 백그라운드에 있을 때도 계속 값을 수집한다.

그래서 생명 주기를 관리해야 하는 안드로이드가 아닌 다른 플랫폼에서 Compose를 개발할 때 collectAsState를 사용한다.

 

 

snapshotFlow

fun <T> snapshotFlow(block: () -> T): Flow<T>

snapshotFlow를 사용하면 State를 Flow로 변환할 수 있다.

블록 내의 State가 변경되면 블록이 실행되고, Flow에 값을 내보낸다,

여기서 주의할 점은 새로운 값이 이전에 내보낸 값과 다른 경우에만 Flow에 값을 내보낸다는 점이다.

연속으로 중복된 값을 내보내지 않는다는 점에서는 Flow의 distinctUntilChanged와 비슷하다.

 

snapshotFlow 사용 예시

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

위 코드에서 listState.firstVisibleItemIndex는 Flow로 변환되어 firstVisibleItemIndex이 변경될 때마다 값을 내보낸다.

Flow 이점을 활용하여 새로운 값을 계산하고 싶을 때 주로 사용한다.

 

 

 

 

 

출처

https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3

https://developer.android.com/develop/ui/compose/side-effects?hl=ko

https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects?hl=ko&continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fjetpack-compose-for-android-developers-3%3Fhl%3Dko%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-advanced-state-side-effects#0

https://tourspace.tistory.com/412

 

 

728x90
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/06   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함