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, ...) {
...
}