Composable 함수에서의 부수효과란, UI 범위 외부에서 발생한 이벤트가 UI 상태를 변경시키는 작업을 의미한다. 여기서 중요한건 '외부 범위'를 어디로 지정할까인데, 첫 번째로 ViewModel이 있다. 이는 Composable함수 범위 밖에서 UI 상태를 관리하는 객체로써 이 곳에서 발생하는 이벤트는 UI의 상태를 변경하기에 충분하다. 여기서 UI의 상태란 UIState뿐만 아니라, 1회성으로 UI 변경을 트리거하는 것을 의미하며, 가령, 화면이동, 스낵바 로딩 등이 있다.
두 번째로는 Composable함수 내부이지만 UI로직이 아닌 곳으로부터 발생한 이벤트가 UI를 변경시키는 경우가 있다. 가령, Composable함수의 .clickable {...}
와 같이 Composable Scope가 아닌 곳으로부터 발생한 이벤트로 UI의 상태를 변경시키는 것이며, 단순한 예로 스낵바를 띄울때가 있다. 또는 Composable함수 범위이지만 LaunchedEffect { ... }
의 경우도 Composable Scope가 아닌 곳으로부터 발생한 이벤트가 UI의 변경을 트리거하는 경우도 있다. 즉, 부수효과란 UI 범위가 아닌 곳, 좀 구체적으로 말하자면 Composable함수의 Scope가 아닌 곳에서 발생한 이벤트가 UI 변경을 트리거하는걸 의미한다.
Compose는 함수형, 선언형 UI이다. 즉, 입력이 동일하면 출력도 동일해야한단 뜻이다. 하지만 내부에 어쩔 수 없이 부수효과가 들어가야만 할 때가 있다. 이를 위해 Compose API에는 이러한 부수효과를 적재적소 잘 관리할 수 있는 6가지 API를 제공한다.
key
파라미터가 존재하는데, 해당 값 변경 시, Coroutine 블록이 재실행된다.위 특성을 활용하여 활용이 여럿 가능하다. 나의 경우, Paging3
라이브러리를 안쓰고 LauchedEffect
를 사용하여 이를 직접 구현하는 부수효과를 만들었다. Lazy한 스크롤에서 item의 버퍼 갯수에 해당하는 item에 도착할 경우, onScrollToEnd()
호출한다.
@Composable
fun PaginationLoadEffect(
listState: LazyListState,
onScrollToEnd: () -> Unit,
bufferItemCount: Int = 5
) {
val shouldLoadMore by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false
val totalItemCount = layoutInfo.totalItemsCount
totalItemCount != 0 &&
lastVisibleItemIndex >= totalItemCount - bufferItemCount
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) onScrollToEnd()
}
}
또한 MVI패턴에서 UiSideEffect
를 옵저빙받는 코드를 정의할때도 사용한다. 해당 옵저빙 코드는 리컴포지션에따라 여러번 옵저빙될 경우, 핫 스트림 특성에 따라 복수개의 옵저빙 코드가 생성된다. 따라서 key
파라미터를 true or Unit과 같이 고정시켜놓음으로써 첫 컴포지션 때, 1번만 호출되도록 설정한다.
LaunchedEffect(Unit) {
viewModel.uiSideEffect.collect {
is MainUiSideEffect.OnNavigatTo_A_Activity -> {}
is MainUiSideEffect.OnNavigatTo_B_Activity -> {}
is MainUiSideEffect.OnFinish -> {}
is MainUiSideEffect.OnShowToastMessage -> {}
}
}
하지만, 곧 다루게 될 rememberCoroutineScope
와 비교했을 때, LaunchedEffect
는 해당 함수의 수명주기가 @Composable함수에 묶여있다.
LaunchedEffect
와는 달리, 첫 컴포지션 때 Coroutine이 자동으로 호출되지 않으며, 개발자가 launch
를 직접 호출해줘야 한다.clickable {...}
)때, Coroutine을 시작하기에 좋다.rememberCoroutineScope
또한 정의하는 첫 위치가 composable함수 내부라는 점은 동일하다. 하지만 Coroutine builder를 사용해 코루틴 작업을 시작할 수 있는 위치가 일반 람다(eg., .clickable { ... }
)에서도 가능하다는 점이 특징이다. 이로인해, rememberCoroutineScope
로 시작된 코루틴은 composable범위가 아니라 하더라도, Coroutine의 범위가 Composable함수에 묶여 있기에, 해당 Composable함수가 사라질 경우, 해당 Coroutine은 제거된다는 특징이 있다. rememberCoroutineScoope
를 사용하는 대표적인 예시로 clickable { ... }
또는 Button()
Composable 함수의 버튼을 클릭해 코루틴 작업을 트리거하는 것이다.
@Composable
fun SaveButton(viewModel: MyViewModel) {
val coroutineScope = rememberCoroutineScope()
Button(onClick = {
coroutineScope.launch {
viewModel.saveData()
}
}) {
Text("저장")
}
}
LauchedEffect
와 rememberCoroutineScope
를 비교해보면 아래와 같다.
항목 | rememberCoroutineScope() | LaunchedEffect |
---|---|---|
실행 시기 | 원할 때 수동으로 실행 | 첫 Composition, 상태값 변경에 따른 Recomposition |
용도 | 사용자 이벤트 처리 | 첫 초기화, 변경사항 처리 |
예시 | 버튼 클릭 및 비동기 작업 처리 | 화면 첫 진입에 따른 비동기 작업 처리, key 변경에 따른 재동작 처리 |
key
파라미터가 없다. 이는 곧 UI가 그려지는 첫 시점인 Composition과 업데이트되는 시점인 Recomposition때마다, 람다가 매번 호출된다는 의미다.SideEffect
내에서 composable함수의 상태 변수를 업데이트할 경우 무한 Recomposition에 빠지니 주의한다.이전 LaunchedEffect
는 내부 람다가 중단함수를 실행시킬 수 있었다. 하지만 SideEffect
와 이후 소개할 DisposableEffect
는 내부 블럭이 일반 람다이다. 즉 Coroutine실행이 불가능하다는 의미이다.
또한 SideEffect
는 LauchedEffect
, DisposableEffect
와 달리 key
파라미터가 없다. 이는 곧, 내부 람다 실행 시점이 UI가 처음 그려지는 Composition과 업데이트되는 Recomposition에 모두 발생한다는 것이다.
다만, Composable함수의 실시간 상태를 외부에서 알아야할 때 SideEffect
를 사용하면 좋다. 가령, 사용자가 특정 이벤트를 발생시켜 UI가 변경되고, 이로 인해 SideEffect
람다가 호출된다 가정하자. 이때, 로그를 찍는다든지, FirebaseAnalytics와같은 사용자 트래킹툴로 실시간 로그를 전송해야할 때 특히 유용할 수 있다.
@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
}
key
파라미터가 존재하므로, LaunchedEffect
와 갱신 시점이 같다.onDispose
를 통한 정리작업이 있을 경우 유용하다.DisposableEffect
를 활용하기 좋을 때는 특정 작업을 해제해야 할때이다. 대표적으로 LifecycleEventObserver
를 사용하여 Composable함수 내에서 Activity의 생명주기를 가져오는 코드를 사용하곤 하는데, 이는 추후, lifecycleOwner.lifecycle.removeObserver(observer)
를 해야하는 만큼 적절한 쓰임새라 볼 수 있다.
@Composable
fun LifecycleEffect(
onCreate: () -> Unit = {},
onStart: () -> Unit = {},
onRestart: () -> Unit = {},
onResume: () -> Unit = {},
onPause: () -> Unit = {},
onStop: () -> Unit = {},
onDestroy: () -> Unit = {},
) {
val lifecycleOwner = LocalLifecycleOwner.current
var wasStopped by remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> onCreate()
Lifecycle.Event.ON_START -> {
if (wasStopped) {
onRestart()
wasStopped = false
}
onStart()
}
Lifecycle.Event.ON_RESUME -> onResume()
Lifecycle.Event.ON_PAUSE -> onPause()
Lifecycle.Event.ON_STOP -> {
wasStopped = true
onStop()
}
Lifecycle.Event.ON_DESTROY -> onDestroy()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
아래는 LaunchedEffect
와 DisposableEffect
를 비교한 표이다.
항목 | LaunchedEffect | DisposableEffect |
---|---|---|
목적 | Coroutine 기반 작업 실행 | 리소스 등록/해제 |
실행 시점 | Composition 진입 시 | Composition 진입 시 & 제거 시 |
종료 처리 | 자동 취소 (Job.cancel) | onDispose 로 명시적 정리 |
예시 | API 요청, 초기화 | 리시버 등록, 옵저버 등록/해제 |
distinctUntilChange()
처럼 값의 연속 수신을 제한하는 역할을 한다.LazyListState
로부터 스크롤 위치를 받아오는데, 이 값이 단순 경계값 초과에 따른 true/false로만 반환될 경우, derivedStateOf
사용을 고려할 수 있다.distinctUntilChange()
와 동일한 역할Compose는 선언형 UI므로, 상위 상태 변수의 변경에 따라 UI가 선택적으로 변화한다. 하지만 상위 상태 변수가 너무 많이 바뀐다면 문제가 생긴다. 바로, 해당 변수가 주입하는 Composable함수 또한 그만큼 빈번하게 Recomposition이 된다는 점이다.
즉, UI에서 필요한 상태값 변경보다 상위 Composable 함수의 상태 변수의 변경이 더 많다면, derivedStateof
를 사용할 수 있다.
아래의 코드는 LazyListState
로부터 받아온 하위 item의 인덱스가 0보다 클 경우, 특정 애니메이션의 visibility를 결정해주는 코드이다. 이때, derivedStateOf
를 사용하지 않는다면 불필요하게 많이 Recomposition되는 스크롤 상태값을 고스란히 받아 UI에 전달해주게 되고, 이로인해 UI가 버벅거릴 수 있다.
@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
Box {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary compositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
}
}
또 다른 예시로, 입력하는 텍스트의 길이에 따라, '제목이 깁니다.' 또는 '제목이 짧습니다.'라는 말을 표현해준다 하자. 이때 입력받는 텍스트의 길이는 계속해서 변화하게 되는데, 이에따라 무분별한 Recomposition이 발생할 수 있다. 방지를 위해 derivedStateOf
를 사용함으로써 경계값이 지날때만 Recomposition을 발생시킨다.
@Composable
fun TitleLengthChecker(title: String) {
val isLongTitle by remember(title) {
derivedStateOf { title.length > 10 }
}
Text(text = if (isLongTitle) "제목이 깁니다" else "제목이 짧습니다")
}
collect()
등을 사용하여 Flow 구독을 시작하는 시점이다.distinctUntilChange()
와 derivedStateOf()
와 유사하게 동작한다.서버 가상머신을 띄워보면 '스냅샷'이라는 기능을 볼 수 있는데, 이는 해당 서버가 가지고 있는 상태를 말 그대로 사진을 찍는다는 의미이다. 이로써, 작업중인 서버가 장애가 났다 하더라도, 스냅샷을 찍어놓은 시점으로 원복하면 그때의 상태 그대로 롤백이 가능하다.
snapshotFlow
도 이와 같다. Composable함수의 상태값의 특정 시점에 사진을 찍는다는 뜻이며, 첫 번째로 값을 캡쳐하는 시기는 collect()
등을 사용한 종단 연산자를 통한 스트림의 구독 시점이다.
그 이후, snapshotFlow()
가 캡쳐한 상태값이 변경됐을 경우, 그때에만 값을 다운스트림하여 구독자에서 이를 수신받을 수 있다.
아래 코드는 @Stable 마커로 표시된 LazyListState
이다. 해당 객체는 public으로 선언된 프로퍼티들이 변경될 때, Compose Runtime에게 Recomposition을 지시할 수 있으며 이를 LaunchedEffect()
의 key
파라미터로써 내부 중단함수 블럭을 트리거하게된다.
그로써 snapshotFlow
는 해당 상태변수가 변경됐을 때, snapshotFlow()
의 스트림을 제거한 후, 다시 만들게되며 이로써 collect()
의 재호출 및 listState.firstVisibleItemIndex
를 새롭게 캡쳐한 값으로 다운스트림받게된다.
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { index -> index > 0 }
.distinctUntilChanged()
.filter { it == true }
.collect {
MyAnalyticsService.sendScrolledPastFirstItemEvent()
}
}