본문 바로가기

안드로이드 Android

안드로이드 개발 (11) Compose Side-effects

공부한 내용 정리입니다. 

 

Compose는 Kotlin으로 만들어진 UI Tool입니다. 코틀린의 Ramda를 최대한 이용한 라이브러리이기 때문에 함수형 언어프로그래밍을 같이 공부해주면 좋습니다. 오늘 내용인 Side-effect(부수 효과) 같은 경우도 함수형 언어를 알아야 이해가 되기 때문에 이 포스팅을 보기전에 아래링크를 보시길 권장합니다.

 

https://sojin.io/article/%EC%88%9C%EC%88%98-%ED%95%A8%EC%88%98%EC%99%80-%EB%B6%80%EC%88%98-%ED%9A%A8%EA%B3%BC/

 

순수 함수와 부수 효과

부수 효과는 무엇이고, 왜 순수 함수를 써야 할까?

sojin.io

Side-effect(부수효과)란  함수가 결괏값을 반환하는 것 이외에 다른 일을 할 때 그 함수는 Side-effect를 가진다고 한다.

 

그리고 이 글에는 알아야할 사전 지식들이 있습니다. composable, composition, recomposition, remember 등등을 안다는 가정하에 정리를 한 내용들이라서 이해가 안된다면 이전 포스팅들을 참고하면 좋습니다.

https://gift123.tistory.com/33

 

안드로이드 개발 (8) Compose 이해 정리

이번 포스팅부터 Compose에 대해 차근히 파헤쳐 가보겠습니다. Android Compose 공식 문서를 보면서 정리한 내용들 입니다. https://developer.android.com/jetpack/compose/mental-model?hl=en Compose 이해  |..

gift123.tistory.com

https://gift123.tistory.com/34

 

안드로이드 개발 (9) Compose 상태 관리

jetpack compose 에 한창 포스팅 중입니다. https://gift123.tistory.com/33 안드로이드 개발 (8) Compose 이해 정리 이번 포스팅부터 Compose에 대해 차근히 파헤쳐 가보겠습니다. Android Compose 공식 문서를..

gift123.tistory.com

https://gift123.tistory.com/38

 

안드로이드 개발 (10) Compose Composable Lifecycle

안녕하세요 Loner 입니다. 오늘은 Compose의 컴포저블 라이프사이클 공부를 정리해 봤습니다. 아래 문서를 정리한 내용입니다. https://developer.android.com/jetpack/compose/lifecycle?hl=en 컴포저블 수명 주..

gift123.tistory.com

 

1. Compose의 SideEffects

- 기본적으로 Composable 에서 SideEffects가 없어야함.

- 필요한 경우 Composable의 Lifecycle 를 인식하는 관리된 환경에서 SideEffect를 호출해야함

 

2. 상태 및 effect 사용 사례

-앱 상태를 변경해야 하는 경우 SideEfffects 가 예측 가능한 방식으로 실행하게끔  Effect API를 사용해야 함

-effect는 UI를 내보내지 않으며 Composition이 완료될 때 SideEffects 를 실행하는 Composable 함수

-effect에서 실행하는 작업이 UI와 관련되고 단방향 데이터 흐름을 중단하지 않아야함 

 

참고: 반응형 UI는 본질적으로 비동기이며 Compose는 콜백을 사용하는 대신 API 수준에서 코루틴을 이용함

 

LaunchedEffect: Composable의 Scope에서 suspend 함수 실행

- Composable 내에서 안전하게 suspend 함수를 호출하려면 LaunchedEffect Composable을 사용

- LaunchedEffect는 Composable 함수이므로 다른 Composable  함수 내에서만 사용가능

- LaunchedEffect가 Composable 을 시작하면 매개변수로 전달된 코드 블록으로 코루틴이 실행됨

- LaunchedEffect가 Composition을 종료하면 코루틴이 취소됨

- LaunchedEffect가 다른 키로 recomposition 되면 기존 코루틴이 취소되고 새 코루틴에서 새 suspend 함수가 실행

 

예제.

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

    // If the UI state contains an error, show snackbar
    if (state.hasError) {

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(scaffoldState.snackbarHostState) {
            // Show snackbar using a coroutine, when the coroutine is cancelled the
            // snackbar will automatically dismiss. This coroutine will cancel whenever
            // `state.hasError` is false, and only start when `state.hasError` is true
            // (due to the above if-check), or if `scaffoldState.snackbarHostState` changes.
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

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

예제 설명

 -LaunchedEffect 호출 사이트가 if 문 내에서 조건이 거짓일 때 LaunchedEffect가 composition 에 있으면 삭제되고 따라서 코루틴이 취소 

 

rememberCoroutineScope: Compsoable 인식 범위를 확보하여 Composable 외부에서 코루틴 실행

@Composable
fun MoviesScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(scaffoldState = scaffoldState) {
        Column {
            /* ... */
            Button(
                onClick = {
                    // Create a new coroutine in the event handler
                    // to show a snackbar
                    scope.launch {
                        scaffoldState.snackbarHostState
                            .showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

 

- Composable 외부에 있지만 Composition을 종료한 후 자동으로 취소되도록 범위가 지정된 코루틴을 실행하려면 

 rememberCoroutineScope를 사용

 

- rememberCoroutineScope는 코루틴 하나 이상의 Lifecycle를 수동으로 관리해야 할 때 사용

(사용자 이벤트가 발생할 때 애니메이션을 취소해야 하는 경우)

 

 - rememberCoroutineScope는 호출되는 Composition의 지점에 바인딩된 CoroutineScope를 반환하는 Composable함수 (호출이 Composable을 종료하면 Scope가 취소됩니다.)

 

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

- rememberUpdatedState는 경우에 따라 effect에서 값이 변경되면 effect를 다시 시작하지 않을 값을 캡처할 수 있음

- rememberUpdatedState는 캡처하고 업데이트할 수 있는 이 값의 참조를 만들어야 합니다.

- 비용이 많이 들거나 다시 만들고 다시 시작할 수 없도록 오래 지속되는 작업이 포함된 effect에 유용

 

 

예제.

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

    // This will always refer to the latest onTimeout function that
    // LandingScreen was recomposed with
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}

에제 설명

 

위 예제는 앱에 시간이 지나면 사라지는 LandingScreen이 있다고 가정

위 예제에서 LandingScreen이 recomposition 되는 경우에도 일정 시간 동안 대기하고

시간이 경과되었음을 알리는 효과는 다시 시작해서는 안 됨

 

1) 호출 사이트의 수명 주기와 일치하는 효과를 만들기 위해 Unit 또는 true와

같이 변경되지 않는 상수가 매개변수로 전달 LaunchedEffect(true)

 

2) onTimeout 람다에 LandingScreen이 recomposition된 최신 값이 항상 포함되도록 하려면 

rememberUpdatedState로 onTimeout을 래핑해야 함

 

3) 코드에서 반환된 State, currentOnTimeout은 effect에서 사용해야 할것

 

주의: 위 예제에서 LaunchedEffect(true)는 while(true)처럼 작동될 수 있음. 예제는 예제일 뿐,

 항상 일시중지하고 필요한 항목인지 확인이 필요

 

 

DisposableEffect: 정리가 필요한 효과

- Key가 변경되거나 Composable이 Composition을 종료한 후 정리해야 하는 side-effect 의 경우

 DisposableEffect를 사용

 

-DisposableEffect 키가 변경되면 Composable이 현재 effect를 삭제(정리)하고

 효과를 다시 호출하여 재설정해야 함

 

예제.

@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {

    // Safely update the current `onBack` lambda when a new one is provided
    val currentOnBack by rememberUpdatedState(onBack)

    // Remember in Composition a back callback that calls the `onBack` lambda
    val backCallback = remember {
        // Always intercept back events. See the SideEffect for
        // a more complete version
        object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                currentOnBack()
            }
        }
    }

    // If `backDispatcher` changes, dispose and reset the effect
    DisposableEffect(backDispatcher) {
        // Add callback to the backDispatcher
        backDispatcher.addCallback(backCallback)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            backCallback.remove()
        }
    }
}

 

예제 설명

 

1) effect가 저장된 backCallback을 backDispatcher에 추가-> backDispatcher가 변경되면 effect가 삭제되고 다시 시작

 

2)DisposableEffect는 onDispose 절을 코드 블록의 최종 문으로 필수 포함.

(onDispose가 없으면 IDE에 빌드 시간 오류가 표시됨)

 

주의: onDispose에 빈 블록을 포함하는 것은 권장하지 않음 

 

3. Side-Effect: Compose 상태를 비 Compose 코드에 게시

Compose 상태를 Compose에서 관리하지 않는 객체와 공유하려면

recomposition 성공 시마다 호출되는 SideEffect composable을 사용할 것 

 

예제

@Composable
fun BackHandler(
    backDispatcher: OnBackPressedDispatcher,
    enabled: Boolean = true, // Whether back events should be intercepted or not
    onBack: () -> Unit
) {
    /* ... */
    val backCallback = remember { /* ... */ }

    // On every successful composition, update the callback with the `enabled` value
    // to tell `backCallback` whether back events should be intercepted or not
    SideEffect {
        backCallback.isEnabled = enabled
    }

    /* Rest of the code */
}

예제설명

-BackHandler 함수와 같이 콜백을 사용 설정해야 하는지 전달하려면 SideEffect를 사용하여 값을 업데이트합니다.

 

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

-produceState은 반환된 State로 값을 푸시할 수 있는 Composition으로 Scope가 지정된 코루틴을 실행

 

-비 Compose상태를 Compose상태 변환하려면 Flow,LiveData, RxJava와 같은 구독 기반 State를 Composition으로 변환하려면 produceState를 사용

 

-produceState이 Composition을 시작시 프로듀서가 실행 -> Composition을 종료시 프로듀서가 취소 

(return 된 state은 합성됨, 동일한 값을 설정해도 recompostion이 실행되지 않음)

 

- produceState가 코루틴을 만드는 경우에 정지되지 않는 데이터 소스를 관찰하는 데 사용할 수 있음.

(이 소스의 구독을 삭제하려면 awaitDispose 함수를 사용)

 

- 내부적으로 produceState는 다른 효과를 사용함

 

- remember { mutableStateOf(initialValue) }를 사용하는 result 변수를 보유하며 

  LaunchedEffect에서 producer 블록을 실행 후 

  producer 블록에서 value가 업데이트될 때마다 result 상태가 새 값으로 업데이트됨

  (기존 API 위에 자신만의 효과를 쉽게 만들 수 있음)

 

예제

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

    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new keys.
    return produceState(initialValue = Result.Loading, url, imageRepository) {

        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

예제 설명

- produceState를 사용하여 네트워크에서 이미지를 로드하는 방법을 보여줌

- loadNetworkImage 함수는 다른 Composable에서 사용할 수 있는 State를 return


주의: return type이 있는 Composable은 일반 Kotlin 함수의 이름을 지정하는 방식에

따라 소문자로 시작하는 이름을 지정함

(기존 Composable 함수는 모두 대문자로 시작.)

 

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

- 다른 상태 객체에서 특정 상태가 계산되거나 파생되는 경우 derivedStateOf를 사용

-이 함수를 사용하면 계산에서 사용되는 상태 중 하나가 변경될 때만 계산이 실행

 

예제

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

    // Calculate high priority tasks only when the todoTasks or
    // highPriorityKeywords change, not on every recomposition
    val highPriorityTasks by remember(todoTasks, highPriorityKeywords) {
        derivedStateOf {
            todoTasks.filter { it.containsWord(highPriorityKeywords) }
        }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) { /* ... */ }
            items(todoTasks) { /* ... */ }
        }
        /* Rest of the UI where users can add elements to the list */
    }
}

예제설명

 

0) 위 예에서는 우선순위가 높은 사용자 정의 키워드가 있는 작업이 먼저 표시되는 기본 할 일 목록을 보여줌

 

1) 위 코드에서 derivedStateOf는 todoTasks 또는 highPriorityKeywords가 변경될 때마다 highPriorityTasks 계산이 실행     되고 그에 따라 UI가 업데이트되도록 보장

 

2) highPriorityTasks를 계산하기 위한 필터링은 비용이 많이 들 수 있으므로 매 recomposition 실행될 때가 아니라 목록이 변경될 때만 실행

 

3) derivedStateOf에 의해 상태가 업데이트되어도 업데이트가 선언된 Composable이 recomposition이 되지 않습니다. Compose는 예의 LazyColumn 내에서 반환된 상태를 읽는 위치의 composable만 recomposition합니다.

 

snapshotFlow: Compose의 상태를 Flow로 변환

- snapshotFlow를 사용하여 State<T> 객체를 콜드 Flow로 변환

 

- snapshotFlow는 수집될 때 블록을 실행하고 읽은 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()
        }
}

예제 설명

 

1) 예제는 사용자가 목록에서 첫 번째 항목을 지나 분석까지 스크롤할 때 기록되는 부작용을 보여줌

2) listState.firstVisibleItemIndex는 Flow 연산자의 이점을 활용할 수 있는 Flow로 변환

4. effect 다시 시작

- Compose의 일부 effect(LaunchedEffect, produceState, DisposableEffect)에서 실행 중인 effect를 취하는 데 사용되는 가변적인 수의 인수를 취하고 새 key로 새 effect를 시작

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

- 효과를 다시 시작하는 데 사용되는 매개변수가 올바른 매개변수가 아닌 경우 문제가 발생할 수 있음

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

- 대체적으로 효과 코드 블록에 사용되는 변경할 수 있는 변수변경할 수 없는 변수는 effect composable에

  매개변수로  추가해야 함

  (이 매개변수 외에 effect를 강제로 다시 시작하도록 더 많은 매개변수를 추가할 수 있음)

 

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

 

- 변수가 키가 없는 remember에 래핑되어 변경되지 않으면 변수를 효과에 키로 전달할 필요가 없음

 

정리: effect에 사용되는 변수는 effect composable의 매개변수로 추가하거나 rememberUpdatedState를 사용해야 함

 

예제

@Composable
fun BackHandler(backDispatcher: OnBackPressedDispatcher, onBack: () -> Unit) {
    /* ... */
    val backCallback = remember { /* ... */ }

    DisposableEffect(backDispatcher) {
        backDispatcher.addCallback(backCallback)
        onDispose {
            backCallback.remove()
        }
    }
}

예제 설명

0) DisposableEffect 코드에서 블록의 backDispatcher가 변경되면 효과가 다시 시작되어야 하므로 effect에서 이를 매개     변수로 취함

 

1) backCallback은 컴포지션에서 값이 변경되지 않으므로 DisposableEffect 키로 필요하지 않으며 키가 없는 remember     에 래핑됨.

 

2) backDispatcher가 매개변수로 전달되지 않고 변경되면 BackHandler는 recomposition되지만

   DisposableEffect는 삭제되지 않고 다시 시작됨. 따라서 이 시점부터 잘못된 backDispatcher가

   사용되기 때문에 문제가 발생

 

키로 사용되는 상수

- true와 같은 상수를 호출 사이트의 수명 주기를 추적하는 효과 키로 사용할 수 있음

- 위 예제에서 본 LaunchedEffect(true) 예와 같은 사용 사례가 있음

- 사용하기 전에 신중하게 필요한 항목인지 확인필요

 

이상 Compose Side Effect에 관한 정리였습니다. 역시나 안드로이드 개발문서는 상당히 친절하면서 꽤 시크하다는 느낌을 받네요 요즘 곳곳에서 Compose 사용 후기가 들려오고 있습니다. 벌써 Compose에 심취한 사람들은 xml은 이제 쓰기 불편하다고도 하지요

 

과연 Compose의 미래가 xml을 모두 대체 할것인지, xml과 Compose와 같이 쓰는형태로 자리매김 할것인지..

기대해볼만 합니다.