사진: Unsplash의Paul Green
Compose를 사용해보셨다면 Side-effect에 대해 들어보셨을 겁니다. 이를 한글로 번역하면 부작용입니다. 말 그대로 Side-effect는 Compose를 사용할 때 발생하는 부작용을 말합니다.
부작용이란 말을 들으면 부정적인 반응이 오기 마련이지만, Compose는 이러한 부작용을 예측가능한 방식으로 실행되도록 다양한 방법을 제공합니다. 부작용을 예측가능하게 사용하면 더 강력한 기능을 합니다.
이번 게시글에서는 공식 문서를 읽어보면서 Side-effect에 대응하는 방법에 대해 알아보고자 합니다.
Compose와 Side-effect
Side-effect의 정의는 컴포저블 함수의 범위 밖에서 발생하는 상태 변화를 말합니다. 기본적으로 컴포저블의 다양한 특성(생명주기, 리컴포지션 등) 때문에 컴포저블 함수는 Side-effect가 없어야 합니다.
Side-effect는 성능 향상과 유지보수의 관점에서 컴포저블 함수와 분리되어야 합니다. 만약 리컴포지션 과정에서 불필요한 Side-effect가 계속해서 발생한다면, 리컴포지션의 성능이 저하될 수 있습니다. 또한 Side-effect와 UI를 분리하는 것으로 UI를 구성하는 논리와 상태를 변경하는 로직을 명확히 분리할 수 있습니다.
그러나, 특정 조건에서 스낵바를 표시하거나 다른 화면으로 이동하는 것과 같은 일회성 이벤트에 대응해야 할 경우에는 Side-effect가 필요합니다. 이러한 작업은 컴포저블의 생명주기를 인지하는 제어된 환경에서 호출되어야 합니다.
만약 앱의 상태를 변화시키고자 한다면, Side-effect가 예측 가능한 방식으로 실행되도록 Effect API(Effect.kt)를 사용해야 합니다.
Effect
Effect는 UI를 생성하지 않고 Side-effect가 실행되는 컴포저블 함수입니다. Effect에서 수행하는 작업은 UI와 관련이 있으며 UDF(단방향 데이터 흐름)를 깨뜨리지 않아야 합니다.
Effect API의 종류
LaunchedEffect
LaunchedEffect는 컴포저블 함수 내에서 suspend function을 안전하게 실행할 수 있게 하는 컴포저블 함수입니다.
fun LaunchedEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val applyContext = currentComposer.applyCoroutineContext
remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
LaunchedEffect가 composition 단계에 들어가면 파라미터로 전달된 코루틴 블록을 실행합니다. 만약 LaunchedEffect가 composition 단계에서 벗어나면 코루틴은 자동으로 취소됩니다.
LaunchedEffect에 전달되는 key의 값이 만약 달라진다면, 이전에 존재하던 코루틴은 취소되고 새로운 suspend 함수가 새로운 코루틴에서 실행됩니다.
이런 특성 때문에 LaunchedEffect는 단순히 suspend 함수를 실행하는 것보다는 데이터 가져오기, 이벤트 처리같은 작업에 가장 적합합니다.
rememberCoroutineScope
`rememberCoroutineScope`는 적용할 Composable 함수 외부에서 코루틴을 실행해야 하거나, 컴포지션을 벗어날 때 자동으로 취소되도록 범위가 지정되어야 한다면 사용할 수 있습니다.
또한, 여러 개의 코루틴을 수동으로 관리해야할 때 사용할 수도 있습니다.
@Composable
fun MyComponent() {
val scope = rememberCoroutineScope()
// scope를 사용하여 코루틴을 실행
scope.launch {
// some background work
}
}
rememberUpdateState
LaunchedEffect는 이전에 말했듯 Key 값이 변경되면 이전의 코루틴을 종료하고 새로운 코루틴을 시작합니다.
다만, 특정 상황에서는 해당 값이 변경되더라도 새로운 코루틴을 다시 시작하고 싶지 않을 수 있습니다. 이때는 `rememberUpdateState`를 사용해서 이 값을 참조하는 객체를 생성해야 합니다.
이 메서드는 작업이 재생성되거나 다시 시작되는 것이 비용이 너무 많이 들거나 불가능할 경우에 유용합니다.
import androidx.compose.runtime.*
import kotlinx.coroutines.delay
import androidx.compose.material3.*
@Composable
fun LandingScreen() {
// 사용자가 랜딩 화면을 떠날 때까지 대기하는 시간 (예: 3초)
val timeToWait = 3000L
// timeToWait 값이 변경되어도 LaunchedEffect가 다시 실행되지 않도록 하려면 rememberUpdatedState 사용
val currentTimeToWait by rememberUpdatedState(timeToWait)
// LandingScreen이 나타난 후 일정 시간 뒤에 화면을 숨기는 효과
LaunchedEffect(Unit) {
delay(currentTimeToWait) // timeToWait가 변경되었을 때 이 값은 갱신됨
// 여기서 알림을 보내거나 다른 작업을 할 수 있습니다.
println("Time's up, hiding LandingScreen!")
}
// 랜딩 화면 UI
Text("Landing Screen")
}
상태가 변경하는 값을 사용해야 하지만, 작업을 재생성하고 싶지는 않는 경우에 이를 사용할 수 있을 것 같습니다.
DisposableEffect
키가 변경되거나 컴포지션 단계가 종료될 때, 정리가 필요한 Side-effect가 있을 경우 DisposableEffect를 사용하면 됩니다.
@Deprecated(DisposableEffectNoParamError, level = DeprecationLevel.ERROR)
fun DisposableEffect(
effect: DisposableEffectScope.() -> DisposableEffectResult
): Unit = error(DisposableEffectNoParamError)
Compose에서 DisposableEffect를 사용하는 가장 흔한 예시는 `LifecycleEventObserver`를 사용할 떄 입니다.
`LifecycleEventObserver`는 Activity나 Fragment의 생명 주기 이벤트를 감지할 수 있는 옵저버입니다. 아래와 같이 사용하여 생명 주기에 맞춰서 Side-effect를 처리할 수 있습니다.
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> Log.d(tag, "ON_CREATE")
Lifecycle.Event.ON_START -> Log.d(tag, "ON_START")
Lifecycle.Event.ON_RESUME -> Log.d(tag, "ON_RESUME")
Lifecycle.Event.ON_PAUSE -> Log.d(tag, "ON_PAUSE")
Lifecycle.Event.ON_STOP -> Log.d(tag, "ON_STOP")
Lifecycle.Event.ON_DESTROY -> Log.d(tag, "ON_DESTROY")
else -> { /* 기타 이벤트 */ }
}
}
}
위의 코드 자체로는 LaunchedEffect를 사용해도 되지만, DisposableEffect를 사용하는 가장 큰 이유는 옵저버를 해제 해줘야할 필요가 있기 때문입니다. 만약 옵저버를 해제하지 않는다면 참조가 계속 유지되므로 해당 컴포넌트가 파괴되어도 가비지 컬렉터가 메모리를 회수하지 못합니다. 그렇기 때문에 DisposableEffect를 사용하여 아래와 같이 해제해줘야 합니다.
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> Log.d(tag, "ON_CREATE")
Lifecycle.Event.ON_START -> Log.d(tag, "ON_START")
Lifecycle.Event.ON_RESUME -> Log.d(tag, "ON_RESUME")
Lifecycle.Event.ON_PAUSE -> Log.d(tag, "ON_PAUSE")
Lifecycle.Event.ON_STOP -> Log.d(tag, "ON_STOP")
Lifecycle.Event.ON_DESTROY -> Log.d(tag, "ON_DESTROY")
else -> { /* 기타 이벤트 */ }
}
}
lifecycleOwner.lifecycle.addObserver(observer)
// onDispose 블록에서 반드시 해제
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
SideEffect
SideEffect는 Composition이 성공적으로 마무리 되었을 때 실행되는 컴포저블입니다. 만약 단순하게 특정 작업을 본문에 작성한다면, Composition이 성공적으로 마무리 되기 전에 실행될 수 있습니다.
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
// 매번 성공적인 컴포지션 시점에 FirebaseAnalytics에
// 현재 사용자 유형(user.userType)을 업데이트하여,
// 이후 발생하는 모든 분석 이벤트에 이 메타데이터가 첨부되도록 보장합니다.
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
이처럼 SideEffect를 활용하면 Compose 상태와 비-Compose 코드 간의 일관된 동기화를 보장할 수 있습니다. 어떠한 외부 시스템과 상태를 공유해야 할 때, 반드시 재구성이 완료된 시점에 실행되도록 SideEffect를 사용하시기 바랍니다.
다만, SideEffect는 리컴포지션이 일어날 떄마다 실행되며 DisposableEffect와 같이 DIspose될 떄 정리를 할 수가 없다는 단점이 존재합니다. 그렇기 때문에 사용하기가 어렵습니다.
produceState
produceState는 비-Compose 상태를 Compose 상태로 변경해주는 Side-effect입니다. Flow, LiveData, RxJava 같은 외부 구독 기반 상태를 Composition 내부에서 사용해야 할 때 유용합니다.
- produceState 프로듀서는 해당 컴포저블이 Composition에 들어올 때 시작되며, Composition에서 사라지면 자동으로 취소됩니다.
- 반환된 State는 conflated 되어, 같은 값을 다시 설정해도 재구성을 트리거하지 않습니다.
- 비록, 코루틴을 생성하지만 suspend 함수가 아닌 데이터 소스도 관찰할 수 있습니다. 이때 구독을 해제하려면 `awitDispose` 함수를 사용합니다.
produceState는 간접적으로 많이 사용됩니다. `collectAsState` 메서드를 보면 내부에서 produceState를 통해 외부 구독 기반 상태은 Flow를 Composition 내부에서 사용할 수 있게 한다는 것을 알 수 있습니다.
@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 }
}
}
이러한 작동 방식은 내부에서 LaunchEffect를 활용해서 간단하게 만들어집니다.
@Composable
fun <T> produceState(
initialValue: T,
key1: Any?,
key2: Any?,
producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val result = remember { mutableStateOf(initialValue) }
LaunchedEffect(key1, key2) {
ProduceStateScopeImpl(result, coroutineContext).producer()
}
return result
}
derivedStateOf
상태를 변형하거나 다양한 상태를 하나의 상태로 변환하려면 derivedStateOf를 사용합니다. 아래와 같이 List의 상태에 따라 별도의 상태를 만들려고 한다면 아주 쉽게 사용할 수 있습니다.
@Composable
fun MessageList(messages: List<Message>) {
Box {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
// minimize unnecessary compositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
}
}
snapshotFlow
produceState를 통해 비-Compose 상태를 Compose 상태로 변경할 수 있다고 말했습니다. 그럼 이 반대로 있어야겠죠.
snapshotFlow는 Compose 상태를 비-Compose 상태인 Flow로 변환하는 메서드입니다.
- snapshotFlow는 Flow가 collect 될 때 블록을 실행하고, 그 안에서 읽은 State 객체의 값을 방출합니다.
- 블록 내에서 읽은 State 중 하나가 변경되면, 이전에 방출된 값과 다를 경우에만 새로운 값을 방출합니다.
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// 리스트 항목...
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 } // 첫 번째 아이템 이후로 스크롤되었는지 불리언으로 변환
.distinctUntilChanged() // 값이 바뀔 때만 다음 연산 진행
.filter { it } // true(첫 번째 이후)인 경우만 통과
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}
Reference
Compose의 부수 효과 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose의 부수 효과 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 부수 효과는 구성 가능한 함수의 범
developer.android.com
'Android > Compose' 카테고리의 다른 글
ViewModel 톺아보기 (1) - ViewModel의 생성과 관리 (0) | 2024.11.18 |
---|---|
SavedStateHandle을 통해 Compose Navigation간 데이터 전달하기 (3) | 2024.09.23 |
Type Safety를 지원하는 Compose Navigation으로 이전하기 (1) | 2024.07.09 |
Compose의 WindowInsets (0) | 2024.04.23 |
Compose의 SnapShot 시스템 (0) | 2024.03.29 |