SideEffect는 Composable 함수 외부에서 발생하는 변화이며, Composable 함수는 Recomposition에 대한 특성과 Composable 함수의 알 수 없는 생명주기 때문에 지양해야 한다고 소개했다.
이러한 SideEffect를 처리하기 위해 SideEffect Handler를 소개하였고, 이번에도 이어서 SideEffect, produceState(collectAsState, collectAsStateWithLifecycle), derivedStateOf, snapshotFlow에 대해서 순서대로 소개한다.
SideEffect
여기서 소개하는 SideEffect는 SideEffect Handler중 하나로, 부수효과 SideEffect와 다르다.
SideEffect는 Composable 함수가 성공적으로 Recomposition 되었을 때 호출되는 Block이며, Compose에서 관리되는 객체가 아닌 다른 객체에 compose의 상태를 공유하기 위해서 사용할 수 있다.
아래의 예시를 통해 확인하자.
@Composable
fun SideEffectExample(nonComposeStateCount: Int) {
SideEffect {
println("Recomposition이 성공했을 때 매번 호출된다.")
}
}
위 코드와 같이, Composition이 성공적으로 완료되면 진행할 동작을 예약할 때 SideEffect를 사용할 수 있다.
안드로이드 공식 문서에서는 사용자 시스템에서, non Compose State인 UserId를 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 {
analytics.setUserProperty("userType", user.userType)
}
return analytics
} // 공식 안드로이드 SideEffect Docs
이렇게 활용할 수 있지만, SideEffect는 매 Recomposition마다 호출되니, 주의해서 사용해야 한다.
보통의 경우 이전에서 소개한 LauncedEffect를 통해 해결할 수 있고, 자원해제가 필요한 경우 DisposableEffect로 대체할 수 있다.
또한, SideEffect는 coroutineScope가 아니기 때문에, 이전 LaunchedEffect처럼 취소되지 않는다.
ProduceState
produceState는 이름 그대로, 시간이 지남에 따라 변하는 상태를 생성한다.
즉 일반적인 값을 Compose State로 만들어 반환한다. 아래의 예시를 통해 확인하자.
@Composable
fun produceStateExample(countUpTo: Int): State<Int> {
return produceState(initialValue = 0) {
while(value < countUpTo) {
delay(1000L)
value ++
}
}
}
위 코드에서는 produceState를 통해, value가 매개변수로 받은 countUpTo보다 작을 때, 1초 기다리고 value에 1을 더하는 로직으로 되어있다. State와 같이, value를 업데이트 할때마다 새로운 value가 반환된다.
실제로 새로운 값을 반환하는 점에서, Flow와 유사한 것을 느낄 수 있다.
필자는 produceState를 많이 사용하고 있는데, 대표적인 예시가 ViewModel의 Flow를 UiState로 활용하는 경우이다.
ViewModel Flow를 통해 새로운 값을 갱신하고 방출하면, Composable 함수에서 CollectAsState와 같은 api를 통해 Flow를 State로 변환하여 사용할 수 있다.
이러한 CollectAsState api는 위에서 소개한 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 }
}
}
Bonus : CollectAsStateWithLifecycle
이번 SideEffect Handler 포스팅중 CollectAsStateWithLifecycle 키워드를 알게되었다. 해당 CollectAsStateWithLifecycle은 기존 CollectAsState와 다르게 생명주기를 인식한다는 점이 있다.
다음은 CollectAsState와 CollectAsWithLifecycle의 Lifecycle 차이이다.
CollectAsState는 Composable의 Lifecycle이 종료되어도 계속 Flow를 유지하는 것과 다르게, CollectAsStateWithLifecycle은 Composable의 생명주기와 함께 생성되고 종료된다.
따라서 대부분의 경우 CollectAsStateWithLifecycle을 사용하도록 권장한다.
DerivedStateOf
DerivedStateOf는 우리말로 하면 파생된 State이다. 파생이라는 단어에 주목하자.
DerivedStateOf는 다른 상태 객체에서 특정 상태가 계산되는 경우, 또 다른 자신의 객체에 파생할 수 있다. 예시를 통해 알아보자 !
@Composable
fun DerivedStateExample() {
var count by remember {
mutableStateOf(0)
} // 만약 count가 변경되어 recomposition 된다면 ??
val counterText = "The counter is $count"
Button(onClick = { count ++ }) {
Text(text = counterText)
}
}
위 코드에서 만약 count가 변경되어 recomposition이 발생한다고 가정하자. 그럼 다음의 단계를 거치게 된다. .
- recomposition
- count State가 포함된 counterText 계산
- counterText 업데이트
- Button Text 중 counterText 계산
- Button Text 업데이트
count가 변경되었을 뿐인데, count State를 가진 counterText를 가지는 모든 상태를 다시 계산해서 업데이트 한다.
사실 이렇게만 보면, 오류는 없는 코드이고 뭐가 문제인지 모를 수 있다.
우리가 1 + 1을 100번 출력하는 코드를 짠다고 가정하자.
그럼 거의 모든 사람이 1 + 1 을 계산한 값을 변수에 기록하고, 해당 변수를 100번 출력하는 것을 떠올릴 것이다.
(호출할 때마다 100번 계산을 할 수는 없으니까.)
위의 경우도 마찬가지이다. recompose를 가지는 state를 모든 상태를 업데이트 하는 것은 상당히 비효율적일 수 있다.
이런경우 우리는 다음과 같이 derivedStateOf를 활용할 수 있다.
@Composable
fun DerivedStateExample() {
var count by remember {
mutableStateOf(0)
}
val counterText by derivedStateOf { "The counter is $count" }
Button(onClick = { count ++ }) {
Text(text = counterText)
}
}
이렇게 derivedStateOf를 활용하면, counterText의 값은 변수와 같이 캐싱되어, 이후 counterText를 가지는 값은 따로 다시 계산하지 않고, 캐싱된 값을 가져옴으로써 업데이트가 된다.
snapshotFlow
이전에 produceState를 통해 collectAsState api에 대해 소개하였다. collectAsState는 Flow를 State로 변환한다고 설명하였다. snapshotFlow를 이것과 정확히 반대이다. State가 변경될 때마다 Flow로 변환하여 방출한다.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SnapshotFlowExample() {
val scaffoldState = rememberScaffoldState()
LaunchedEffect(key1 = ) {
snapshotFlow { scaffoldState.snackbarHostState }
.mapNotNull { it.currentSnackbarData?.message } // 워후 ~ 강력한 Flow Operation
.distinctUntilChanged() // StateFlow와 같이 새로운 값만 방출
.collect { message ->
println("scaffold compose state를 flow로 변환 및 방출 $message")
}
}
}
위 코드는 compose State인 scaffold State를 snapshotFlow를 통해 방출 및 수집하는 코드이다.
snapshotFlow를 통해, Flow의 강력한 연산자를 활용할 수 있고, 더 반응형스럽게 코드를 구현할 수 있다.
참고 자료
https://tourspace.tistory.com/412#
https://developer.android.com/jetpack/compose/side-effects?hl=ko