부수 효과는 흔히 Side Effect라고도 하며 함수가 만들어진 목적과는 다른 효과 또는 부작용을 뜻한다.
부수 효과가 있다면 예측할 수 없는 일들이 벌어질 가능성이 있고, 이는 버그로 발생될 수도 있고 유지보수 비용을 증가 시킨다.
이러한 부수효과가 없는 함수는 순수함수(pure function), 부수효과가 있는 함수는 불순함수(impure function)라고 한다.
컴포즈에서의 부수 효과도 같은 개념이지만 좀 더 자세히 설명하면, Composable
함수의 범위 밖에서 발생하는 앱 상태에서 관한 변경사항이다.
컴포저블의 수명 주기 및 속성으로 인해 컴포저블에는 부수 효과가 없는 것이 좋다
하지만 부수 효과가 필요한 경우가 있음.
예를 들면 스낵바를 표시하거나 특정 상태 조건에 따라 다른 화면으로 이동하는 등 일회성 이벤트를 트리거할 때이다.
이러한 작업은 컴포저블의 수명 주기를 인식하는 관리된 환경에서 호출해야 한다.
컴포저블 내에서 안전하게 suspend 함수를 호출하려면 LaunchedEffect
컴포저블을 사용하면 된다.
LaunchedEffect
가 컴포지션을 시작하면 매개변수로 전달된 코드 블록으로 코루틴이 실행됨.
LaunchedEffect
가 컴포지션을 종료하면 코루틴이 취소된다.
LaunchedEffect
가 다른 키로 재구성되면 기존 코루틴이 취소되고 새 코루틴에서 새로운 suspend 함수가 실행됨
@Composable
fun MyScreen(
state: UiState<List<Movie>>,
snackbarHostState: SnackbarHostState
) {
// If the UI state contains an error, show snackbar
if (state.hasError) {
// `LaunchedEffect` will cancel and re-launch if
// `scaffoldState.snackbarHostState` changes
LaunchedEffect(snackbarHostState) {
// Show snackbar using a coroutine, when the coroutine is cancelled the
// snackbar will automatically dismiss. This coroutine will cancel whenever
// `state.hasError` is false, and only start when `state.hasError` is true
// (due to the above if-check), or if `scaffoldState.snackbarHostState` changes.
snackbarHostState.showSnackbar(
message = "Error message",
actionLabel = "Retry message"
)
}
}
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
// ...
}
}
위 코드에서 snackbarHostState
에 오류가 포함되어 있으면 코루틴이 트리거되고 오류가 포함되어 있지 않으면 취소된다.
LaunchedEffect
호출 부분이 if문 내에 있으므로 문장이 거짓일 때 LaunchedEffect
가 컴포지션에 있으면 삭제되고 따라서 코루틴이 취소된다.
LaunchedEffect
는 컴포저블 함수이므로 다른 컴포저블 함수 내에서만 사용할 수 있다.
컴포저블 외부에 있지만 컴포지션을 종료한 후 자동으로 취소되도록 범위가 지정된 코루틴을 실행하려면 rememberCoroutineScope
를 사용하면 된다.
또한, 하나 이상의 코루틴 수명 주기를 수동으로 관리해야 할 때 (ex: 사용자 이벤트가 발생할 때 애니메이션을 취소해야하는 경우) rememberCoroutineScope
를 사용하면 된다.
사용자가 Button
을 클릭할 때마다 Snackbar
를 표시할 수 있다.
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
// Creates a CoroutineScope bound to the MoviesScreen's lifecycle
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
Column(Modifier.padding(contentPadding)) {
Button(
onClick = {
// Create a new coroutine in the event handler to show a snackbar
scope.launch {
snackbarHostState.showSnackbar("Something happened!")
}
}
) {
Text("Press me")
}
}
}
}
주요 매개변수 중 하나가 변경되면 LaunchedEffect
가 다시 시작된다.
하지만 경우에 따라 Effect
에서 값이 변경되면 Effect
를 다시 시작하지 않을 값을 캡처할 수 있다.
이렇게 하려면 rememberUpdatedState
를 사용하여 캡처하고 업데이트할 수 잇는 이 값의 참조를 만들어야 한다.
이 접근 방식은 비용이 많이 들거나 다시 만들고 다시 시작할 수 없도록 금지된 오래 지속되는 작업이 포함된 Effect
에 유용하다.
예를 들면 앱에 시간이 지나면 사라지는 LandingScreen이 있다고 가정하자.
LandingScreen이 리컴포지션되는 경우에도 일정 시간 동안 대기하고 시간이 경과되었음으로 알리는 효과는 다시 시작해서는 안 된다.
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// This will always refer to the latest onTimeout function that
// LandingScreen was recomposed with
val currentOnTimeout by rememberUpdatedState(onTimeout)
// Create an effect that matches the lifecycle of LandingScreen.
// If LandingScreen recomposes, the delay shouldn't start again.
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}
/* Landing screen content */
}
호출 부분의 수명 주기와 일치하는 효과를 만들기 위해 Unit 또는 true와 같이 변경되지 않는 상수가 매개변수로 전달된다.
위 코드에서는 LaunchedEffect(true)
가 사용된다.
onTimeout
람다에 LandingScreen이 리컴포지션된 최신 값이 항상 포함되도록 하려면 rememberUpdatedState
로 onTimeout
을 래핑해야 한다.
즉, rememberUpdatedState(onTimeout)
을 사용하지 않고 onTimeout
을 직접 사용한다면, LaunchedEffect
내에서 onTimeout
람다가 처음 실행될 때 캡처된다.
이는 LandingScreen이 리컴포지션되어 onTimeout
의 새로운 람다를 받더라도, LaunchedEffect
내에서는 여전히 이전의 onTimeout
람다를 사용하게 된다.
상황
onTimeout
람다는 화면 전환 동작을 정의onTimeout = { navController.navigate("nextScreen") }
로 설정onTimeout
람다가 새로운 동작으로 변경onTimeout = { navController.navigate("alternativeScreen") }
로 변경LaunchedEffect
내의 delay(5000)
가 끝나고 onTimeout
을 호출문제 발생
rememberUpdatedState 사용 안 함:
LaunchedEffect
는 초기 onTimeout
람다를 사용합니다.navController.navigate("nextScreen")
가 호출됩니다.navController.navigate("alternativeScreen")
)는 호출되지 않습니다.rememberUpdatedState 사용
LaunchedEffect
는 항상 최신 onTimeout
람다를 참조합니다.navController.navigate("alternativeScreen")
가 호출됩니다.키가 변경되거나 컴포저블이 컴포지션을 종료한 후 정리해야 하는 부수 효과의 경우 DisposableEffect
를 사용하면 된다.
DisposableEffect
키가 변경되면 컴포저블이 현재 효과를 삭제(정리)하고 효과를 다시 호출하여 재설정해야 한다.
예를 들면 LifecycleObserver
를 사용하여 Lifecycle
이벤트를 기반으로 애널리틱스 이벤트를 전송할 수 있다.
컴포즈에서 이 이벤트를 수신 대기하려면 DisposableEffect
를 사용하여 필요에 따라 등록하고 취소하면 된다.
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Send the 'started' analytics event
onStop: () -> Unit // Send the 'stopped' analytics event
) {
// Safely update the current lambdas when a new one is provided
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// If `lifecycleOwner` changes, dispose and reset the effect
DisposableEffect(lifecycleOwner) {
// Create an observer that triggers our remembered callbacks
// for sending analytics events
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
// Add the observer to the lifecycle
lifecycleOwner.lifecycle.addObserver(observer)
// When the effect leaves the Composition, remove the observer
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
/* Home screen content */
}
위의 코드에서는 Effect
가 observer
를 lifecycleOwner
에 추가한다. lifecycleOwner
가 변경되면 효과가 삭제되고 새 lifecycleOwner
로 다시 시작된다.
DisposableEffect
는 onDispose
절을 코드 블록의 마지막으로 포함해야 한다.
컴포즈에서 컴포즈에서 관리하지 않는 객체와 컴포즈 상태를 공유할 때는 SideEffect
컴포저블을 사용하면 된다.
SideEffect
를 사용하면 리컴포지션에 성공할 때마다 Effect가 실행된다.
반면 컴포저블에 Effect
를 직접 작성하는 경우 성공적인 리컴포지션이 보장되기 전에 Effect
를 실행하는 것을 잘못되었다.
예를 들어 애널리틱스 라이브러리를 사용하면 커스텀 메타데이터(이 예에서는 ‘사용자 속성’)를 이후의 모든 애널리틱스 이벤트에 연결하여 사용자 인구를 분류할 수 있다. 현재 사용자의 사용자 유형을 애널리틱스 라이브러리에 전달하려면 SideEffect
를 사용하여 값을 업데이트 한다.
간단하게 정리하자면 SideEffect는 리컴포지션이 성공적으로 완료될 때마다 실행되는 코드 블록을 정의하고, 주로 Compose 외부의 상태를 업데이트하거나 동기화하는 데 사용된다.
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
// 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
}
produceState
는 반환된 State
로 값을 푸시할 수 있는 컴포지션으로 범위가 지정된 코루틴을 실행한다.
비 컴포즈 상태를 컴포즈 상태로 변환하려면, 예를 들어 Flow, LiveData
또는 RxJava
와 같은 외부 구독 기반 상태를 컴포지션으로 변환하려면 이 코루틴을 사용해야 한다.
produceState
가 컴포지션을 시작하면 프로듀서가 실행되고 컴포지션을 종료하면 취소된다. 반환된 State는 합성되고 동일한 값을 설정해도 리컴포지션이 트리거되지 않는다.
produceState
가 코루틴을 만드는 경우에도 정지되지 않는 데이터 소스를 관찰하는 데 사용할 수 있고, 이 소스의 구독을 삭제하려면 awaitDispose
함수를 사용하면 된다.
@Composable
fun loadNetworkImage(url: String): State<ImageBitmap?> {
// produceState를 사용하여 네트워크에서 이미지를 로드하고 Compose 상태로 변환합니다.
return produceState<ImageBitmap?>(initialValue = null, url) {
// 네트워크에서 이미지를 로드하는 가상 함수
value = loadImageFromNetwork(url)
// 컴포지션이 종료되면 구독을 취소합니다.
awaitDispose {
// 여기에서 구독을 정리하는 코드
println("Subscription disposed")
}
}
}
// 가상 네트워크 이미지 로드 함수
suspend fun loadImageFromNetwork(url: String): ImageBitmap? {
// 네트워크에서 이미지를 로드하는 데 시간이 걸리는 작업 시뮬레이션
delay(3000)
// 실제 네트워크 로드 대신 null 반환 (여기서 실제 이미지 로드 로직이 들어감)
return null
}
@Composable
fun ImageScreen(url: String) {
val imageState by loadNetworkImage(url)
// 이미지를 로드하는 동안 로딩 상태를 표시
if (imageState == null) {
Text("Loading image...")
} else {
// 이미지가 로드되면 이미지를 표시
Image(bitmap = imageState!!, contentDescription = null)
}
}
💡 리턴 타입이 있는 컴포저블은 일반 Kotlin 컨벤션처럼 소문자로 시작하는 이름을 지정해야 한다.
produceState
같은 경우에는 뷰모델을 사용하여 Compose
외부에서 상태를 관리하는 일반적인 방식으로 개발을 할 때는 딱히 많이 사용하는 사례는 없을 것 같다.
컴포즈에서는 관찰된 상태 객체 또는 컴포저블 입력이 변경될 때마다 리컴포지션이 발생한다. 상태 객체 또는 입력이 UI가 실제로 업데이트해야 하는 것보다 더 자주 변경되어 불필요한 리컴포지션이 발생할 수 있다.
컴포저블에 관한 입력이 리컴포지션해야 하는 것보다 더 자주 변경되는 경우 derivedStateOf
함수를 사용해야 한다. 이는 스크롤 위치와 같이 항목이 자주 변경되지만 컴포저블은 특정 기준점을 넘어야만 반응해야 할 때 발생한다. derivedStateOf
는 필요한 만큼만 업데이트되는 관찰 가능한 새로운 Compose
상태 객체를 만든다.
즉, 상태가 변경되더라도 특정 조건이 충족될 떄만 관찰자에게 변경을 알리므로 불필요한 리컴포지션을 줄이는 데 유용하다.
@Composable
fun ScrollExample() {
// 스크롤 상태를 기억합니다.
val listState = rememberLazyListState()
// 스크롤 위치가 특정 기준점 (예: 100dp) 을 넘었는지 여부를 derivedStateOf를 사용하여 파생합니다.
val hasScrolledPastThreshold by derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 100
}
// UI 업데이트: 스크롤 위치가 기준점을 넘었을 때만 리컴포지션 됩니다.
if (hasScrolledPastThreshold) {
Text("Scrolled past threshold!")
} else {
Text("Scroll down to see the effect")
}
LazyColumn(state = listState) {
items(100) { index ->
Text("Item #$index")
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewScrollExample() {
ScrollExample()
}
rememberLazyListState
를 사용하여 LazyColumn
의 스크롤 상태를 기억한다. 이는 현재 스크롤 위치와 관련된 상태를 관리한다.derivedStateOf
를 사용하여 hasScrolledPastThreshold
라는 파생 상태를 생성하고, 이 상태는 스크롤 위치가 특정 기준점을 넘었는지 여부를 나타낸다.listState.firstVisibleItemIndex
와 listState.firstVisibleItemScrollOffset
를 관찰하여 스크롤 위치가 기준점을 넘었는지 확인한다.hasScrolledPastThreshold
상태에 따라 텍스트를 표시한다.derivedStateOf
를 사용하여 파생되었기 때문에, 스크롤 위치가 변경되더라도 기준점을 넘지 않는 한 리컴포지션이 발생하지 않는다.snapshotFlow
를 사용하여 State<T>
객체를 콜드 Flow로 변환한다. snapshotFlow
는 수집될 때 블록을 실행하고 읽은 State 객체의 결과를 내보낸다. snapshotFlow
블록 내에서 읽은 State
객체의 하나가 변경되면 새 값이 이전에 내보낸 값과 같지 않은 경우 Flow
에서 새 값을 collector
에 내보낸다. ( 이 동작은 Flow.distictUntilChanged
의 동작과 비슷함)
@Composable
fun ScrollAnalytics() {
// 스크롤 상태를 기억합니다.
val listState = rememberLazyListState()
// 현재 컴포지션의 CoroutineScope를 얻습니다.
val coroutineScope = rememberCoroutineScope()
// 스크롤 위치를 관찰하여 Flow로 변환합니다.
val scrollFlow = remember {
snapshotFlow { listState.firstVisibleItemIndex }
}
// CoroutineScope 내에서 Flow를 수집하고 상태 변화를 관찰합니다.
LaunchedEffect(Unit) {
coroutineScope.launch {
scrollFlow
.filter { it > 0 } // 첫 번째 항목을 넘었는지 확인
.distinctUntilChanged() // 동일한 값을 여러 번 내보내지 않음
.collect {
// 첫 번째 항목을 넘었을 때 애널리틱스 이벤트 기록
AnalyticsLibrary.logEvent("Scrolled past first item")
}
}
}
// UI 구성
LazyColumn(state = listState) {
items(100) { index ->
Text("Item #$index")
}
}
}
LaunchedEffect
, produceState
, DisposableEffect
와 같은 컴포즈의 일부 Effect에서 실행 중인 Effect를 취소하는 데 사용되는 가변적인 수의 인수를 취하고 새 Key
로 새 Effect
를 시작한다.
EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }
대체적으로 Effect
코드 블럭에 사용되는 변경할 수 있는 변수와 변경할 수 없는 변수는 Effect
컴포저블에 매개변수로 추가해야 한다. 이 매개변수 외에 Effect
를 강제로 다시 시작하도록 더 많은 매개변수를 추가할 수 있다.
변수를 변경해도 효과가 다시 시작되지 않아야 하는 경우 변수를 rememberUpdatedState
에 래핑해야 한다.
변수가 키가 없는 remember
에 래핑되어 변경되지 않으면 변수를 Effect
에 Key
로 전달할 필요가 없다.
@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit, // Send the 'started' analytics event
onStop: () -> Unit // Send the 'stopped' analytics event
) {
// These values never change in Composition
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
/* ... */
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
currentOnStart
및 currentOnStop
은 DisposableEffect
키로 필요하지 않다. rememberUpdatedState
의 사용으로 컴포지션에서 이 키의 값이 변경되지 않기 때문이다.
lifecycleOwner
가 매개변수로 전달되지 않고 변경되면 HomeScreen은 재구성되지만 DisposableEffect
는 삭제되거나 다시 시작되지 않는다.
https://developer.android.com/develop/ui/compose/side-effects