모바일/Android

[공식문서] Side-effects in Compose

Patti Smith 2024. 8. 4.

Preview

side-effect는 composable 함수의 범위 밖에서 일어나는 앱의 상태변화를 일컫는다. composable의 lifecycle과 속성은 예측불가능한 recomposition이 일어나기 때문에 다양한 순서로, recomposition가 실행되거나 폐기될 수 있기 때문에 composable은 side-effect와 독립적이어야 한다.

 

그러나 side-effect는 스낵바를 보여주거나 특정한 조건이 만족됐을 경우 다른 화면으로 navigate되는 둥 트리거가 발생했을 시 필요하다. 이런 액션은 composable의 lifecycle 같은 통제된 환경에서 호출되어야 한다. 

 

상태와 효과 사용 사례

composable은 side-effect에게 자유롭다. 어플의 상태변화를 만들 때 반드시 Effect API를 사용해 side effect가 예측할 수 있는 방향 안에서 실행되어야 한다. 여기서 effect는 UI를 제거하지 않고 composition이 실행됐을 때 side-effect를 만드는 composable 함수를 뜻한다.

 

가능한 다양한 효과는 Compose에서 일어나기 때문에 effect는 쉽게 과다사용될 수 있다. 방향이 없는 data 플로우를 깨트리지 않고 UI와 관계할 수 있도록 확실히 하는 것이 중요하다. 반응형 UI는 기본적으로 비동기적이므로 제트팩 Compose는 API레벨에서 콜백을 사용하는 대신 코루틴을 사용하면서 이를 해결한다. 

 

LaunchedEffect : composable 범위에 있는 suspend 함수의 실행

LaunchedEffect이 처음 Composition에 들어갈 때, 파라미터로 전달되는 코드블럭과 함께 코루틴이 실행된다. 이 코루틴은 LaunchedEffect이 composition을 떠나면 취소된다. 만약 LaunchedEffect이 다양한 키와 함께 recompose된다면, 이미 실행하고 있는 코루틴은 취소되며 새로운 suspend fucntion이 새 코루틴에 launch된다.

 

var pulseRateMs by remember { mutableStateOf(3000L) }
val alpha = remember { Animatable(1f) }

LaunchedEffect(pulseRateMs) {
	while (isActive) { // pulse rate가 변하면 이 effect는 재시작된다
    	delay(pulseRateMs)
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}

 

이 코드를 보면, 애니매이션은 suspending fuction delay를 사용한다. 그 다음엔 순차적으로 투명도를 0으로 만든 뒤 다시 돌아가  animateTo로 투명도를 1로 만든다. composable의 life동안 이 함수는 계속 반복된다.

 

remeberCoroutineScope : composable 외부에서 코루틴을 실행하여 composition-aware 범위를 얻음

LaunchedEffect는 composable 함수 내부에서만 실행된다. compsable 컨텍스트 안에서 진행되기 때문에 onClick과 같은 람다함수에 일반함수가 아닌 composable 함수를 직접호출할 수 없다. 게다가 onClick은 컴파일 시점에서 구성되는 UI와 다르게 사용자와 상호작용을 통해 런타임 시 실행된다(그래서 recompoisiton 역시 런타임에 진행). 하나 이상의 lifecycle 코루틴을 수동으로 컨트롤하고 싶을 때도 rembmerCoroutine을 사용할 수 있다. 이때 composition이 떠나면 이 코루틴은 직접 제어할 필요 없이 자동으로 취소된다. 예를 들어 유저 이벤트가 일어났을 때 애니매이션을 취소하고 싶을 때다.

 

rembmerCoroutine은 CoroutinScope을 반환하는 composable함수다. 이 CoroutineScope는 composition이 호출된 포인트에 연결되어 있다. 이 scope는 Composition을 떠날 때 취소될 수 있다. 다음 코드는 유저가 버튼을 눌렀을 때 스낵바를 보여주는 코드다.

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState){
	val scope = rememberCoroutineScope() // MoviesScreen의 스코프와 연결된 코루틴
    
    Scaffold(
    	snackbarHost = {
        	SnackbarHost(hostState = snackHostSate)
            }
    ) { contentPadding -> 
    	Column(Modifier.padding(contentPadding)) {
        	Button(
            	onClick = {
                 	scope.launch {
                    	// 스낵바를 보여주는 이벤트 핸들러에 새로운 코루틴 추가
                    	snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
            	Text("Press me")
            }
        }
    }
}

 

rememberUpdatedState : 값이 변하더라도 재시작 하지 말아야 하는 효과에서 값을 참고

LaunchedEffect은 key가 변할 때 재시작된다. 하지만 어떤 경우에 값을 유지시키기 위해 재시작하고 싶지 않을 수 있다. 이를 위해 rememberUpdatedState를 사용한다. 오랫동안 작동되어어 비용이 많이 드는 오페이션을 유지시키거나 재시작, 재생성을 막기 위한 접근법이다.

 

예를 들어, 어플이 어떤 시간 이후에 LandingScreen을 사라지게 하고 싶을 때를 가정해보자. LandingScreen이 recompose되면 effect는 어떤 시간동안 기다린다. 이 시간 동안엔 재시작되서는 안 된다.

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

	// onTimeout 함수를 최선값으로 참고한다.
	val currentOnTimeout by rememberUpdatedState(onTimeout)
    
    // LandingScreen에 맞는 lifecycle 효과를 생성
    // LandingScreen이 recompose되면 delay는 다시 실행되지 않는다.
    LanchedEffect(true) {
    	delay(SplashWaitTimeMillis)
        cueentOnTimeOut()
    }
    
    /* Landing screen content */
}

 

호출 사이트의 lifecycle과 매치되는 effect를 생성하기 위해 unit 혹은 true 같은 절대로 변하지 않는 상수를 파라미터로 전달한다. 위의 코드에서는 LaunchedEffect(true)가 그렇다. onTimeout 람다를 항상 최신값으로 유지하기 위해 onTimeout은 rememberUpdatedStat함수로 래핑되어야 한다. State가 반환되면 currentOnTimeout은 effect에서 사용된다.

 

DisposableEffect : 정리가 필요한 효과

side-effect는 key가 변하거나 composable이 composition을 떠났다면 정리할 필요가 있다. 이때 DisposableEffect을 사용한다. DisposableEffect은 키가 변할 때, composable이 현재 효과를 버리고 새 효과를 다시 호출하여 재시작한다.

 

예를 들어, LifecycleObserver을 사용해 Lifecycle 이벤트 기반 분석 이벤트를 보내고 싶다면 Compose에 있는 이벤트를 감시하기 위해 DisposableEffect을 사용하여 필요할 때 observer을 등록하고 해제한다.

 

@Composable
fun HomeScreen(
	lifecycleOwner : LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit // started 분석 이벤트를 보낸다
    onStop: () -> Unit // stopped 분석 이벤트를 보낸다
){
	// 안전하게 현재 람다를 업데이트한다. 
    val currentOnState by remeberUpdateedState(onStart)
    val currentOnStop by remeberUpdatedStae(onStop)
    
    // lifecycleOwner가 변화한다면 effect를 버리거나 재시작한다.
    DisposableEffect(lifecycleOwner) {
    	// remebers 콜백을 트리거하는 옵저버를 생성
        // 분석 이벤트를 송신
        val observer = LifecycleEvenetObserver { -, event -> 
        	if (event == Lifecycle.Event.ON_START) {
            	currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
            	currentOnStop()
            }
        }
        
        // lifecycle에 옵저버를 등록
        lifecycleOwner.lifecycle.addObserver(observer)
        
        // effect가 composition을 떠나면, observer 제거
        onDispose {
        	LifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

 

effect는 lifecycleOwner에 추가되며, lifecycleOwner가 변화할 때 effect는 삭제되거나 새로운 lifecycleOwner로 재시작된다. 또 DisposableEffect의 리턴값이 onDispose이므로 코드블럭 마지막에 배치해야 한다.

 

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

produceState

는 value를 넣고 State를 리턴할 수 있는 composition에 대한 코루틴 스코프를 launch한다. 그래서 비 compose state를 compose state로 변환할 수 있다. 예를 들어 외부 구독 기반 state인 Flow, LiveData, Rxjava를 가져와 compose가 인식할 수 있는 형태인 Composition으로 전환한다. 

 

produceState가 composition에 들어갈 때 lauch되며, composition을 떠날 때 취소된다. 반환된 state가 같은 값으로 세팅하면 recomposition이 트리거되지 않는다.

 

produceState가 코루틴을 만들어도 비 suspending 데이터가 관찰될 수 있다. 그 데이터의 구독 취소하고 awaitDispose 함수를 사용한다. 

 

다음 예시는 어떻게 produceState가 네트워크로부터 이미지 로드를 하는지에 대한 것이다. loadNetworkImage composable 함수는 다른 composable에서 사용되는 State를 리턴한다.

 

@Composable
fun loadNetWorkImage(
	url: String,
    imageRepositiory: ImageRepositiory = ImageRepository()
): State<Result<Image>> {

	// 초기 값으로 result Loading과 함께 State<T>를 생성한다.
    // url과 imageRepository 중 둘 중 하나가 변하면 producer를 실행한다.
    // 새로운 입력값과 함께 취소되거나 재실행된다.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository){
    	// 코루틴에서 supsend 호출을 만들 수 있다.
        val image = imageRepository.load(url)
        
        // 에러 또는 성공 결과가 반환되면 state를 업데이트한다.
        // 이 state를 읽는 recomposition이 트리거된다.
        value = if (image == null){
        	Result.Error
        } else {
        	Result.Success(image)
        }
    }
}

 

위 코드의 진행상황은 이렇다

 

  1. produceState 함수가 호출된다
  2. State<Result<Image>> 객체가 생성되고 초기값으로 Result.Loading이 설정된다.
  3. 내부 코루틴이 실행된다
  4. State 객체가 즉시 반환된다.(initalValue) 
  5. 반환 후, 백그라운드에서 코루틴이 실행된다.
    1. imageRespository.load(url)이 호출된다.
    2. 로드가 완료되면 value가 업데이트된다.

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

reomposition은 관찰된 state 객체 혹은 composable의 입력값이 변할 때 일어난다. state 객체와 입력값은 UI가 정말로 업데이트 될 때보다 더 자주 변할 것이다. 그리고 이건 불필요한 recompoistion을 일으킨다.

 

derivedStateOf fucntion은 composable에 대한 입력값이 내가 실제로 필요한 recompose횟수보다 더 많이 일어날 때 필요하다. derivedStateOf은 새로운 compose객체를 만들어 내가 필요한 만큼 업데이트할 수 있다. 이 방식은 FLows의 distinctUntilChanged() 오퍼레이터와 비슷하다.

 

단 derivedStateOf은 비용이 너무 많이들기 때문에 결과가 바뀌지 않은데도 불필요한 recomposition가 일어날 때만 사용한다.

 

1) 올바른 사용

스크롤 위치가 자주 변하지만, UI는 특정 임계값을 넘을 때만 반응해야 하는 경우

@Composable
// 메시지 파라미터가 바뀌면 메시지 리스트 composable이 recompose한다.
// derivedStateOf는 이 recomposition에 영향을 끼치지 않는다.
fun MessageList(messages: List<Message>) {
	Box {
    	val listState = rememberLazyListState()
        
        LazyColumn(state = listState) {
        	//
        }
        
        // 첫번째 아이템이 지나가면 버튼을 보여준다.
        // 저장된 derivedstate를 사용하여 불필요한 composition을 최소화한다.
        val showButton by remember {
        	derivedStatedOf {
            	listState.firstVisibleItemIndex > 0
            }
        }
        
        AnimatedVisiblity(visitble = showButton) {
        	ScrollToTopButton()
        }
    }
}

 

firstVisibleItemIndex는 첫번째 visible 아이템이 변할 때 변한다. 스크롤 시, 값은 0, 1, 2, 3, 4, 5...로 바뀐다. 하지만 recomposition은 0보다 클때만 일어난다. 이 빈번한 업데이트에서 mismatch는 derivedStateOf을 사용할 좋은 케이스이다.

 

2) 틀린 사용

두 compose 상태 객체를 결합할 때 derivedStateOf을 사용한다. 그러나 이건 오버헤드를 일으킨다. 

 

var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" }
val fullNameCorrect = "$firstName $lastName" // 올바른 사용

 

이 코드에서 fullName은 firstName이나 lastName만큼이나 업데이트가 일어난다. 굳이 사용할 필요가 없다.

 

snapshotFlow : compose 상태를 flows로 전환

snapshotFlow는 State<T>를 clod Flow로 변환한다. snapshotFlow는 state 객체에대한 결과를 삭제하거나 수집하면 코드블럭을 실행시킨다. state 객체가 하나 이상일 때 snapshowFlow에서 읽으면 이 flow는 이전에 방출한 값과 같지 않은지 확인하여 콜렉터에서 새 값을 방출한다.

 

val listState = rememberLazyListState()

LazyColumn(state = listState){

}

LaunchedEffect(listState) {
	snapshotFlow { listState.firstVisibleItemIndex } // 아이템의 index를 flow로 변환
    	.map { index -> index > 0 } // 첫번째 항목을 지나쳤는지
        .distinctUntilChanged() // 이전에 방출한 값과 같은지 여부
        .filter { it == true } // 첫번째 아이템을 지나 스크롤된 경우만 통과
        .collect {
        	MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

 

listState.firstVisibleItemIndex는 flow로 변환된다. 코드를 살펴보면 첫번째 아이템이 지나쳤을 때만 collect하는 걸 볼 수 있다.

 

효과 재시작

compose에서 effect를 취소하고 새 key와 함께 재시작할 수 있다. 

EffectName(restartIfThisKeyChnages, orThisKey, orThisKey, ...) { block }
// restartIfThisKeyChnages 키가 변경되면 재 시작

 

이 동작은 키를 여러 개를 사용하거나, 키의 순서도 제각각이라 재시작의 기준이 모호해지기 때문에 사용되는 매개변수 역시 동작 횟수가 적절하지 않으면 문제가 발생할 수 있다. 예컨대 덜 재시작하면 버그가 발생하거나, 더 실행되면 비효율적으로 동작한다.

 

effect 코드 블록에서 가변 및 불변 변수는 effect composable의 매개변수로 추가해야 한다. 그 외에도 effect를 강제로 재시작하고 싶다면 더 많은 매개변수를 추가할 수 있다. 만약 변수가 변경되어도 재시작하지 않으려면 rememberUpstatedState로 랩핑하면 된다. (rememberUpdstateState는 effect가 재시작되는 걸 막지만 최신값은 유지하고 싶을 때 사용한다.)

 

재시작과 recompoistion

여기서 재시작은 recompoistion과는 비슷해보이지만 다른 개념이다. recomposition은 compose UI의 상태가 변경될 때 UI를 업데이트하여 composable 함수가 호출되어 UI를 다시 그리는 작업이지만 재시작은 특정 effect가 취소되고 새로 시작되는 것을 의미한다.

@Composable
fun MyComponent(count: Int) {
	Text("Count: $count") // recomposition
    
    LaunchedEffect(count) {
    	// count가 변경되면 재시작
    }
}

댓글