Flows Know Nothing About the Lifecycle: How repeatOnLifecycle and LifecycleRegistry Make Collection Safe
Flows Know Nothing About the Lifecycle: How repeatOnLifecycle and LifecycleRegistry Make Collection Safe
Modern Android UI code collects flows. You wrap a collect in repeatOnLifecycle, or you call collectAsStateWithLifecycle in a composable, and the data stops flowing when the screen goes to the background and starts again when it returns. Most developers know this is the recommended pattern, but the deeper question is why it is needed at all. A Flow is a Kotlin coroutines type that has no connection to Android. It does not know that a screen exists, let alone that one moved to the background. Understanding how that gap is closed, and why the older LiveData never had it, explains a large part of how the modern UI layer fits together.
In this article, you'll dive deep into how lifecycle aware flow collection works, exploring why a Flow is blind to the lifecycle, how the LifecycleRegistry drives state changes to observers as ordered events, how repeatOnLifecycle rides that machine to launch and cancel collection, what its restart semantics mean for your code, why launchWhenStarted was deprecated, how collectAsStateWithLifecycle builds on the same foundation, and why LiveData got all of this for free.
The fundamental problem: a Flow does not know your screen went away
A Flow is defined entirely in kotlinx.coroutines. Collecting one suspends the calling coroutine and runs until that coroutine is cancelled, and nothing else. There is no callback for "the user pressed home," because a flow has no concept of a user, a screen, or an Android lifecycle. The only thing that can stop a collect is cancellation of the coroutine it runs in.
That is fine until you collect in a scope that outlives the visible screen. Consider the most natural code:
lifecycleScope.launch {
locationFlow.collect { updateMap(it) }
}
lifecycleScope is cancelled only when the lifecycle reaches DESTROYED. When the user switches to another app, the Activity stops but is not destroyed, so this coroutine keeps running and locationFlow keeps emitting. If locationFlow is a callbackFlow wrapping the GPS, the sensor stays on in the background and drains the battery. If it is a polling flow, it keeps hitting the server. If it is a StateFlow feeding a view, every emission updates a screen the user cannot see, which is wasted work and, for the old View system, a possible crash when you touch a stopped view.
What you actually want is narrow: collect only while the screen is at least STARTED, cancel collection when it drops below that, and start again when it comes back. A flow cannot express any of this on its own, because it has no idea what STARTED means. Something has to translate lifecycle changes into start and stop signals, and that something is the Lifecycle library.
The lifecycle as a state machine: states and one step at a time
Before anything can react to the lifecycle, the lifecycle has to be modeled. Lifecycle.State is an enum, and the declaration order is the whole point:
public enum class State {
DESTROYED,
INITIALIZED,
CREATED,
STARTED,
RESUMED;
public fun isAtLeast(state: State): Boolean {
return compareTo(state) >= 0
}
}
Because the states are ordered, "at least STARTED" is a simple comparison, and DESTROYED is deliberately the lowest value. You can picture the states as nodes and the Lifecycle.Event values as edges between adjacent ones. The helpers that connect them only ever describe one step:
public fun upFrom(state: State): Event? = when (state) {
State.INITIALIZED -> ON_CREATE
State.CREATED -> ON_START
State.STARTED -> ON_RESUME
else -> null
}
public fun downFrom(state: State): Event? = when (state) {
State.CREATED -> ON_DESTROY
State.STARTED -> ON_STOP
State.RESUMED -> ON_PAUSE
else -> null
}
upFrom answers "which event leaves this state going up one step," and downFrom answers the same going down. There is no edge that jumps two nodes, which is what later guarantees an observer moving from INITIALIZED to RESUMED sees ON_CREATE, ON_START, and ON_RESUME in that exact order, with none skipped. A related pair, upTo(state) and downFrom(state), names the event that arrives at a state on the way up and the event that leaves it on the way down. That pair is what repeatOnLifecycle later uses to pick the event that starts work and the event that cancels it.
LifecycleRegistry: dispatching state changes in order
LifecycleRegistry is the concrete Lifecycle that an Activity, Fragment, or NavBackStackEntry owns. It holds the current state and a set of observers, and its job is to walk every observer to the current state through those one step events. The library was rewritten in Kotlin, so the implementation is now plain Kotlin with no m prefixed fields.
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor