Compose 기초 - Compose 사이드이펙트 관리

Compose 사이드이펙트 (원문)

💡개념이 헷갈리거나 제가 잘 모르거나 많은 사람들이 잘 모를 것 같은 것 위주로 정리

왜 사이드이펙트에 대해서 조심해야 하나?

Recomposition에 의해 @Composable 함수가 빈번히 호출되거나 호출 제외 될 수 있기 때문에 @Composable 함수에 사이드이펙트가 존재하면 사용자의 예상을 벗어나는 동작을 할 수도 있다.

*사이드이펙트 예시 : 전역변수, SharedPreference 등의 값을 변경하는 행위, 한 번만 실행해야 하는 작업

사이드이펙트 작업을 해야 한다면 Effect API를 사용함으로써 더 안전한 사이드이펙트 작업으로 만들 수 있다.

Initial Composition 시점에 실행

LaunchedEffect() {...}

Initial Composition 시점에 LaunchedEffect() {...}로 전달된 suspend 람다{...}를 실행한다.

@Composable
fun Child(state: Boolean) { 
    // 키값 : scaffoldState.snackbarHostState
    // 코루틴 : LaunchedEffect(...)에 전달된 람다 { ... }
    LaunchedEffect(scaffoldState.snackbarHostState) {     
        scaffoldState.snackbarHostState.showSnackbar(        
            message = "Error message",        
            actionLabel = "Retry message"      
        )    
    }
}

Recomposition 시점에 Launchedeffect()로 전달된 키값이 변화했다면 suspend 람다{...}가 새로 실행된다. 기존 suspend 람다{...}가 실행 중이었다면 중지됨.

Composable 외부에서 실행

rememberCoroutineScope()

LaunchedEffect는 Composable이므로 @Composable 함수 안에서만 사용할 수 있다.

Composable 범위 밖에서 호출되는 onClick과 같은 람다{...}에서는 rememberCoroutineScope를 사용한다.

xXxxScope.launch {...}로 전달된 suspend 람다{...}가 실행된다.

@Composable
fun MoviesScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {
    // scope의 라이프사이클은 MoviesScreen의 라이프사이클에 종속된다.
    val scope = rememberCoroutineScope() 

    Button(       
        onClick = {         
            scope.launch {            
                scaffoldState.snackbarHostState.showSnackbar("Something happened!")          
            }        
        }     
    ) {        
        ...
    }
}

rememberCoroutineScope()를 통해 만들어진 scope의 라이프사이클은 rememberCoroutineScope()를 호출한 Composable의 라이프사이클에 종속된다.

remember된 상태의 최신값에 지연 접근하기

rememberUpdatedState()

LaunchedEffect() {...}, rememberCoroutineScope.launch {...}의 람다{...} 안에서 지연된 상태접근을 시도할 때 최신 상태값을 가져올 수 있다.

@Composable
fun Parent() {
    var state by remember { mutableStateOf(false/*첫값*/) }

    Box(
        modifier = Modifier
            .size(50.dp, 50.dp)
            .background(Color.Blue)
            // Box를 클릭하면 state값이 true로 바뀐다.
            .clickable { state = state.not() },
    ) {
        // "box state $state"는 state값이 바뀔 때마다 출력된다.
        Log.e("BSSCCO", "box state $state")
        Child(state)
    }
}

@Composable
fun Child(state: Boolean) {
    val rememberState1 by remember { mutableStateOf(state) }
    val rememberState2 by remember(state) { mutableStateOf(state) }
    val rememberUpdatedState by rememberUpdatedState(state)

    LaunchedEffect(true) {
        delay(5000L) // 이후 작업을 5초 간 지연시킨다.
        // 5초가 지나기 전에 Box를 클릭하면 state값이 true로 바뀔 것이고 5초가 되는 시점에 아래 주석대로 출력될 것이다.
        Log.e("BSSCCO", "child state $state") // "child state false"
        Log.e("BSSCCO", "child rememberState1 $rememberState1") // "child state false"
        Log.e("BSSCCO", "child rememberState2 $rememberState2") // "child state false"
        Log.e("BSSCCO", "child rememberUpdatedState $rememberUpdatedState") // "child state true" 최신값이 반영됐다.
    }
}

remember(state) {...}rememberUpdatedState(state)의 차이

remember(state) {...}는 위에서 알아본 지연된 상태접근 이슈를 해결할 순 없다.

remember(state) {...}rememberUpdatedState(state) 처럼 Recomposition 과정에서 state값이 바뀐 것을 반영할 수 있다.

그런데 rememberUpdatedState(state) 코드가 있으면 state가 바뀌었을 때 Composable을 두 번 Recomposition 되도록 만들기 때문에 낭비다.

// 호출될 때마다 내부코드에서 스스로 자신의 값을 set 한다.
fun rememberUpdatedState(newValue: T) = remember { 
    mutableStateOf(newValue) 
}.apply { 
    it.value = newValue 
}
@Composable
fun Parent() {
   var state by remember { mutableStateOf(false/*첫값*/) }

   Box(
       modifier = Modifier
           .size(50.dp, 50.dp)
           .background(Color.Blue)
           // Box를 클릭하면 state값이 true로 바뀐다.
           .clickable { state = state.not() },
   ) {
       // "box state $state"는 state값이 바뀔 때마다 출력된다.
       Log.e("BSSCCO", "box state $state")
       Child(state)
   }
}

@Composable
fun Child(state: Boolean) {
   val rememberState1 by remember { mutableStateOf(state) }
   val rememberState2 by remember(state) { mutableStateOf(state) }
   // rememberUpdatedState() 호출로 인해 Child가 두 번 Recomposition 되어버린다.
   val rememberUpdatedState by rememberUpdatedState(state)

   Log.e("BSSCCO", "child state $state") // "child state false"
   Log.e("BSSCCO", "child rememberState1 $rememberState1") // "child state false"
   Log.e("BSSCCO", "child rememberState2 $rememberState2") // "child state true" 최신값이 반영됐다.
   Log.e("BSSCCO", "child rememberUpdatedState $rememberUpdatedState") // "child state true" 최신값이 반영됐다.
}

따라서 state값이 바뀐 것을 반영할 수 있어야 하면서 지연된 상태접근이 불필요하다면 remember(state) {...}를 사용해야 한다.

Composition 종료 시점에 실행

DisposableEffect() {...}

Composable의 Composition이 종료될 때 onDispose {...}로 전달된 람다{...}를 실행한다.

@Composable 
fun Parent() { 
    var state by remember { mutableStateOf(false) }
 
    Box(
        modifier = Modifier.size(50.dp, 50.dp)
            .background(Color.Blue)
            // Box를 클릭하면 state값이 반대 boolean값으로 바뀐다.
            .clickable { state = state.not() }, 
    ) { 
        // state값이 바뀔 때마다 "box state $state"가 출력된다.
        Log.e("BSSCCO", "box state $state")
        if(state) { 
            Child(state) 
        } 
    } 
}

@Composable 
fun Child(state: Boolean) { 
    DisposableEffect(state) { 
        // Initial Composition 또는 state값이 바뀔 때마다 "DisposableEffect"이 출력된다.
        Log.e("BSSCCO", "DisposableEffect")
 
        onDispose { 
            // Composition이 종료될 때 "onDispose"가 출력된다.
            Log.e("BSSCCO", "onDispose") 
        } 
    } 
}

DisposableEffect() {...}로 전달된 키값이 변화해 Recomposition 되면 DisposableEffect() {...}로 전달된 람다{...}가 새로 실행된다. 기존 람다는 suspend가 아니기 때문에 바로 중지되진 않는다.

Recomposition 시점에 실행

SideEffect {...}

Recomposition 시점에 SideEffect {...}로 전달된 람다{...}를 실행한다.

@Composable
fun Child(state: Boolean) {
    SideEffect {      
        Log.e("BSSCCO", "recomposition $state")
    }
}

Non-blocking 결과를 remember된 상태로 변환

produceState() {...}

produceState() {...}로 전달된 람다suspend ProduceStateScope<T>.() -> Unit를 사용해 일반적인 값을 State<T>로 변환한다.

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

    return produceState(initialValue = Result.Loading, url, imageRepository) {
        val image = imageRepository.load(url)
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

Flow, LiveData 또는 RxJava와 같은 외부 구독 기반 상태를 State<T>로 변환할 때 유용하다.

Flow<T>.collectAsState()도 내부적으로 produceState()를 사용했다.

produceState()의 매개변수로 전달된 suspend 람다{...}에서 scope가 종료되는 걸 잡아내고 싶다면 awaitDispose {...}를 사용한다.

val currentPerson by produceState<Person?>(null, viewModel) {
    val disposable = viewModel.registerPersonObserver { person ->
        value = person
    }

    awaitDispose {      
        disposable.dispose()
    }
}

무거운 Blocking 작업의 결과를 remember된 상태로 변환

remember() { derivedStateOf {...} }

derivedStateOf {...}remember {...} 안에서만 사용해야 쓸모가 있다.

mutableStateOf()는 값을 인자로 넣는 반면에 derivedStateOf {...}는 람다{...}를 인자로 받는다.

derivedStateOf {...}로 전달된 람다{...}remember {...}에 의해 Recomposition 시 재호출 빈도가 최소화 되며 따라서 무거운 변환 작업에 대한 부담이 적다.

@Composable
fun Screen() {
    var flag by remember { mutableStateOf(false) }
    var items by remember {
        mutableStateOf((0 until 1000).toList())
    }
    var keywords by remember {
        mutableStateOf((0 until 10).toList())
    }
    val scope = rememberCoroutineScope()

    ItemList(
        flag = flag,
        items = items,
        keywords = keywords,
        onItemClick = { item ->
            // scope.launch {
                if (item in keywords) {
                    keywords = keywords.takeLast(keywords.size - 1)
                } else {
                    flag = flag.not()
                }
            // }
        }
    )
}

@Composable
private fun ItemList(
    flag: Boolean,
    items: List<Int>,
    keywords: List<Int>,
    onItemClick: (Int) -> Unit,
) {
    Log.e("BSSCCO", "flag $flag")

    // 무거운 변환작업을 Recomposition 마다 하지 않고 items, keywords가 변경됐을 때만 하도록 만들어준다.
    val filteredItems by remember(items, keywords) {
        Log.e("BSSCCO", "derivedStateOf")
        // derivedStateOf는 MediatorLiveData와 같은 기능이라고 할 수 있다.
        derivedStateOf { items.filter { it in keywords } }
    }

    LazyColumn(Modifier.fillMaxSize()) {
        items(
            items = filteredItems,
            key = { item -> item + 10000 }
        ) { item ->
            Item(item + 10000, onClick = { onItemClick(item) })
        }
    }

    LazyColumn(Modifier.fillMaxSize()) {
        items(
            items = filteredItems,
            key = { item -> item }
        ) { item ->
            Item(item, onClick = { onItemClick(item) })
        }
    }
}

@Composable
private fun Item(item: Int, onClick: () -> Unit) {
    Box(
        modifier = Modifier
            .clickable(onClick = onClick)
            .fillMaxWidth()
            .height(50.dp),
        contentAlignment = Alignment.Center,
    ) {
        Log.e("BSSCCO", item.toString())
        Text(item.toString())
    }
}

State<T>를 flow로 변환

snapshotFlow {...}

Composable 상태 State<T>는 구독 가능한 값이다.

Flow는 구독 가능한 스트림이다.

State<T>를 Flow로 변환하여 Flow의 다양한 연산자를 활용할 수 있다.

val listState = rememberLazyListState()

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

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

사이드이펙트에 주어진 람다가 다시 시작되는 조건

LaunchedEffect(key) {...}, remember(key) {...}, DisposableEffect(key) {...} 등에서 쓰이는 키값이 바뀌면 주어진 람다{...}가 새로 실행 된다.

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