A web page lives until its tab is closed. An Android app does not get that courtesy. It is started, stopped, put in the background, and — when the phone needs memory — killed without a word, then recreated later as though nothing happened. Two moments in that cycle cause most of the bugs: the first few hundred milliseconds after launch, and the moment the app leaves the screen.
This lesson is both, and what this app does at each.
Two lifecycles
An Activity moves through created, started (visible) and resumed (in front, taking input), and back down through paused, stopped and destroyed. Rotation destroys it and creates a new one; pressing Home stops it; pressing Back from the root finishes and destroys it.
The process has a lifecycle too, and it is the one that matters for saving work. Android ranks processes: the foreground app, then visible ones, then those running a service, then cached ones — apps in the background, kept in memory only in case the user comes back. When memory runs short, cached processes are killed first, oldest first. Your app spends most of its life as a cached process that might be killed at any moment.
Compose ties into the Activity's lifecycle quietly. collectAsStateWithLifecycle stops
collecting when the Activity is stopped and resumes when it starts; a LaunchedEffect is
cancelled when its composable leaves the composition. Most code never has to look at the lifecycle
directly. Two pieces of this app do.
Leaving the screen: the mobile-only problem
The cart is saved to the server, debounced by 300 milliseconds so that three taps on "+" become one write. In a browser that is safe: the debounce always gets to fire. On a phone, a customer can add a pizza and switch to their messages within those 300 milliseconds — and the process may then be killed before the write ever happens. The pizza is gone the next time they open the app.
The fix is to write immediately, skipping the debounce, the moment the app leaves the screen:
fun flushPendingWrites() {
if (!_isHydrated.value) return
persistJob?.cancel()
persistJob = scope.launch { persist() }
}
The question is what "leaves the screen" means, and the obvious answer is wrong.
Why not Activity.onStop
An Activity's onStop fires when the app goes to the background — and also on every
rotation, because rotation stops and destroys the Activity before creating the new one. Hooking the
flush there would write the cart on every turn of the phone. What the app wants is the lifecycle of
the whole application:
ProcessLifecycleOwner.get().lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
cartStore.flushPendingWrites()
}
},
)
ProcessLifecycleOwner (from lifecycle-process) reports the app as a
whole: ON_START when the first Activity becomes visible, ON_STOP when the
last one leaves the screen — with a short delay that rides out a rotation, so turning the phone does
not count. It is registered once, in Application.onCreate, and lives as long as the
process.
The flush is launched, not awaited. onStop cannot block, and the system gives a short,
unguaranteed window after it before the process may be frozen or killed. The write races to use that
window, in the application scope, so it is not cancelled when any screen goes away.
Best effort, and what guaranteed looks like
Honesty about that window: a flush in onStop is best effort. In practice it
completes nearly always — it is one small request — but nothing promises it will. Work that
must eventually happen, even if the app is killed or the phone restarts, belongs in
WorkManager, which persists the task and runs it when constraints (like a network
connection) are met. Uploading a photo the customer took is WorkManager work. Saving a cart that is
also being saved on every change, and that the customer will see on screen if it failed, is not worth
the machinery.
(The iOS app does the same thing with scenePhase, flushing on .inactive
as well as .background because it arrives first. The problem is identical; only the
callback's name differs.)
Lifecycle hooks for one screen
Sometimes a screen, not the app, cares about the lifecycle. The Orders tab is the example here, and it was found the honest way — by a bug. Its ViewModel survives a tab switch, because the tab's back-stack entry is saved rather than destroyed. So an order placed from the Menu tab did not appear in the history when the customer switched to Orders; the list was the one loaded before. Compose has an effect for exactly this:
LifecycleResumeEffect(Unit) {
viewModel.load(showsLoadingState = false)
onPauseOrDispose { }
}
LifecycleResumeEffect runs its block each time the screen's lifecycle reaches
resumed — when the tab is shown again, and when the customer returns from another app — and
onPauseOrDispose is where anything it started would be cleaned up. It is scoped to the
screen, so it stops when the customer navigates away; that is the difference from an app-wide observer
like the cart flush. The load is quiet (showsLoadingState = false), so returning to the tab
keeps showing the old list for the few hundred milliseconds until the new one arrives, instead of
flashing a spinner. LifecycleEventEffect(Lifecycle.Event.ON_STOP) { … } is the
single-event version.
Launch: the first few hundred milliseconds
When the app starts, it does not know who is signed in. The token is encrypted on disk, and reading it is asynchronous. Then the token must be checked against the server, because a token that exists may have expired. For that window — usually well under a second — the honest answer to "is anyone signed in?" is "not yet known".
Drawing the app during that window is how you get the classic launch flicker: the Profile tab shows "Sign in", then swaps to the customer's name; the Orders tab briefly shows its signed-out prompt to a signed-in customer. So the app does not draw until it knows.
The splash screen as the launch gate
Android 12 gave every app a system splash screen, drawn by the OS from the app's theme before any of
the app's code runs. The core-splashscreen library backports it to older versions and adds
the useful part: keeping it up until the app is ready. It starts in the theme:
<style name="Theme.Pizza.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/pizza_primary</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="postSplashScreenTheme">@style/Theme.Pizza</item>
</style>
The launch Activity uses that theme in the manifest; postSplashScreenTheme names the
theme to switch to once the splash is dismissed. Then, in the Activity:
installSplashScreen().setKeepOnScreenCondition { appViewModel.isLaunching.value }
setKeepOnScreenCondition is polled before each frame; while it returns true, the splash
stays. It reads the session state from the root ViewModel, so the splash stays up exactly until the
session is restored. And installSplashScreen() must run before
super.onCreate, or the theme switch happens too late and the splash flashes.
Using the system splash as the gate, rather than a spinner of your own, means the customer sees one continuous launch — the OS's splash, then the app — instead of a splash, then a loading screen, then the app.
Restoring the session
suspend fun restoreSession() {
try {
if (tokenStore.currentToken() == null) return
val user = repository.currentUser()
_state.update { it.copy(user = user) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.i(TAG, "Stored session could not be restored; clearing the token.")
tokenStore.clear()
_state.update { it.copy(user = null) }
} finally {
_state.update { it.copy(isRestoringSession = false) }
}
}
The finally is the most important line: whatever happens — no token, a valid one, an
expired one, no network — isRestoringSession becomes false, and the splash goes away. A
launch gate that can get stuck is worse than the flicker it prevents.
A launch sequence whose order is load-bearing
if (authStore.state.value.isRestoringSession) {
viewModelScope.launch {
authStore.restoreSession()
menuStore.reload()
cartStore.hydrate()
}
}
The order is not arbitrary. The session comes first, because an authenticated request needs the token. The menu comes next. The cart hydrates last, because a saved cart line holds only ids — its prices come from the menu, so hydrating first would produce a basket of zero-priced lines. Each step is a suspend function, so "in this order" is simply three lines in a row.
Never write before you have read
One more launch rule protects the cart. Until the saved cart has been loaded, any write would replace the server's copy with an empty one — the single worst bug the cart could have. So the store refuses to persist before hydration:
private fun schedulePersist() {
// Never write before hydrating: that would overwrite the saved cart with an empty one,
// which is the single worst bug this file can have.
if (!_isHydrated.value) return
// …
The flush has the same guard. A test pins it down: add an item before hydration, advance the clock a full second, assert that nothing was written.
Keeping launch fast
Everything in Application.onCreate runs before the first frame, on every cold start —
including when Android starts the process just to deliver a notification. This app's is short on
purpose: Hilt's container, Stripe's one-line configuration, the lifecycle observer. Nothing touches the
network or the disk there; the slow work happens in the launch sequence, behind the splash, on a
coroutine. If onCreate starts growing, App Startup and lazy initialisation are the tools,
and a baseline profile (the build lesson) speeds up the code that remains.
There are three kinds of start worth distinguishing when you measure: cold (no
process — everything above runs), warm (process alive, Activity recreated) and
hot (Activity merely brought to the front). Android Studio and
adb shell am start -W report the time for each; the cold start is the one to optimise.
Watching it happen
Lifecycle code is the hardest to test by using the app normally, because a developer's phone rarely kills anything. Make it happen deliberately. With the app in the background:
adb shell am kill com.lovemesomecoding.pizza.android.debug # kill as the OS would
adb shell am start -W -n com.lovemesomecoding.pizza.android.debug/com.lovemesomecoding.pizza.MainActivity
The second command relaunches and prints LaunchState: COLD and the time to first frame.
This app was checked exactly so: a drink added, the app sent home, the process killed. The cold start
took about one and a half seconds on the emulator; the splash stayed up while the session was restored;
the cart came back with the drink in it, fetched from the server by the id the flush had saved. Developer
options' "Background process limit" set to "No background processes" makes the same thing happen to
every app you leave, which is a brutal and useful week-long test.
Next
The launch sequence configured Stripe. The next lesson uses it: create the order, then pay, with Stripe's PaymentSheet — and why, on Android, the ViewModel is not allowed to hold the sheet.