Compose

jetpack compose Side-effects(부수효과)

hik14 2024. 2. 7. 03:31

 

side effect 정의

composable 함수 범위 외부에서 발생하는 앱 상태의 변경.

 

composable의 lifecycle 및 속성((예측할 수 없는 recomposition, 다양한 순서로 컴포저블 recomposition,  실행 또는 삭제할 수 있는 recomposition))으로 인해 composable의 이상적인 형태는 side-effect가 전혀 존재하지 않는것이다.

 

그러나 때로는 snack bar와 같은 일회성 이벤트를 트리거하거나 특정 상태 조건이 주어지면 다른 화면으로 이동하는 등의  side-effect이 필요한 경우도 있습니다. 필요한 side-effects는 컴포저블의 수명 주기를 인식하는 제어된 환경에서 이루어져야 한다.

 

State and effect use cases

* Effect - UI를 생성하지 않고, 컴포지션이 완료될 때  side-effect가 실행되도록 하는 composable 함수입니다.

 

Compose 에서는 다양하고 가능한 effects 함수를 지원하기 때문에 잘못 사용하는것에 주의해야 한다.

effects를 사용할땐, ui와 관련된 작업이며, 단방향 데이터 흐름을 방해하지 않는 것인지 확인해봐야한다.

 

LaunchedEffect:(run suspend functions in the scope of a composable)

composable scope 안에서 suspend function 사용하기!

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

 

fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

 

- LaunchedEffect는 컴포지션인 composable 함수에서 매개변수로 전달된 코드 블록을 사용하여 코루틴을 시작

- 컴포지션을 벗어나면 코루틴이 취소됩니다.

- LaunchedEffect가 다른 키로  recomposition되면 이전 코루틴이 취소되고 새로운 suspend funtion이 새 코루틴에서 시작

 

Scaffold에 Snackbar를 표시하는 작업

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

    // UiStaterk error을 포함하고 있으면 snackbar를 보여줌
    if (state.hasError) {

        // `scaffoldState.snackbarHostState` 변한다면,
        // `LaunchedEffect`는 코루틴을 취소하고 새로운 코루틴 시작
        
        LaunchedEffect(snackbarHostState) {
            // 코루틴을 이용해 snackbar를 보여주고, 코루틴이 취소되면 자동으로 snackbar를 닫습니다.
            // 코루틴은 `state.hasError`가 false일 때마다 취소되고,
            // `state.hasError`가 true(위의 if-check로 인해)이거나
            // `scaffoldState.snackbarHostState`가 변경되는 경우에만 시작됩니다.
            snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

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

 

RememberCoroutineScope:  컴포저블 외부에서 코루틴을 실행하기 위해 composition-aware scope 얻기

(LaunchedEffect는 composable 함수로 composable 함수 안에서만 실행가능 함)

 

-RememberCoroutineScope는 "호출된 컴포지션 지점"에 바인딩된 CoroutineScope를 반환하는 composable 함수입니다.

- composable 함수 외부에서 코루틴을 실행하면서, 컴포지션을 벗어나면 자동으로 취소되도록 coroutine scope 를 지정하려면 RememberCoroutineScope를 사용

- 사용자 이벤트가 발생할 때, 애니메이션을 취소하는 등 하나 이상의 코루틴의 수명 주기를 수동으로 제어해야 할 때마다 RememberCoroutineScope를 사용

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // the MoviesScreen's lifecycle과 바인딩된 CoroutineScope를 반환한다.
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // 새로운 코루틴을 생성하여 스넥바를 보여준다.
                    scope.launch {
                        snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

rememberUpdatedState: reference a value in an effect that shouldn't restart  if the value changes

(값의 변경으로, 재시작을 하지 않는 "effect함수 안에 값"을 참조한다.)

 

LaunchedEffect는 주요 매개변수(상태) 중 하나가 변경되면 다시 시작됩니다. 그러나 어떤 상황에서는 Effect 함수안의 value를 저장하고 싶을 수도 있습니다. RememberUpdatedState를 사용하여 값을 캡처하고 업데이트할 수 있는 참조를 생성를 생성할 수 있다.

 

비용이 많이 들거나 다시 만들고 다시 시작하는 데 비용이 많이 들 수 있는 장기 작업이 포함된 Effect에 유용하다.

 

앱에 일정 시간이 지나면 사라지는 LandingScreen이 있다고 가정해 보겠습니다. LandingScreen이 재구성되더라도 일정 시간을 기다렸다가 currentOnTimeout() 함수는 다시 시작하면 안 됩니다.

@Composable
fun LandingScreen(onTimeout: () -> Unit) {

    // 항상 LandingScreen이 재구성된 최신 onTimeout 함수를 참조합니다.
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // LandingScreen의 수명주기와 일치하는 effect를 만듭니다.
    // LandingScreen이 재구성되면 지연이 다시 시작되어서는 안 됩니다.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}

 

* LaunchedEffect(true)는 while(true)만큼 조심히 사용해야 된다. 유효한 사용 사례가 있더라도 항상 잠시 멈추고  필요한지 확인해야됨.

 

call site의 라이프사이클에 맞는 effect를 만들기 위해 Unit 또는 true와 같이 변하지 않는 상수가 매개변수로 전달됩니다. 위 코드에서는 LaunchedEffect(true)가 사용되었습니다. onTimeout 람다에 항상 LandingScreen이 재구성된 최신 값이 포함되도록 하려면 onTimeout을 RememberUpdatedState 함수로 래핑해야 합니다. RememberUpdatedState 반환된 State인 currentOnTimeout을 Effect안에서 사용해야 합니다.

 

DisposableEffect: effects that require cleanup (정리가 필요한 effect)

key 변경 후 정리해야 하는 side-effect 있거나 컴포저블이 컴포지션을 벗어나는 경우 DisposableEffect를 사용

DisposableEffect key가 변경되면 컴포저블은 현재 효과를 삭제(정리 수행)하고 효과를 다시 호출하여 재설정해야 합니다.

예를 들어 LifecycleObserver를 사용하여 수명 주기 이벤트를 기반으로 분석 이벤트를 보낼 수 있습니다. 

Compose에서 이러한 이벤트를 수신하려면 DisposableEffect를 사용하여 필요할 때 Observer를 등록하고 등록 취소 가능하다.

 

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // 'started' 상태가 되었단 event 알려주기
    onStop: () -> Unit // 'stopped' 상태가 되었단 event 알려주기
) {
    
    // 새로운 람다가 제공되면 현재 람다를 안전하게 업데이트
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // `lifecycleOwner`가 변경되면 효과를 삭제하고 재설정하세요.
    DisposableEffect(lifecycleOwner) {
        // remember 콜백을 트리거하는 observer를 만듭니다. 
        //	event를 알려주기 위해서,
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        //  observer를 the lifecycle에 등록
        lifecycleOwner.lifecycle.addObserver(observer)

        // effect가 컴포지션을 벗어나면 observer를 제거합니다.
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

SideEffect: publish Compose state to non-Compose code ( )

Compose에서 관리되지 않는 객체와 Compose 상태를 공유하려면 SideEffect 컴포저블을 사용.

SideEffect를 사용하면 재구성이 성공할 때마다 Effect 함수가 실행됩니다.

 

예를 들어 analytics library를 사용하면, 모든 후속 분석 이벤트에 사용자 지정 메타데이터(이 예에서는 "사용자 속성")를 연결하여 사용자

모집단을 분류할 수 있습니다. 현재 사용자의 사용자 유형을 분석 라이브러리에 전달하려면 SideEffect를 사용하여 해당 값을 업데이트할 수 있다.

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

    // 구성이 성공할 때마다 현재 사용자의 userType으로 FirebaseAnalytics를 업데이트하여 
    // 다음 분석 이벤트에 이 메타데이터(user.userType)가 첨부되도록 합니다.
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

 

produceState: convert non-Compose state into Compose state

producerState는 반환된 state에 값을 푸시할 수 있는 컴포지션으로 범위가 지정된 코루틴을 시작합니다.

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

 

Compose가 아닌 상태를 Compose 상태로 변환합니다.

를 들어 Flow, LiveData 또는 RxJava와 같은 외부 subscribe 기반 상태를 Composition으로 가져옵니다.

 

producer(suspend ProduceStateScope)는 producerState가 컴포지션에 들어갈 때 시작되고, 컴포지션을 떠날 때 취소됩니다. 반환된 State는 병합됩니다. 동일한 값을 설정하면 재구성이 트리거되지 않습니다.

 

producerState가 코루틴을 생성하더라도,  non-suspending sources of data를 관찰하는 데에도 사용할 수 있습니다.

해당 source에 대한 subscribe을 제거하려면 waitDispose 함수를 사용하십시오.

 

예에서는 producerState를 사용하여 네트워크에서 이미지를 로드하는 방법을 보여줍니다.

loadNetworkImage composable 함수는 다른 composable 함수에서 사용할 수 있는 상태를 반환합니다.

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {

    // Result.Loading을 초기값으로 사용하여 State<T>를 생성합니다.
  	// `url` 또는 `imageRepository`가 변경되면 실행 중인 생산자가 취소되고 새 입력으로 다시 시작됩니다.
    
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {

        // 코루틴에서는 suspend 함수 호출을 할 수 있습니다.
        val image = imageRepository.load(url)

        // 오류 또는 성공 Result로 상태 업데이트.
        // 그러면 이 반환되는 State를 읽는 재구성이 트리거됩니다.
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

 

* return 값이 있는 컴포저블의 이름은 일반 Kotlin 함수 이름과 동일하게 소문자로 시작해야 합니다.

 

핵심 포인트: producerState는 내부적으로 LaunchedEffect 함수를 사용합니다!

Remember { mutableStateOf(initialValue) }를 사용하여 result 변수를 보유하고, LaunchedEffect에서 producer 블록을 트리거합니다. producer 블록에서 값이 업데이트될 때마다 결과 상태가 새 값으로 업데이트됩니다.

derivedStateOf: convert one or multiple state objects into another state

Compose에서는 관찰된 State 객체 또는 composable 함수의 파라미터가 변경될 때마다 재구성이 발생합니다. 상태 개체 또는 파라미터는 UI가 실제로 업데이트해야 하는 것보다 더 자주 변경되어 불필요한 재구성이 발생할 수 있습니다.

 

컴포저블에 대한 입력이 재구성해야 하는 것보다 더 자주 변경되는 경우 DerivativeStateOf 함수를 사용.

입력또는 상태가 자주 변경될 때,  컴포저블은 특정 임계값을 넘은 후에만 이에 반응하면 될때 사용한다.

 

derivedStateOf는 필요한 만큼만 업데이트되는 것을 관찰할 수 있는 새로운 Compose 상태 객체를 만듭니다.

Kotlin Flows의 independentUntilChanged() 연산자와 유사하다.

 

* DerivedStateOf는 비용이 높기 때문에 결과가 변경되지 않았을 때 불필요한 재구성을 피하기 위해서만 사용해야 합니다.

 

올바른 사용법

@Composable
// messages 매개변수가 변경되면 MessageList composable이 재구성됩니다.
// DerivedStateOf는 이 재구성에 영향을 주지 않습니다.
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

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

       // 스크롤되는 화면에 첫 번째로 아이템이 첫번째가 아니면, 버튼을 보여준다.
       //불필요한 구성을 최소화하기 위해 기억된 derivedStateOf 를 사용합니다.
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

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

 

메세지 리스트 는 지속적을 바뀌어도 스크롤 되어 첫번째 아이템이 안보여야 버튼을 보여준다.

 

잘못된 사용법

 

흔히 저지르는 실수는 두 개의 Compose 상태 객체를 결합할 때 "deriving state"하기 때문에 DerivedStateOf를 사용해야 한다고 가정하는 것입니다. 

// 이렇게 사용하지 마세요. DerivativeStateOf의 잘못된 사용입니다.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // 잘못된 사용
val fullNameCorrect = "$firstName $lastName" // 옳바른 사용.

 

snapshotFlow: convert Compose's State into Flows

snapshotFlow를 사용하여 State<T> 객체를 콜드 Flow로 변환합니다. snapshotFlow는 collecting되면 블록을 실행하고 그 안에서 읽은 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()
        }
}

 

Restarting effects

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

 

일반적으로 아래와 같은 형태를 띤다.

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

 

- Effect를 필요한 것보다 적게 다시 시작하면,  앱에 버그가 발생할 수 있습니다.

- 필요 이상으로 Effect를 다시 시작하는 것은 비효율적일 수 있습니다.

 

경험상 코드의 Effect(key){ ... } 블록에 사용되는 var 및 val 변수는 Effect를 사용하는 컴포저블에 매개변수로 추가되어야 합니다.

그 외에도 Effect를 강제로 다시 시작하기 위해 더 많은 매개변수를 추가할 수 있습니다.

 

변수 변경으로 인해 Effect가 다시 시작되어서는 안 되는 경우 변수는 RememberUpdatedState에 래핑되어야 합니다.

변수가 key 없이 remember 에 싸여 있어서 절대 변경되지 않는 경우,  변수를 Effect의 키로 전달할 필요가 없습니다.

 

* Effect에 사용되는 변수는 Effect 컴포저블의 매개변수로 추가하거나,  RememberUpdatedState를 사용해야 합니다.

 

 

DisposableEffect 코드에서 Effect는 해당 블록에 사용된 lifecycleOwner를 매개 변수로 사용합니다.

왜냐하면 Effect를 변경하면 Effect가 다시 시작되어야 하기 때문입니다.

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

 

currentOnStart 및 currentOnStop은 RememberUpdatedState 사용으로 인해 컴포지션에서 해당 값이 변경되지 않으므로 DisposableEffect의 Key로 전달해서는 안됩니다.

 

lifecycleOwner를 매개변수로 전달하지 않고 lifecycleOwner가 변경되면, HomeScreen이 재구성되지만 DisposableEffect는 삭제되고 다시 시작되지 않습니다. 해당 시점부터 잘못된 lifecycleOwner가 사용되기 때문에 문제가 발생합니다.

Constants as keys

true와 같은 상수를 Effect key로 사용하여 call site의 수명 주기를 따르도록 할 수 있습니다. 위에 표시된 LaunchedEffect 예제와 같이 유효한 사용 사례가 있습니다. 하지만 그렇게 하기 전에, 다시 한 번 생각해보고 그것이 당신에게 필요한 것인지 확인하세요.