본문 바로가기

안드로이드/개념정리

[Compose] SideEffect

반응형

SideEffect란

Composable 범위 밖에서 발생하는 state 변경을 말하며, 이것을 예측 가능한 방식으로 제어하기 위해 Compose에서 제공하는 SideEffect Api를 사용합니다.

Compose의 상태는 UI 갱신의 기준으로 사용되며 상태 관리를 잘못해서 무분별하게 UI 갱신이 이뤄질 경우 앱의 성능이 떨어질 수 있기 때문 입니다.

 

기본적으로 Composable은 바깥쪽에서 안쪽, 단방향으로 state를 전달한다.

또 다수의 Composable을 겹쳐서 사용하는데 각 Composable 마다 Lifecycle이 별도로 가지고 있다

 

하지만 안쪽에서 바깥쪽 state를 변경해야 하거나, Coposable이 아닌 곳의 state를 변경해야 하는 경우가 생기는데 이를 SideEffect라  칭하며 이로인해 발생할 수 있는 예외 상황을 컨트롤 하고자 SideEffect Api를 제공합니다.

 

SideEffect 사용하기전 Compose에 lifecycle에 대해 이해하고 있어야 합니다.

Composable이 화면상에 나타나는 순간 Enter the Compositon이며 state 변경시 recompostion이 발생하고 화면상에서 사라질때를 Leave the Composition이라 합니다.

 

SideEffect

Composition 완료후 수행할 작업이 있을때 사용하며 recomposition시마다 실행됩니다.

coroutine scope가 아니기 때문에 recompostion시 기존 작업이 취소되지 않으며, 취소를 원할  경우 DisposableEffect를 사용해야 합니다.

 

출처 

왼쪽 예제에서 Button 클릭시 SideEffect가 실행되고 오른쪽 예제에선 실행되지 않습니다.

왼쪽 맨 아래 Text가 count를 사용하고 있기 때문에 count 값이 바뀔때마다 Counter Composable 함수가 recompose 됩니다.

 

 

LaunchedEffect

Composable 함수내에서  특정 키 값이 변경될때마다 실행시킬 로직이 있거나 1회만 실행시킬 경우 사용하며 suspend 함수를 호출할때 사용합니다.

Composable Lifecycle을 따르기 때문에 Copomse 화면이 보이면 실행되고 화면에서 사라지면 실행중이던 작업은 취소 됩니다.

( Leave Compostion ) 

첫 Composable시 입력해야 하는 TextField에 포커싱을 주고 키보드가 노출되도록 하기 위한 코드입니다.

        LaunchedEffect(Unit) {
            emailFocusRequester.requestFocus()
        }

 

tabIndex가 변경됨에 따라 'scrollupEvent' 메소드가 호출되는 코드입니다.

    LaunchedEffect(key1 = tabIndex) {
        scrollUpEvent()
    }

 

여기서 주의해야 하는건 Lifecycle상 Enter the Composition시에 실행되며 

Leve the Composition( 화면상에서 사라질때 )후 다시 Enter the Composition시에도 실행됩니다. 

@Composable
fun Test(){

    Text(text = "test")
    LaunchedEffect(key1 = Unit) {
        L.d("LaunchedEffect")
    }
}

@Composable
fun TestParent(){
    var isShow by remember {
        mutableStateOf(true)
    }
    
    Column {

        if(isShow){
            Test()
        }

        Button(onClick = {
            isShow = !isShow
        }) {
            Text(text = "클릭")
        }
    }
}

로그를 확인했을때 'isShow' 변경에 따라 Test Composable이 사라졌다 다시 나탔을때도 'LaunchedEffect' 로그가 찍히게 됩니다.

 

 

rememberCoroutineScope
LaunchedEffect와 동일하게 suspend 함수 호출할때 사용되며 Composable Lifecycle을 따르기 때문에 Copomse 화면이 보이면 실행되고 화면에서 사라지면 실행중이던 작업은 취소 됩니다.

 

차이점이라면 LaunchedEffect는 Composition 내부에서만 사용 가능하지만 rememberCoroutinScope로 생성된 스코프는 Composition이 아닌곳에서도 호출이 가능하다.

대표적으로 버튼 클릭시 로직 수행할 경우 많이 사용합니다.

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

 

 

DisposableEffect

LaunchedEffect와 동일하게 key값이 변경될때마다 실행됩니다.

차이점이라고 하면 Leave Compostion이나 Recomposition시 onDispose가 실행되어 해제할 리소스가 있을 경우 사용합니다.

 

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

 

 

rememberUpdatedState

compose에서 state는 remember 키워드를 사용해 Recomposition시 값이 유지되도록 하는데 

rememberUpdatedState 키워드를 사용하면 값이 유지되지 않습니다.

주로 LaunchedEffect에서 오랜 시간 작업해야 하는 경우 많이 쓰인다고 하네요! 

자세한 예시가 나와 있는 블로그가 있어 설명을 줄일테니 참고해주세요! 

 

 

derivedStateOf

remember 키워드와 함께 사용되며 값이 변경 되었을때만 state를 갱신하여 불필요한 Recomposition을 줄입니다.

 

remember 키워드만 사용 했을때 입력되는 username에 따라 'submitEnabled' 상태 값이 같음에도 계속 갱신됩니다.

 

derivedStateOf 키워드와 함께 사용 했을 경우 'submitEnabled' 값이 변경될때만 갱신되는걸 볼수 있습니다.

 

아래 코드처럼 스크롤 첫번째 인덱스 변경에대해 변수로 만들어서 사용할수도 있습니다.

    val firstVisibleIndex =
        remember { derivedStateOf { scrollState.firstVisibleItemIndex } }

 

 

produceState

state가 아닌 값을 state로 변환할 때 사용합니다.

initialValue를 remeber 키워드를 사용하여 저장하고 LaunchEffect를 통해 key값이 변경될 때마다 producer 영역이 실행되며 initialValue와 같은 타입을 반환합니다.

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

 

대표적인 예로 flow를 Compose sate로 변경할때 사용하는 'collectAsStateWithLifeCycle' 메소드를 보면 내부에서 produceState를 사용하는걸 알수 있습니다. 

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

 

또 ProduceStateScopeImpl는 awaitDispose 함수를 가지고 있는데 해당 함수는 key값이 변경되어 producer 영역이 재 실행 되기 전에 실행되므로 종료시 정리가 필요한 코드를 작성하면 되겠습니다.

 

아래 예시는 타이머를 세기위한 코드로 참고만 해주시고 다른 포스팅에 더 자세한 실 사용 예시들을 기재해 보겠습니다.

            var isResumeTimer by remember {
                mutableStateOf(false)
            }
            
            Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .setPadding(top = 12)
                ) {

                    val coroutineScope = rememberCoroutineScope()

                    val timer by produceState(initialValue = 600L, key1 = isResumeTimer, producer = {
                        var job: Job? = null

                        if(isResumeTimer){
                            job = coroutineScope.launch {
                                while(true){
                                    L.d("value=${value}")
                                    delay(1000)
                                    value -= 1
                                }
                            }
                        }

                        awaitDispose {
                            job?.cancel()
                        }
                    })

                }

 

 

snapshowFlow

compose state를 flow로 변경해주는 api입니다.

compose 영역에서 외부로 값을 전달할때 유용한 api로 변경된 값이 compose 영역이 아니기 때문에 다른 sideEffect api와 함께 쓰입니다.

변경하는 이유는 flow operator를 이용하여 효율적인 코드를 작성하기 위함입니다.

    LaunchedEffect(key1 = offset) {
        snapshotFlow { scrollState.firstVisibleItemIndex }
            .map { index -> (index + 5 >= items.size) }
            .distinctUntilChanged()
            .filter { it == true }
            .collect{
                brandVM.moreFavoriteBrand()
            }
    }

첫번째 보이는 아이템 index 값에 +5를 더했을때 토탈 아이템 갯수보다 많을 경우 다음 페이지의 브랜드를 불러오는 코드 입니다.

 

마치며

컴포즈를 처음 도입할땐 '클릭 이벤트는 어떻게 하지?' 등 필요할때 찾아서 했었는데 

전체적으로 공부하고 나니 리펙토링할 부분들이 보여서 적용 해보고 관련 포스팅도 작성해 보도록 하겠습니다.

 

 

 

 

반응형

'안드로이드 > 개념정리' 카테고리의 다른 글

[AOS] Kotlin 확장 함수  (0) 2021.10.08
[AOS] Navigation Component  (0) 2021.10.05