포스트

Android · Compose

Compose에서 SingleLiveEvent를 사용하며 겪은 두 가지 문제

LaunchedEffect 안에서 LiveData를 observe할 때 완료된 CoroutineScope를 참조하는 문제와 조건부 composition의 observer 중복 등록, Google SingleLiveEvent wrapper 때문에 해제가 실패하는 이유를 살펴본다

이 글의 목차

2026-09-13 정정: 기존의 removeObserver(observer) 해결 코드는 일반 LiveData에만 적용됩니다. Google 샘플의 SingleLiveEvent는 내부에서 별도 observer를 등록하므로 원래 observer를 넘겨도 제거되지 않습니다. 해당 설명과 예제를 수정했습니다.

오늘은 LiveData를 Compose에서 사용하면서 겪었던 오류들을 공유해보려고 합니다.

단일 이벤트를 처리하는 코드는 크게 두 방향으로 나뉩니다.

  1. UI state에 처리할 값을 담고, Action을 수행한 뒤 해당 값을 비우는 방법
  2. 하나의 이벤트 스트림을 통해 단발성 이벤트를 발행하는 방법

두 번째 방법에서는 Kotlin Coroutine의 Channel이나 SharedFlow를 사용하기도 하고, 기존 View 기반 프로젝트라면 SingleLiveEvent가 남아 있는 경우도 많습니다. 저 역시 Compose로 화면을 옮기면서 기존 SingleLiveEvent를 그대로 사용했는데요.

처음에는 큰 문제가 없어 보였습니다. 그런데 API 응답 시점에 따라 스크롤이 되기도 하고 안 되기도 했고, 조건부로 화면을 노출하는 테스트에서는 observer가 계속 늘어나는 현상도 확인했습니다.

이 두 문제 모두 코드만 봤을 때는 꽤 자연스러워 보인다는 공통점이 있었습니다.

완료된 LaunchedEffect의 scope를 다시 참조하고 있었다

API 호출이 성공하면 특정 위치로 스크롤해야 하는 화면이 있었습니다. ViewModel은 SingleLiveEvent<Int>로 이동할 위치를 전달했고, Compose에서는 animateScrollToItem()을 호출해야 했습니다.

animateScrollToItem()은 suspend 함수이기 때문에 자연스럽게 LaunchedEffect 안에서 observer를 등록했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun ProductListScreen(
    viewModel: ProductListViewModel,
    listState: LazyListState,
) {
    val lifecycleOwner = LocalLifecycleOwner.current

    LaunchedEffect(Unit) {
        viewModel.scrollEvent.observe(lifecycleOwner) { position ->
            launch {
                listState.animateScrollToItem(position)
            }
        }
    }
}

코드만 보면 별문제가 없어 보입니다. LaunchedEffect 안이니 coroutine을 쓸 수 있고, observer에서 이벤트를 받으면 launch로 suspend 함수를 실행하고 있으니까요.

문제는 observe()가 observer를 등록한 뒤 바로 반환된다는 점입니다.

1
2
3
4
5
6
7
8
9
10
11
LaunchedEffect 시작
    ↓
observe()로 observer 등록
    ↓
LaunchedEffect 블록 종료
    ↓
LaunchedEffect의 Job 완료
    ↓
API 응답 도착
    ↓
observer 안에서 이미 완료된 scope로 launch 시도

LaunchedEffect의 블록은 observe()를 등록한 뒤 더 할 일이 없으니 바로 끝납니다. 등록 중 실행된 callback이 자식 coroutine을 시작하지 않았다면, LaunchedEffect가 제공한 CoroutineScope의 Job도 완료됩니다. 나중에 API 응답이 도착해 observer가 실행되더라도, 내부의 launch는 이미 완료된 Job을 부모로 삼게 됩니다. 새 coroutine은 시작하지 못하고 취소됩니다.

observer 콜백 자체가 suspend scope인 것은 아닙니다. 다만 LaunchedEffect의 블록이 suspend CoroutineScope.() -> Unit 형태라서, 그 안에 작성한 observer 람다에서도 바깥 receiver의 launch가 보입니다. 컴파일도 되니 놓치기 더 쉬웠습니다.

launch와 LaunchedEffect는 호출 가능한 위치도 다릅니다. 위 예제의 observer 안에서는 바깥 scope의 launch를 호출하고 있습니다. 여기에 LaunchedEffect를 넣으면 일반 callback에서 Composable을 호출하게 되어 컴파일되지 않습니다. 반면 @Composable 함수 본문에서 쓰는 if나 inline 함수인 let 안에서는 LaunchedEffect를 호출할 수 있습니다. Compose의 effect와 callback 설명

같은 코드가 어떤 때는 동작했던 이유

더 까다로웠던 점은 이 코드가 항상 실패하지 않았다는 것입니다.

LiveData에 값이 있고 LifecycleOwner가 STARTED 이상이면, observe()를 등록하는 도중 현재 값이 전달될 수 있습니다. Google 샘플의 SingleLiveEvent는 여기에 아직 소비되지 않은 값이라 pending flag가 true여야 한다는 조건이 추가됩니다. 이미 소비한 값이 저장되어 있다는 이유만으로 callback이 다시 실행되지는 않습니다.

등록 중 callback이 실행되면 LaunchedEffect 블록이 아직 끝나지 않았으므로 내부의 launch도 실행할 수 있습니다. 부모 Job은 이 자식 coroutine이 끝날 때까지 완료되지 않습니다. 따라서 “등록 이후에 도착한 응답은 모두 실패한다”는 설명도 정확하지 않습니다. 실패를 가르는 기준은 callback이 호출된 시점에 부모 Job이 이미 완료되었는가입니다. Kotlin Job의 완료와 자식 coroutine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
LaunchedEffect(Unit) {
    val effectJob = coroutineContext[Job]

    viewModel.scrollEvent.observe(lifecycleOwner) { position ->
        Log.d(
            "ScrollEvent",
            "active=${effectJob?.isActive}, completed=${effectJob?.isCompleted}",
        )

        launch {
            listState.animateScrollToItem(position)
        }
    }
}

이런 코드는 등록 중 전달된 미소비 값으로는 정상처럼 보이다가, Job이 완료된 뒤 도착한 값에서는 실패할 수 있습니다. QA에서는 잘 됐는데 운영 환경에서 간헐적으로 동작하지 않는 버그로 이어지기 딱 좋은 조건이죠.

공식 Compose 문서에서도 LaunchedEffect는 composition에 진입할 때 coroutine을 실행하고, 전달한 블록의 생명주기 안에서 suspend 작업을 수행하는 API라고 설명합니다. observer처럼 나중에 호출되는 callback을 등록만 해두는 용도와는 수명이 다릅니다. Compose side-effects 문서

LaunchedEffect(Unit)도 조건문 안에서는 다시 실행된다

두 번째 문제는 observer의 중복 등록이었습니다.

LaunchedEffect(Unit)이라고 쓰면 해당 화면에서 딱 한 번만 실행될 것처럼 느껴집니다. 정확히는 현재 composition에 들어와 있는 동안 한 번입니다. Composable이 composition에서 빠졌다가 다시 들어오면 새로운 LaunchedEffect(Unit)이 만들어집니다.

1
2
3
4
5
6
7
if (visible) {
    LaunchedEffect(Unit) {
        event.observe(lifecycleOwner) {
            Log.d("SingleEvent", "observer called")
        }
    }
}

이 코드는 다음 순서로 움직입니다.

  1. visible == true가 되면서 observer가 등록됩니다.
  2. visible == false가 되면 Composable은 composition에서 빠집니다.
  3. 하지만 LiveData의 observer는 같은 LifecycleOwner가 DESTROYED 되기 전까지 남아 있습니다.
  4. 다시 visible == true가 되면 새로운 observer가 하나 더 등록됩니다.

Compose 입장에서는 LaunchedEffect를 정상적으로 정리했습니다. 그러나 LiveData.observe()로 등록한 observer까지 대신 제거해주지는 않습니다. Compose의 생명주기와 LifecycleOwner의 생명주기를 같은 것으로 생각했던 게 문제였습니다.

실제로 observer 등록 지점에 로그를 찍고 Toggle 버튼을 열세 번 눌러봤을 때 로그는 여덟 번 찍혔습니다. 처음에는 Unit으로 묶었는데 왜 여러 번 호출되는지부터 이해가 되지 않았습니다.

클릭 횟수와 observer 등록 횟수는 같지 않습니다. 화면이 다시 보이는 시점에만 LaunchedEffect가 composition에 진입하기 때문입니다. 최초 상태와 분기 구조에 따라 숫자도 달라질 수 있습니다. 중요한 것은 열세 번이나 여덟 번이라는 숫자가 아니라, 같은 LifecycleOwner가 살아 있는 동안 composition에 재진입할 때마다 observer가 추가될 수 있다는 사실입니다.

SingleLiveEvent가 중복 observer를 가리고 있었다

그런데 실제 화면에서는 observer가 늘어났는데도 Action이 여러 번 실행되지는 않았습니다.

SingleLiveEvent는 AndroidX가 제공하는 표준 타입이 아니어서 구현은 프로젝트마다 다릅니다. 많이 사용된 구현은 AtomicBoolean 형태의 pending flag를 두고, 여러 observer 중 한 곳에서만 값을 소비하도록 만듭니다. 값을 전달받은 활성 observer 중 하나가 flag를 false로 바꾸면 나머지 observer의 callback은 통과하지 못합니다. 새로 등록한 화면이 이벤트를 받는다는 보장은 없습니다.

덕분에 화면 동작만 보면 정상처럼 보입니다. 하지만 동작이 한 번이라는 사실이 observer도 하나라는 뜻은 아닙니다. 이전 composition에서 등록한 observer가 여전히 LiveData 내부에 남아 있을 수 있습니다. 그 observer가 먼저 pending flag를 소비한 뒤 완료되거나 취소된 scope로 launch하면, 실제 UI 동작은 실행되지 않고 현재 화면의 observer도 값을 받지 못할 수 있습니다.

해제 누락을 설명하기 위한 다음 예제는 일반 MutableLiveData를 사용합니다. composeRule 설정과 import는 생략했습니다. 같은 owner가 활성 상태로 유지되고, effect에 진입할 때마다 별도 observer를 만들도록 합니다. 이 테스트는 SingleLiveEvent의 해제 동작까지 검증하지는 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Test
fun observer_is_registered_again_after_composition_reentry() {
    val event = MutableLiveData<String>()
    val received = AtomicInteger()

    composeRule.setContent {
        var visible by remember { mutableStateOf(true) }
        val lifecycleOwner = LocalLifecycleOwner.current

        Column {
            Button(onClick = { visible = !visible }) {
                Text("Toggle")
            }

            if (visible) {
                LaunchedEffect(Unit) {
                    val observer = object : Observer<String> {
                        override fun onChanged(value: String) {
                            received.incrementAndGet()
                        }
                    }
                    event.observe(lifecycleOwner, observer)
                }
            }
        }
    }

    // 화면을 composition에서 제거했다가 다시 진입시킵니다.
    composeRule.onNodeWithText("Toggle").performClick()
    composeRule.waitForIdle()
    composeRule.onNodeWithText("Toggle").performClick()
    composeRule.waitForIdle()

    composeRule.runOnIdle {
        event.value = "message"
    }

    composeRule.runOnIdle {
        assertThat(received.get()).isEqualTo(2)
    }
}

첫 진입에서 observer가 하나 등록되고, 화면 재진입에서 하나가 더 등록됩니다. 이후 값을 한 번 발행했는데 callback은 두 번 실행됩니다. SingleLiveEvent로 테스트하면 pending flag 때문에 callback이 한 번만 통과할 수 있어 이 문제가 가려집니다.

Google SingleLiveEvent는 등록한 observer를 한 번 더 감싼다

기존 글에서는 DisposableEffect의 onDispose에서 removeObserver(observer)를 호출하면 해결된다고 설명했습니다. 그런데 Google 샘플의 SingleLiveEvent에는 그 코드를 그대로 적용할 수 없습니다.

Google 샘플 원본의 등록 부분을 Kotlin 형태로 줄이면 다음과 같습니다. 실행용 클래스가 아니라 wrapper 동작만 나타낸 축약 코드입니다.

1
2
3
4
5
6
7
// observe(owner, originalObserver) 내부
val pendingObserver = Observer<T> { value ->
    if (pending.compareAndSet(true, false)) {
        originalObserver.onChanged(value)
    }
}
super.observe(owner, pendingObserver)

호출자가 넘긴 originalObserver와 super.observe()에 전달하는 pendingObserver는 서로 다른 객체입니다. Google 샘플에는 둘을 연결해 제거하는 removeObserver() 재정의가 없습니다.

LiveData도 자체적으로 lifecycle을 관리하는 wrapper를 만들지만, observer 저장소의 key에는 LiveData.observe()에 전달받은 객체를 사용합니다. 일반 LiveData에서는 그것이 원래 observer이고, 위 SingleLiveEvent에서는 pendingObserver입니다. removeObserver()는 인자로 받은 객체를 key로 찾아 제거하므로 removeObserver(originalObserver)로는 해당 등록을 찾지 못합니다. AndroidX LiveData 구현

1
2
3
4
일반 LiveData:       originalObserver → lifecycle wrapper
SingleLiveEvent:    pendingObserver  → lifecycle wrapper
                         ↓
                   originalObserver 호출

변수 타입을 LiveData<Int>로 선언해도 실제 객체가 SingleLiveEvent라면 재정의한 observe()가 실행됩니다. 타입 표기만 바꿔서는 해결되지 않습니다.

일반 LiveData에서는 등록과 해제를 묶는다

다음 코드는 observer를 별도로 감싸지 않는 일반 LiveData용입니다. 일반 LiveData는 재등록 시 최신 값을 다시 전달할 수 있으므로, 이 코드 자체가 이벤트의 한 번 처리를 보장하지는 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Composable
fun ObserveScrollPosition(
    scrollPosition: LiveData<Int>,
    listState: LazyListState,
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    val scope = rememberCoroutineScope()

    DisposableEffect(scrollPosition, lifecycleOwner, listState) {
        val observer = Observer<Int> { position ->
            scope.launch {
                listState.animateScrollToItem(position)
            }
        }

        scrollPosition.observe(lifecycleOwner, observer)
        onDispose {
            scrollPosition.removeObserver(observer)
        }
    }
}

rememberCoroutineScope()는 composition에서 빠질 때 취소됩니다. DisposableEffect는 위 key가 바뀌거나 composition에서 빠질 때 등록한 observer를 제거합니다. listState도 key에 넣어 observer가 교체 전의 스크롤 상태를 계속 참조하지 않도록 했습니다.

이 scope는 owner가 STARTED 아래로 내려갔다는 이유만으로 취소되지는 않습니다. LiveData의 새 값 전달은 멈추지만 이미 시작한 스크롤 작업까지 같은 시점에 취소하려면 lifecycle에 맞춘 별도 처리가 필요합니다.

기존 SingleLiveEvent에서는 실제 wrapper를 제거해야 한다

Google 샘플처럼 wrapper를 만드는 구현이라면 선택지는 다음과 같습니다.

  • 구현을 수정할 수 있다면 원래 observer와 등록한 wrapper의 대응을 보관하고, 해제 시 super.removeObserver(wrapper)를 호출하도록 만듭니다. owner 파괴에 따른 자동 해제에서도 대응 정보를 정리하고, 중복 등록과 observeForever()의 동작까지 함께 정해야 합니다.
  • 구현을 유지한다면 removeObservers(lifecycleOwner)는 owner에 속한 등록을 찾아 제거할 수 있습니다. 다만 해당 SingleLiveEvent와 owner 조합의 모든 observer가 제거됩니다. 같은 owner를 공유하는 다른 Composable의 구독도 사라질 수 있어 공용 해제 코드로 넣으면 안 됩니다. 이 조합의 구독을 한 곳에서 독점 관리할 때만 사용할 수 있습니다.
  • UI 상태나 Flow로 옮길 수 있다면 composition과 lifecycle에 맞게 작업을 실행하고 취소하는 방식으로 바꿉니다.

owner가 DESTROYED가 될 때의 자동 제거는 Google 샘플에서도 동작합니다. 문제는 owner가 살아 있는 동안 Composable만 빠졌을 때입니다. 아래 비교처럼 원래 observer를 제거한 뒤에도 등록이 남는지 확인해야 합니다. 두 경우 모두 다른 구독이 없고 owner가 파괴되지 않은 상태를 전제로 합니다.

확인 순서일반 MutableLiveDataGoogle 샘플 SingleLiveEvent
observe(owner, observer) 후 hasObservers()truetrue
removeObserver(observer) 후 hasObservers()falsetrue
removeObservers(owner) 후 hasObservers()falsefalse

이 표는 소스에서 도출한 예상 결과입니다. 앞의 MutableLiveData 테스트가 통과해도 이 동작을 확인한 것은 아닙니다. 프로젝트에서 사용하는 SingleLiveEvent 구현으로 해제 후 등록 상태와 화면 재진입 후 수신 주체를 따로 검증해야 합니다.

Compose에서 Single Event 처리 방법

이번 문제를 겪고 나서는 Snackbar, 스크롤, Navigation처럼 UI에서 처리해야 하는 Action을 우선 2가지 방법으로 해결하고 있는데요.

State로 Single Event 처리하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
data class ProductListUiState(
    val scrollTarget: Int? = null,
)

@Composable
fun ProductListScreen(
    uiState: ProductListUiState,
    onScrollCompleted: () -> Unit,
    listState: LazyListState,
) {
    val currentOnScrollCompleted by rememberUpdatedState(onScrollCompleted)

    uiState.scrollTarget?.let { position ->
        LaunchedEffect(position, listState) {
            listState.animateScrollToItem(position)
            currentOnScrollCompleted()
        }
    }
}

위 코드는 대기 중인 스크롤 요청 하나만 표현하는 축약 예제입니다. Action을 처리한 뒤 scrollTarget을 null로 비웁니다. 같은 위치로 연속 요청하면 값이 같아 새 요청을 구분하지 못하고, 처리 중 다른 요청이 오면 이전 완료 callback이 새 요청을 지울 수도 있습니다. 그런 요구가 있다면 요청 ID를 두고 완료된 ID와 현재 요청이 일치할 때만 비우거나, 대기열로 표현해야 합니다. 상태에 담는 것만으로 정확히 한 번 실행이 보장되지는 않습니다. suspend 함수는 원래 실행되어야 할 LaunchedEffect 블록 안에 있고, observer를 등록하거나 제거할 필요도 없습니다.

현재 Android UI events 가이드도 ViewModel에서 시작된 UI Action을 UI state 변화로 표현하고, UI가 처리한 뒤 다시 상태를 갱신하는 방향을 권장합니다. Channel 같은 스트림이 모든 상황에서 잘못됐다는 뜻은 아니지만, ViewModel이 UI보다 오래 살아 있는 경우에는 전달과 처리 보장을 별도로 고민해야 합니다. Android UI events 가이드

Kotlin Coroutines의 Channel 활용하기

여러 이벤트를 순서대로 처리해야 하거나, state에 값을 넣었다가 다시 비우는 코드가 오히려 어색하다면 Channel을 사용할 수도 있습니다.

ViewModel에서는 외부에 Channel 자체를 공개하지 않고 receiveAsFlow()로 변환한 Flow만 노출합니다. 그래야 UI는 이벤트를 받기만 하고, 발행은 ViewModel 안에서만 일어나게 만들 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
sealed interface ProductListEffect {
    data class ScrollTo(val position: Int) : ProductListEffect
    data class ShowMessage(val message: String) : ProductListEffect
}

// ProductRepository는 앱에서 정의한 상품 조회 의존성입니다.
class ProductListViewModel(
    private val productRepository: ProductRepository,
) : ViewModel() {

    private val _effects = Channel<ProductListEffect>(
        capacity = Channel.BUFFERED,
    )
    val effects: Flow<ProductListEffect> = _effects.receiveAsFlow()

    fun loadProducts() {
        viewModelScope.launch {
            val products = productRepository.getProducts()

            if (products.isEmpty()) {
                _effects.send(
                    ProductListEffect.ShowMessage("상품이 없습니다."),
                )
                return@launch
            }

            _effects.send(ProductListEffect.ScrollTo(position = 0))
        }
    }
}

Compose에서는 LaunchedEffect 안에서 flowWithLifecycle로 lifecycle을 연결한 뒤 Flow를 collect합니다. flowWithLifecycle은 androidx.lifecycle.flowWithLifecycle을 import합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Composable
fun ProductListScreen(
    viewModel: ProductListViewModel,
    listState: LazyListState,
    snackbarHostState: SnackbarHostState,
) {
    val lifecycleOwner = LocalLifecycleOwner.current

    LaunchedEffect(
        viewModel,
        lifecycleOwner,
        listState,
        snackbarHostState,
    ) {
        viewModel.effects
            .flowWithLifecycle(
                lifecycleOwner.lifecycle,
                Lifecycle.State.STARTED,
            )
            .collect { effect ->
                when (effect) {
                    is ProductListEffect.ScrollTo -> {
                        listState.animateScrollToItem(effect.position)
                    }

                    is ProductListEffect.ShowMessage -> {
                        snackbarHostState.showSnackbar(effect.message)
                    }
                }
            }
    }
}

앞에서 문제가 됐던 LiveData.observe()와 차이가 보입니다. observe()는 observer를 등록한 뒤 바로 반환되지만, collect는 Flow 수집이 끝날 때까지 suspend 상태로 남습니다. 따라서 이벤트가 나중에 도착해도 LaunchedEffect의 Job은 아직 살아 있고, animateScrollToItem()이나 showSnackbar() 같은 suspend 함수를 같은 블록에서 바로 호출할 수 있습니다.

flowWithLifecycle은 owner가 STARTED 이상일 때 원본 Flow 수집을 시작하고, 그 아래로 내려가면 원본 수집을 취소합니다. 다시 STARTED가 되면 수집을 재개합니다. 화면이 composition에서 빠지거나 effect의 key가 바뀌면 LaunchedEffect가 취소되어 전체 수집과 처리 중인 작업도 취소됩니다.

여기서 원본 Flow의 수집 중단과 이미 전달된 이벤트의 처리 취소는 구분해야 합니다. flowWithLifecycle 뒤의 collect는 바깥 scope가 살아 있는 동안 유지되므로, owner가 비활성화되어도 이미 시작한 showSnackbar()나 스크롤 작업까지 자동으로 취소되지는 않습니다. 내부 버퍼로 이미 전달된 값도 남아 있을 수 있습니다. 비활성화 시 처리 중인 작업까지 취소해야 한다면 repeatOnLifecycle 블록 안에서 처리하는 방식이 더 맞습니다. AndroidX flowWithLifecycle 구현과 취소 범위

다만 Channel이라고 해서 전달 문제가 자동으로 해결되는 것은 아닙니다. 위 예제에서 Channel.BUFFERED를 선택한 것도 하나의 정책입니다.

  • 기본값인 Channel.RENDEZVOUS는 buffer가 없어서 receiver가 없으면 send()가 suspend됩니다.
  • Channel.BUFFERED는 UI가 잠시 수집하지 않아도 event를 buffer에 남길 수 있지만, 화면으로 돌아왔을 때 이미 의미가 없어진 스크롤이나 Navigation이 실행될 수도 있습니다.
  • trySend()는 receiver나 buffer 공간이 없을 때 실패할 수 있으므로 반환된 ChannelResult를 확인해야 합니다.
  • receiveAsFlow()를 여러 곳에서 collect하면 broadcast가 아니라 fan-out으로 동작합니다. 하나의 event는 collector 한 곳에만 전달됩니다.

또 collector가 event를 꺼낸 직후 취소되면 실제 UI Action을 수행하기 전에 값이 사라질 수도 있습니다. Kotlin 공식 문서도 Channel의 capacity에 따라 sender의 suspend와 buffer 동작이 달라지고, receiveAsFlow()는 취소 시점에 따라 전달받은 element가 유실될 수 있다고 설명합니다. Channel API, receiveAsFlow() API

그래서 Channel을 사용할 때는 “단발 이벤트니까 Channel”에서 끝내지 않고 아래 조건을 먼저 정해야 합니다.

  1. 화면이 없을 때 event를 버릴지, 기다릴지, buffer에 보관할지
  2. 여러 화면이 collect할 때 한 곳만 받을지, 모두 받아야 할지
  3. event를 받은 뒤 UI Action이 완료되지 못했을 때 다시 처리해야 하는지

사용자에게 반드시 보여야 하는 결과거나 화면 복원 후에도 의미가 남는 정보라면 state가 더 잘 맞습니다. 반대로 현재 화면이 살아 있는 동안 발생한 UI 효과를 한 collector가 순서대로 처리하고, 취소 시 유실되어도 괜찮다는 조건이 분명하다면 Channel도 선택할 수 있습니다.

기존 코드에서 LaunchedEffect(Unit) { liveData.observe(...) }를 발견했다면 두 가지를 먼저 확인해보면 좋겠습니다.

  • observer callback에서 바깥 LaunchedEffect의 launch를 다시 사용하고 있지 않은가
  • 조건부 composition에 재진입했을 때 이전 observer가 실제로 제거되는가. SingleLiveEvent 내부 wrapper까지 확인했는가

긴 글 읽어주셔서 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.