Lifecycle, LiveData & repeatOnLifecycle Explained
ARCHITECTURE › Components
LiveData is an observable data holder: you put a value in with setValue/postValue, and any registered observer gets an onChanged callback when it changes, a bit like a one-value stream you can also read synchronously. What makes it special is that it's **lifecycle-aware**: it only delivers a value to an observer whose LifecycleOwner is **active**, meaning STARTED or RESUMED. An observer registered while the owner is CREATED, say the Activity is stopped but not yet destroyed, is registered but *inactive*, it won't receive onChanged calls until the owner starts again, at which point it gets the most recently set value.
This is what makes LiveData safe by default: no onChanged calls fire on a stopped screen, so you never update a hidden or destroyed View. When the owner reaches DESTROYED, LiveData removes the observer entirely, no manual removeObserver needed for the common case of observe(owner, ...).
MutableLiveData has two setters and they are not interchangeable. setValue() (or liveData.value = x) must run on the **main thread** and updates synchronously; call it off the main thread and it throws. postValue() can be called from **any thread**, it schedules the update to run on the main thread shortly after.
The subtlety: if you call postValue() several times before the main thread gets a chance to process the first one, the updates **coalesce**, only the most recent value is guaranteed to be dispatched, intermediate ones can be silently dropped. That's fine for "latest state wins" cases, but it's a real gotcha if you expected every value to arrive.
Three transform tools sit on top of LiveData, and interviewers like to check you know which fits which shape:
- **map**: a synchronous transform of each value, name.map { it.uppercase() }. - **switchMap**: your block returns a *new* LiveData for each input value, and the result swaps to follow it, automatically dropping the old source. Classic use: userId.switchMap { id -> repository.getUser(id) }, an id-driven async lookup. - **MediatorLiveData**: merges *multiple independent* LiveData sources into one, via addSource() per input, re-emitting whenever any of them fires.
map or switchMap each transform a single upstream; reach for MediatorLiveData the moment you need to react to more than one source.
For collecting a Flow safely in the View system, the answer is Lifecycle.repeatOnLifecycle(state). It's a suspend function: every time the lifecycle reaches the target state (usually STARTED), it launches its block in a **fresh coroutine**, and the moment the lifecycle drops back below that state, it **cancels** that coroutine outright, not just pauses it. This repeats for as long as the Lifecycle exists, up to DESTROYED.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
Because it actually cancels rather than pausing, the Flow being collected, and anything upstream producing values for it, genuinely stops doing work while the screen is backgrounded.
Before repeatOnLifecycle existed, people reached for lifecycleScope.launchWhenStarted { ... }. It's now deprecated, and the reason is exactly the cancel-versus-pause distinction from the last chunk: launchWhenStarted only **suspends** the coroutine body when the lifecycle drops below STARTED, it never cancels it.
That means a Flow.collect inside it doesn't actually stop, the collection (and its upstream producer, a network socket, a database query, a sensor listener) keeps running in the background even while the screen is fully hidden, quietly burning battery and resources. repeatOnLifecycle fixes this by cancelling the coroutine outright, so upstream work genuinely stops.
One gotcha worth internalizing: repeatOnLifecycle is a **suspend function that only returns once the Lifecycle reaches DESTROYED**. So any code written *after* it, in the same coroutine, is effectively unreachable for the entire time the screen is alive.
If you need multiple independent collectors, each one needs its own launch *inside* the repeatOnLifecycle block, not sequenced after it. And you only need to write this once, in onCreate(), since repeatOnLifecycle internally handles the start or stop cancel-and-relaunch cycle for you, no separate onStart or onStop wiring required.
Lifecycle.State (INITIALIZED, CREATED, STARTED, RESUMED, DESTROYED) is the *current condition* of an owner; Lifecycle.Event (ON_CREATE, ON_START, ...) is the *transition* that moved it there. To react to those transitions today, implement **DefaultLifecycleObserver**, plain overridable methods like onStart(owner) and onStop(owner), rather than the older, now-deprecated @OnLifecycleEvent-annotated LifecycleObserver, which relied on reflection and annotation processing to dispatch calls at runtime.
class MyObserver : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) { startWork() }
override fun onStop(owner: LifecycleOwner) { stopWork() }
}
lifecycle.addObserver(MyObserver())
Zooming out: Flow has no Android dependency and composes cleanly with operators, so it belongs in the **data layer** (repositories), while LiveData's transforms (map, switchMap) always run on the main thread, a poor fit for that layer. The ViewModel bridges the two, exposing the repository's Flow as a StateFlow (stateIn) for Compose, or via asLiveData() for View-system code that still expects LiveData.
On the UI side, Compose's collectAsStateWithLifecycle() is the StateFlow equivalent of repeatOnLifecycle, it stops collecting when the composable's lifecycle drops below STARTED and resumes automatically, so you get the same safety without writing the boilerplate yourself.