It is the most persistent headache in modern Android development: handling "fire-and-forget" events. You trigger a navigation action, show a Toast, or display a Snackbar. It works perfectly until the user rotates their device or switches to Dark Mode.
Suddenly, that "Payment Successful" Snackbar pops up a second time (the "Zombie Event"). Or worse, the navigation event triggers while the app is in the background, effectively vanishing into the void, leaving the user stuck on a loading screen.
Using LiveData for events was deprecated years ago. StateFlow is for state, not events. The battleground for one-off events is now between SharedFlow and Channel. If you don’t understand the underlying mechanics of how these primitives buffer and replay data, your app likely contains race conditions waiting to happen.
Here is the architectural pattern for handling single-shot UI events that ensures they are delivered exactly once, even across configuration changes.
The Root Cause: Hot Streams vs. State
To fix the bug, we must understand the architecture gap. The fundamental issue stems from conflating UI State with UI Events.
UI State (handled by StateFlow) represents what the screen is (e.g., isLoading, listOfItems). It is sticky. If you rotate the phone, the new Activity subscribes to the StateFlow, gets the latest cached value, and renders the screen. This is desirable behavior.
UI Events (Navigation, Snackbars) are transient. They happen once. If you treat an event like state, it becomes sticky. The user rotates the phone, the view re-subscribes, and the "Show Error" event is re-emitted.
Why SharedFlow Often Fails
Many developers migrated to SharedFlow to solve the "sticky" problem. They configure it with replay = 0.
// The common mistake
val _events = MutableSharedFlow<Event>(replay = 0)
This prevents the zombie event on rotation. However, SharedFlow is a "hot" stream. If an event is emitted while the UI is not collecting (e.g., the app is backgrounded or the View is being recreated), the event is dropped. It is lost forever.
To fix the drop, developers add a buffer or replay = 1, but this re-introduces the zombie event problem. It is a vicious cycle.
The Solution: Channels and receiveAsFlow
The cleanest solution for guaranteed, exactly-once delivery is using a Kotlin Channel exposed as a Flow.
A Channel implements a queue. It buffers events when there are no subscribers. Unlike SharedFlow, which broadcasts to all active subscribers or drops data, a Channel holds the item until a consumer claims it. Once consumed, the item is removed from the buffer.
This aligns perfectly with Android's lifecycle:
- Buffer the event while the Activity is stopped (or rotating).
- Deliver the event when the Activity subscribes.
- Remove the event so it doesn't trigger again on the next rotation.
Step 1: Define Your Events
Use a sealed interface to strictly type your UI events. This improves maintainability and makes your when statements exhaustive.
sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent
data class NavigateToDetails(val id: String) : UiEvent
data object ShowLoading : UiEvent
}
Step 2: The ViewModel Implementation
In your ViewModel, use a private Channel and expose it as a Flow using receiveAsFlow().
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
class MyViewModel : ViewModel() {
// Capacity = Channel.BUFFERED ensures we don't suspend
// the sender if the UI isn't ready.
private val _uiEvents = Channel<UiEvent>(Channel.BUFFERED)
// Expose as a cold Flow
val uiEvents = _uiEvents.receiveAsFlow()
fun submitOrder() {
viewModelScope.launch {
try {
// Simulate network work
_uiEvents.send(UiEvent.ShowLoading)
// ... perform business logic ...
// Success event
_uiEvents.send(UiEvent.ShowSnackbar("Order Placed!"))
_uiEvents.send(UiEvent.NavigateToDetails("12345"))
} catch (e: Exception) {
_uiEvents.send(UiEvent.ShowSnackbar("Error: ${e.message}"))
}
}
}
}
Step 3: The Activity/Fragment Consumption
The collection side is critical. You must use the repeatOnLifecycle API. This API creates a coroutine that executes a block when the lifecycle is at least in a certain state (usually STARTED) and cancels it when the lifecycle falls below that state.
This ensures you don't process navigation events when the app is in the background (which causes crashes).
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
class OrderActivity : AppCompatActivity() {
private val viewModel: MyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_order)
observeEvents()
}
private fun observeEvents() {
// Start a coroutine in the lifecycle scope
lifecycleScope.launch {
// suspend until lifecycle is STARTED
repeatOnLifecycle(Lifecycle.State.STARTED) {
// Collect safely
viewModel.uiEvents.collect { event ->
handleEvent(event)
}
}
}
}
private fun handleEvent(event: UiEvent) {
when (event) {
is UiEvent.ShowSnackbar -> {
// Show actual snackbar
println("Showing Snackbar: ${event.message}")
}
is UiEvent.NavigateToDetails -> {
// Perform navigation
println("Navigating to ID: ${event.id}")
}
UiEvent.ShowLoading -> {
// Update loading UI
}
}
}
}
Deep Dive: Why This Works
The magic lies in the interaction between receiveAsFlow and repeatOnLifecycle.
When receiveAsFlow is collected, it creates a cold flow. However, it pulls data from the underlying Channel buffer. Because channels are unicast (mostly), collecting from them consumes the element.
Here is the scenario breakdown:
- Event Fired in Background: The user clicks "Buy", then immediately minimizes the app. The ViewModel sends
ShowSnackbar. - Buffering: The Activity is
STOPPED.repeatOnLifecyclecancels the collector. The event sits in theChannelbuffer. It is NOT dropped. - Resuming: The user re-opens the app. Activity moves to
STARTED.repeatOnLifecyclere-launches the collection block. - Delivery: The collector connects to the flow, sees the buffered item, and delivers it. The Snackbar shows.
- Rotation: The user rotates the phone. The Activity is destroyed and recreated.
repeatOnLifecyclestarts a new collection. - No Zombie: Because the event was consumed in step 4, the Channel is now empty. The new Activity waits for new events. No double Snackbar.
Common Pitfalls and Edge Cases
1. The Multiple Collector Problem
Channels are generally unicast. If you have multiple collectors (e.g., two fragments listening to the same ViewModel event channel), one will get the event, and the other will not.
Constraint: Ensure your events are intended for a single consumer (the current View). If you need broadcast events (e.g., logging out triggers UI updates in 5 different fragments), SharedFlow is actually better, but it requires careful synchronization. For 99% of specific screen interactions, Channel is the correct choice.
2. Immediate Execution
If you use lifecycleScope.launchWhenStarted (which is deprecated), the coroutine pauses but does not cancel. This keeps the subscription alive but paused. This can fill up buffers if you aren't careful. Stick to repeatOnLifecycle.
3. Process Death
Note that Channel and SharedFlow live in memory. If Android kills your app process to save memory (System-initiated process death), and the user returns, the ViewModel is recreated.
Any events in the Channel buffer that were not yet consumed are lost.
If an event is critical (like a completed financial transaction ID that must be shown), it should be persisted in SavedStateHandle or a database, converting it into State rather than a transient Event.
Conclusion
The distinction between State and Events is the most crucial concept in Android UI architecture.
Use StateFlow to tell the UI what to display. Use Channel (via receiveAsFlow) to tell the UI what to do.
By combining Channel buffering with repeatOnLifecycle, you eliminate both the lost-event bug and the zombie-event bug, resulting in a robust, crash-free user experience.