본문 바로가기

안드로이드

컴포즈 부수효과

반응형

부수효과

 

컴포저블에는 부수 효과가 없어야 한다. 하지만 앱의 상태를 변경하는 데 필요한 경우 컴포저블의 수명 주기를 인식하는 관리된 환경에서 부수 효과를 호출해야 한다. Compose에서 다양한 가능성 효과를 이용할 수 있기 때문에 과다하게 사용될 수 있는데 이를 방지하기 위해서는 효과(Effect)에서 실행하는 작업이 UI와 관련되고 단방향 데이터 흐름을 중단하지 않아야 한다.

 

LaunchedEffect: 컴포저블의 범위에서 suspend 함수 실행, 취소

실행시점: 컴포지션 실행
종료시점: 해당 컴포지션의 종료
재실행 발생 조건: LaunchedEffect block의 parameter 변경 시

매개변수로 전달된 코드 블록으로 코루틴이 실행되고 LaunchedEffect가 컴포지션을 종료하면 코루틴이 취소된다.

 

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

    // 특정 조건을 설정하고 그 경우에 LaunchedEffect를 실행하는게 가능하다.
    if (state.certainConditions) {

        // rember의 상태가 변하면 LaunchedEffect가 재실행 된다. 이경우 기존 실행은 취소 된다.
        LaunchedEffect(scaffoldState.snackbarHostState) {
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }
}

 

예제와 같이 LaunchedEffect(scaffoldState.snackbarHostState)에서 scaffoldState.snackbarHostState 값이 변경될 때 기존 코루틴이 취소 되고 새로운 코루틴이 실행된다.

 

rememberCoroutineScope: 컴포지션 인식 범위를 확보하여 해당 컴포저블의 외부에서 코루틴 실행

실행시점: 컴포지션 실행
종료시점: 외부 컴포지션의 종료
재실행 발생 조건: 없음

컴포저블 외부에 있지만 컴포지션을 종료한 후 자동으로 취소되도록 범위가 지정된 코루틴을 실행하는 경우와 코루틴 하나 이상의 수명 주기를 수동으로 관리해야 해야 할 때 rememberCoroutineScope를 사용하면 된다.

rememberCoroutineScope는 컴포지션의 지점에 바인딩된 CoroutineScope를 반환하는 구성이 가능한 함수이다. 호출이 컴포지션을 종료하면 범위가 취소된다.(선언한 부분 (call site)의 lifecycle에 따라 시작/취소된다.)

 

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

    // MoviesScreen의 lifecycle 범위내의 rememberCoroutineScope생성
    val scope = rememberCoroutineScope()

    Scaffold(scaffoldState = scaffoldState) {
        Column {
            /* ... */
            Button(
                onClick = {
                    // 이벤트 핸들러 안에 새로운 snackbar coroutine을 생성하고 외부(MoviesScreen)에서 생성된 rememberCoroutineScope의 lifecycler내에서 snackbar coroutine을 관리함으로 생명주기는 MoviesScreen의 생명주기를 따른다.
                    scope.launch {
                        scaffoldState.snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

 

애니메이션을 예제로 이해하면 편하다. 특정 composition 내에서 animation이 동작할 경우 coroutine이 필요하게 되는데 이 coroutine은 composition이 종료될 때 같이 종료되어야 하기 때문에 rememberCoroutineScope을 사용해야 한다.

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

LaunchedEffect를 사용할 때 값이 변경되는 경우 다시 시작하지 않으려면 rememberUpdatedState을 사용해 주면 된다. 이 접근 방식은 비용이 많이 들거나 다시 만들고 다시 시작할 수 없도록 금지되어진 오래 지속되는 작업이 포함된 효과에 유용하다.

LaunchedEffect의 block 내부에서 접근은 해야 하나, 변경되더라도 LaunchedEffect를 재시작시키지 않도록 하기 위해서는 해당 값을 rememberUpdatedState로 한번 wrapping 해야 한다.

예를 들어 앱에 시간이 지나면 사라지는 LandingScreen이 있다고 가정해 보자. LandingScreen이 재구성되는 경우에도 일정 시간 동안 대기하고 시간이 경과되었음을 알리는 효과는 다시 시작해서는 안 된다.

 

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

    // LandingScreen이 리컴포즈 될때마다 데이터를 마지막 onTimeout을 가리키지만 LaunchedEffect내에서 재호출 하지는 않는다.
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // LandingScreen의 lifecycle에 맞춘 LaunchedEffect가 실행되고 true조건에 의해 LaunchedEffect내부는 다시실행 되지만 rememberUpdatedState에 의해 onTimeout은 다시 실행 되지 않는다.
    LaunchedEffect(true) { //예제 임으로 true이지만 리컴포즈 마다 다시 실행 되는 조건 true는 조심해야 된다.
        delay(SplashWaitTimeMillis)
        currentOnTimeout()//onTimeout이 rememberUpdatedState로 래핑 되어 있음으로 리컴포즈 될 때에도 onTimeout은 다시 호출 되지 않는다. 
    }

    /* Landing screen content */
}

 

호출 사이트의 수명 주기와 일치하는 효과를 만들기 위해 Unit 또는 true와 같이 변경되지 않는 상수가 매개변수로 전달된다. 위 코드에서는 LaunchedEffect(true)가 사용된다. someText 람다에 LandingScreen이 재구성된 최신 값이 항상 포함되도록 하려면 rememberUpdatedState로 someText을 래핑해야 합니다. 코드에서 반환된 State, currentOnTimeout은 효과에서 사용해야 한다.

 

SideEffect : Compose 상태를 비 Compose 코드에 게시

실행시점: 리컴포지션 실행
종료시점:NAN

재실행 발생 조건: 리컴포지션 발생시

Compose에서 관리되는 객체가 아닌 다른 객체에 compose의 상태를 공유하기 위한 용도로, Composition이 성공적으로 완료되면 진행할 동작을 예약할 때 SideEffect를 사용한다.
리컴포지션 성공 시마다 호출되며 주로 Compose 상태를 Compose에서 관리하지 않는 객체와 공유하기 위해 사용된다.

하지만 recompose 마다 호출되므로 효율적인 사용을 위해서 특정 시점에만 실행되어야 하는 경우에는 LaunchedEffect를 필요에 따라 자원 해제가 필요한 경우에는 DisposableEffect로 대체할 수 있다.

예를 들어 애널리틱스 라이브러리를 사용하면 커스텀 메타데이터를 이후의 모든 애널리틱스 이벤트에 연결하여 사용자 인구를 분류할 수 있다. 현재 사용자의 사용자 유형을 애널리틱스 라이브러리에 전달하려면 SideEffect를 사용하여 값을 업데이트한다.

 

@Composable
fun rememberAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        /* ... */
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    //SideEffect의 Block은 coroutine scope 아니라 recomposition 된다고 기존 실행이 취소되지 않는다.
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

 

DisposableEffect: 정리가 필요한 효과

실행시점: 컴포지션 실행
종료시점: 컨포지션 종료
재실행 발생 조건: block parameter의 변경(키가 변경)

키가 변경되면 이전 효과를 삭제하고 효과를 다시 설정한다. LaunchedEffect와 동일한 동작을 하지만 차이점은 onDispose를 통하여 삭제 되었을 때 행동을 정의할 수 있다.

 

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // lifecycleOwner가 변경되거나 dispose되었을 경우 효과를 reset해준다.
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // lifcecyle에 observer를 등록한다.
        lifecycleOwner.lifecycle.addObserver(observer)

        // 컴포지션의 종료, 키의 변경등을 통해 이전의 효과가 종료 되었을 경우 observer를 remove 해줄 수 있다.
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

 

위와 같이 lifecycle이 변경되거나 dispose되었을 경우 이전에 추가한 observer를 onDispose를 통해 remove해줄 수 있다.

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

실행시점: 컴포지션 실행
종료시점: 컨포지션 종료
재실행 발생 조건: block param의 변경

produceState는 State(컴포즈 상태)로 값을 retrun한다. 비 Compose 상태를 Compose 상태로 변환하려면, 예를 들어 Flow, LiveData 또는 RxJava와 같은 외부 구독 기반 상태를 컴포지션으로 변환하려면 produceState를 사용 하면된다. produceState을 사용하면 아래와 같이 State형태로 변경해 줄 수 있다.

 

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository
): State<Result<Image>> { //State 형태로 리턴

    // State의 초기 값을 Result.Loading을 설정한다.
    // url 또는 imageRepository가 변경되면 실행 중인 producer가 취소되고 새로 입력된 값과 함께 재 실행 된다.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {

        // 코루틴 내부이므로 suspend 합수를 호출할 수 있다.
        val image = imageRepository.load(url)

        // 결과를 State형태로 반환함으로 반환 받는 곳에서 emit에 대한 oberve가 발생한다.
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

 

기존에 Flow, LiveData 또는 RxJava를 collectAsState등을 통해 바꾸었는데 해당 경우에도 내부에서는 아래와 같이 produceState를 사용하고 있다.

 

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

 

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(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 */
    }
}

 

위 코드에서 derivedStateOf는 todoTasks가 변경될 때마다 highPriorityTasks 계산이 실행되고 그에 따라 UI가 업데이트되도록 보장한다. highPriorityKeywords가 변경되면 remember 블록이 실행되고 이전 파생 상태 객체 대신 새로운 파생 상태 객체가 생성되고 기억된다. highPriorityTasks를 계산하기 위한 필터링은 비용이 많이 들 수 있으므로 매 리컴포지션 시가 아니라 목록이 변경될 때만 실행해야 한다.

derivedStateOf에 의해 상태가 업데이트되어도 업데이트가 선언된 컴포저블이 재구성(리컴포즈)되지 않는다. Compose는 예의 LazyColumn 내에서 반환된 상태를 읽는 위치의 컴포저블만 재구성한다.

위 코드는 highPriorityKeywords가 todoTasks에 비해 훨씬 덜 자주 변경될 때 유용하다. 변경 빈도가 낮지 않다면 derivedStateOf 대신 remember(todoTasks, highPriorityKeywords)를 사용하는게 좋다.

 

snapshotFlow: Compose의 상태를 Flow로 변환

snapshotFlow를 사용하여 State 객체를 콜드 Flow로 변환한다. State를 통해 작업하기 어려운 부분들을 Flow로 바꿔 여러가지 operator(map, filter 등을 사용하여)를 이용하여 작업 가능하다.

다음 예는 사용자가 목록에서 첫 번째 항목을 지나 분석까지 스크롤할 때 기록되는 부작용을 보여준다.

 

val listState = rememberLazyListState()

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

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

 

위 코드에서 listState.firstVisibleItemIndex는 Flow 연산자의 이점을 활용할 수 있는 Flow로 변환된다.

 

참고

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

반응형