Jetpack Compose(5) - 부수 효과

김재원·2022년 3월 16일
1

JetpackCompose

목록 보기
5/9
post-thumbnail

이 글은 JetpackCompose 공식문서 - 부수 효과를 읽어보면서 정리한 글 입니다.

컴포저블에는 부수 효과(Side-effect)가 없어야 합니다. 하지만 Compose의 동작이 외부의 상태를 변경해야 경우 컴포저블의 수명 주기를 인식하는 관리된 환경에서 부수 효과를 호출해야 합니다.

부수효과란 Composable 함수의 범위 밖에서 앱 상태에 대한 변경사항을 뜻합니다. Composable에는 부수효과가 없어야 하지만, 상태를 변경할 때 필요한 경우가 있습니다. 이번글에서는 Composable에서의 부수 효과에 대한 API들에 대해 정리해보도록 하겠습니다😌.

상태 및 효과 사용 사례

부수 효과를 사용할 때는 부수 효과가 예측 가능한 방식으로 실행될 수 있도록 Effect API를 사용해야합니다.

Effect: UI를 내보내지 않으며 컴포지션이 완료될 때 부수효과를 실행하는 Composable함수

LaunchedEffect: 컴포저블의 범위에서 코루틴 실행

Composable에서 안전하게 정지 함수(Suspend)를 호출하려면 LauncedEffect Composable을 사용해야합니다.

@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    scaffoldState: ScaffoldState = rememberScaffoldState()
) {

    if (state.hasError) {

        LaunchedEffect(scaffoldState.snackbarHostState) {
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(scaffoldState = scaffoldState) {
        /* ... */
    }
}

위 함수에서 state.hasError이 true면 코루틴(showSnackbar)이 트리거 되고, 그렇지 않으면 코루틴이 취소됩니다.

LaunchedEffect는 Composable의 수명 주기를 따릅니다.

실행시점종료시점재실행되는 조건
Composition EnterComposition Leaveparam의 변경

LaunchedEffect의 매개변수에 넣은 값이 변경되면 기존 코루틴을 종료하고 새 코루틴에서 suspend 함수를 실행합니다.

rememberCoroutineScope - 컴포저블 외부에서 코루틴 실행

LaunchedEffect는 composable 함수이기 때문에 Composable 함수 내에서만 호출할 수 있습니다. 그래서 Composable 이외의 부분에서 coroutine scope를 얻기 위해서는 rememberCoroutineScope를 사용해야합니다.
rememberCoroutineScope는 호출한 Composable의 수명주기에 따라 시작/종료됩니다.

실행시점종료시점재실행되는 조건
Composition EnterComposition LeaveX
@Composable
fun Screen(scaffoldState: ScaffoldState = rememberScaffoldState()) {

    val scope = rememberCoroutineScope()

    Scaffold(scaffoldState = scaffoldState) {
        Column {
            Button(
                onClick = {
                    scope.launch { // snackBar를 표시하기 위해 새로운 coroutine을 생성합니다.
                        scaffoldState.snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

Screen의 Composition이 종료되면 scope에 있는 coroutine도 취소됩니다. 따라서 Screen을 떠나면 snackbar도 취소됩니다.

rememberUpdatedState: 값이 변경되는 경우 다시 시작되지 않아야 하는 효과에서 값 참조

LaunchedEffect는 내부 블록의 값이 변경되면 기존 coroutine을 취소하고 새로운 coroutine을 실행합니다.
LaunchedEffect 내부의 값을 변경해야하지만 새로운 coroutine을 실행하지 않아야 하거나 effect가 오랜시간 걸릴 때 작업을 유지해야 할 경우rememberUpdatedState를 사용합니다.

@Composable
fun ShowSnackBar(scaffoldState: ScaffoldState = rememberScaffoldState(), timeoutText: String) {
    val currentTimeoutText by rememberUpdatedState(timeoutText)

    LaunchedEffect(true) {
        try {
            delay(3000L)
            scaffoldState.snackbarHostState.showSnackbar(currentTimeoutText)
        } catch (ce: CancellationException) {

        }
    }

}

timeText가 변하더라도 LaunchedEffect는 재시작하지 않고(매개변수로 true를 고정해두었기 때문) 3초 이후에 snackbar를 띄웁니다.
만약 rememberUpdatedState를 사용하지 않는다면 timeoutText가 변해도 똑같은 Snackbar가 표시될 것 입니다.(lambda capturing으로 final형태로 값이 들어가기 때문)

DisposableEffect: 정리가 필요한 효과

DisposableEffect는 LaunchedEffect와 동작이 동일합니다. 하지만 DinsposableEffectsms Compose leave로 종료할 때 항상onDispose{..} 가 호출됩니다.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit,
    onStop: () -> Unit
) {
    // 새로운 람다식이 들어왔을 때 안전하게 실행할 수 있도록 rememberUpdatedState를 사용합니다.
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // lifecycleOwner가 변경되면 DisposableEffect내에 있는 코루틴이 다시 시작됩니다.
    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)

        // Effect가 Composition을 떠날 때 observer를 삭제합니다.
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

DisposableEffect는 onDispose 절을 코드 블록의 최종 문장으로 적어둬야합니다. 그러지 않으면 IDE에 오류가 표시됩니다.

produceState: 비 Compose 상태를 Compose 상태로 변환

Compose에서는 State<T>를 통해 상태를 관찰하고 Composable을 업데이트 합니다. 따라서 Flow, LiveData, RxJava등의 관찰가능한 타입들을 Compose에서 사용하려면 State로 변환하는 과정이 필요한데, produceState가 그 역할을 합니다.
produceState는 관찰하고 있는 값이 변경되면 State<T>를 반환합니다.
Compose에서는 Flow, LiveData를 위해 State로 변환하는 함수를 지원합니다.

  • LiveData<T> : observeAsState(initial: R)

    observeAsState를 사용하기 위해서는 gradle에
    implementation "androidx.compose.runtime:runtime-livedata:$compose_version"를 추가해야합니다.

  • Flow<T> : collectAsState(initial: R, context: CoroutineContext)

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

이 함수는 다른 State에서 파생된 State를 원할 때 사용됩니다.

@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {

    val todoTasks = remember { mutableStateListOf<String>() }

    val highPriorityTasks by remember(highPriorityKeywords) {
        derivedStateOf { todoTasks.filter { it.containsWord(highPriorityKeywords) } }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) { /* ... */ }
            items(todoTasks) { /* ... */ }
        }
    }
}

위 코드에서 derivedStateOf {}는 todoTasks가 변경될 때 마다 highPriorityTask의 계산이 실행되고, UI가 업데이트됨을 보장합니다.

highPriorityTasks를 계산하는 비용이 많이 들기 때문에 highPriorityKeywords가 변경될 때만 계산해야합니다. 따라서 highPriorityTask를 derivedStateOf로 묶었습니다.

snapshotFlow: Compose의 상태를 Flow로 변환

snapshotFlow를 사용하여 State<T> 객체를 콜드 Flow로 변환합니다.

snapshotFlow 블록 내에서 읽은 State 객체 하나가 변경되었을 때 새 값이 이전에 내보낸 값과 같지 않은 경우 Flow에서 새 값을 수집기에 내보냅니다.

val listState = rememberLazyListState()

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex } // Flow<T>를 반환합니다.
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

효과 다시 시작

LaunchedEffect, produceState, DisposableEffect와 같은 Compose의 일부 효과는 실행중인 효과를 취소하고 새로운 효과를 실행하기 위해 여러개의 매개변수를 받습니다. 매개변수가 변경되면 새로운 효과가 실행됩니다.

효과를 다시 실행할 때는 다음 두가지를 고려해야합니다.

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

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

효과에 사용되는 변수는 효과 컴포저블의 매개변수로 추가하거나 rememberUpdatedState를 사용해야 합니다.

키로 사용되는 상수

키에는 상수(true등)를 넣을 수 있습니다. 이렇게 되면 LaunchedEffect의 경우 단 한번만 coroutine을 실행하게 됩니다. 따라서 상수를 사용할 때는 신중해야합니다.


이렇게 해서 Compose의 효과에 대해 알아보았습니다. 다음 글에서는 Compose의 단계에 대해 다뤄보도록 하겠습니다. 끝까지 읽어주셔서 감사하고 즐거운 개발 되세요🤗.

참고

Compose의 부수 효과
투덜이의 리얼 블로그
Jetpack Compose의 고급 상태 및 부작용

profile
항상 배울 것을 찾는 개발자입니다🔥.

0개의 댓글