RizTech Academy logo
RizTech Academy
Android Best PracticesLesson 3 of 540 min

Defensive Android: process death, the main thread and config changes

Defensive code in a plain Kotlin program means checking inputs at the boundary and failing loudly, which the Kotlin course's error-handling lesson covered. Android adds a second kind of input: the platform itself. It kills your process while the user is in the camera, rotates the screen in the middle of a download, keeps your code running when nobody is looking, gives your main thread 16 milliseconds a frame, and lets any app on the phone send your activity an intent. Earlier modules met each of these once. This lesson runs all five against Mandi Bhav in new forms, including one real bug the audit found and fixed, and ends with a checklist you can run on any screen in ten minutes.

Everything here was run on the course emulator, in scratch copies with their own application ids; the timings came from a release build.

1. Process death: the handoff through memory

Module 5 lost a search query to process death. The more dangerous version is a crash, and it comes from a shortcut that looks like tidy code: passing a selection to the next screen through an object instead of the navigation key. In a scratch copy:

/** Trap 1: the market the user tapped, handed to the next screen through memory. */
object SelectedMarket {
    var name: String? = null
}

with MarketsRoute(onMarketClick = { name -> SelectedMarket.name = name; navigation.navigate(Market(name)) }) and the market screen reading SelectedMarket.name!! instead of key.name.

Tap Markets, then Lasalgaon APMC: the screen opens. Rotate: the screen survives, same process (pid 12627 before and after), because the object is still in memory. Every test a developer runs by hand passes. Now press Home, run adb shell am kill, and come back:

E AndroidRuntime: FATAL EXCEPTION: main
E AndroidRuntime: Process: com.riztechacademy.mandibhav.m15t, PID: 12563
E AndroidRuntime: java.lang.NullPointerException
E AndroidRuntime: 	at com.riztechacademy.mandibhav.ui.MandiBhavAppKt.MandiBhavApp$lambda$3$4(MandiBhavApp.kt:97)

The back stack came back, because Navigation saved it; the Market key was on it, holding the name. The object did not, because it lived in the old process. The real app reads key.name, which is why module 6 put only ids and names in keys.

What not to do about it. The tempting fix for a NullPointerException is to stop it being null. The same scratch copy with SelectedMarket.name ?: "" instead of !!, killed the same way, did not crash. It opened a market screen with no name, saying:

My home market
No prices from this market today.

and a "My home market" button that would save an empty string as the farmer's home market. The crash told you the bug and where it was; the guard turned it into a plausible wrong screen and a corrupt preference. A guard that hides a bug is worse than no guard. The fix is to read the value from somewhere that survives: the key, a SavedStateHandle, or disk.

2. The main thread: viewModelScope is not a background thread

Module 9's table said it: viewModelScope runs on Dispatchers.Main.immediate. So does everything a ViewModel's combine does, including filterPrices, homeMarketFirst and commoditiesIn on every keystroke. A scratch release build timed the block that builds PricesUiState while "pune" was typed into the search, one letter at a time:

Prices saved Thread Time per keystroke
9 (today's real prices.json) main 47 to 195 microseconds
3,009 (about a state's worth) main 1,732 to 5,369 microseconds

A frame at 60 Hz has about 16.7 ms. Five milliseconds of filtering, on a laptop's processor, is a third of a frame before Compose has drawn anything; on a ₹9,000 phone it will be several times that. With nine prices it is nothing.

The obvious fix is one line before stateIn:

.flowOn(Dispatchers.Default)

and it does move the work: the log said thread=DefaultDispatcher-worker-5. It also broke typing. Typing "pune" with 0.1 to 0.15 seconds between letters, and reading the search field afterwards with UI Automator:

Build Runs Field read "pune" Field read something else
Filtering on main 9 9 none
flowOn(Dispatchers.Default) 9 6 "une", "pne", "pue"

Three runs in nine lost a letter. The text field shows uiState.query, which now arrives a few milliseconds after the keystroke, from another thread. A second keystroke lands while the first is still on its way back, and the field is reset to a value that is already out of date.

The version that kept every letter (nine runs in nine) splits the two jobs: the field reads the query straight from SavedStateHandle, synchronously, on the main thread, and only the price list is built on Default. Mandi Bhav has nine prices today, so its combine stays on the main thread, and that is the right call until the measurement says otherwise. Know where the line is: the ViewModel is on the main thread unless you move it, and what the user types must never wait for anything you moved.

3. Configuration changes: a real bug, found by this audit

Rotation recreates the activity and its composition; remember forgets, rememberSaveable and the ViewModel do not. Module 5 taught that with a counter. Running the rotation check against Mandi Bhav itself found one place that got it wrong.

On a phone with no saved prices, the Prices screen downloads them and, after 5 seconds, adds a line saying the connection is slow, so a farmer on weak data knows the app has not frozen. The flag was remember { mutableStateOf(false) }. A scratch build pointed at a local server that sends prices.json in pieces over 15 seconds, fresh install, read with UI Automator:

  5.4s portrait: ["Downloading today's prices…"]
 11.6s portrait: ["Downloading today's prices…", 'This is taking a while; the connection is slow. …']
 15.2s after rotating: ["Downloading today's prices…"]
 22.3s landscape: ["Downloading today's prices…", 'This is taking a while; the connection is slow. …']

The download carried on, because it belongs to the ViewModel; the explanation vanished for another five seconds, because it belonged to the composition. A small thing, on exactly the phone and connection the line was written for. The checkpoint changes one word:

// Saveable: a rotation half-way through a slow download must not
// take the explanation away for another five seconds.
var isSlow by rememberSaveable { mutableStateOf(false) }

and the same run, rotated at the same moment, kept the line on screen at every reading. Rotation is not the only configuration change: dark mode, font size, language and a foldable opening all do the same, and module 2 showed why android:configChanges is not the answer.

4. Lifecycle-aware collection: who is still listening?

PricesRoute collects with collectAsStateWithLifecycle(), which stops when the app goes to the background, and the ViewModel's stateIn(WhileSubscribed(5_000)) then stops building the state 5 seconds later. That chain is what makes module 9's "sync when the connection returns, only while the Prices screen is watching" true. Change one call in a scratch copy to the plainer collectAsState() and count the network callbacks the app has registered with the system (adb shell dumpsys connectivity, entries whose RequestorPkg is the app), with a log line when the state starts and stops being built:

On screen 3 s after Home 8 s after Home 30 s after Home
collectAsStateWithLifecycle() 1 1 0 0
collectAsState() 1 1 1 1
D/Upstream: prices state: started
D/Upstream: prices state: stopped

from the lifecycle version, 9.4 seconds after it started, and no "stopped" line at all from the other. With collectAsState() the app keeps a callback with the system, a Room query watching the prices table, and the "refresh when the connection comes back" logic alive for a screen nobody can see.

One honest note from the run: an attempt to show that background version actually downloading on reconnect did not produce a request in the time watched, so this lesson does not claim it did. Recent Android versions can also freeze a cached app's process, which hides some of this on new phones. Do not rely on the platform to stop work you started; stop it yourself.

5. Intents: the system sends you nulls too

Module 11 attacked the deep-link parser with hostile links. The quieter case is an intent that is not hostile at all, only incomplete. A scratch copy read the link the short way:

if (action == Intent.ACTION_VIEW) parseDeepLink(data!!.toString()) else null

and was sent a VIEW with no data, to the activity already open:

adb shell am start -a android.intent.action.VIEW -n com.riztechacademy.mandibhav.m15t/com.riztechacademy.mandibhav.MainActivity
E AndroidRuntime: java.lang.NullPointerException
E AndroidRuntime: 	at com.riztechacademy.mandibhav.MainActivity.linkTarget(MainActivity.kt:51)
E AndroidRuntime: 	at com.riztechacademy.mandibhav.MainActivity.onCreate$lambda$0(MainActivity.kt:33)
E AndroidRuntime: 	at androidx.activity.ComponentActivity.onNewIntent(ComponentActivity.kt:895)

The real app, data?.toString()?.let(::parseDeepLink), sent the same intent while open and again from cold, crashed zero times. An exported activity's intent is input from outside the app, and every field of it is optional until you have checked.

The checklist

Run this on any screen before you call it done. Each line takes a minute on the emulator:

Interruption How to cause it What must survive
Process death Home, adb shell am kill <package>, reopen from Recents Where the user was, and anything they typed; no crash
Configuration change Rotate; switch dark mode; change font size Everything on screen, including UI notes and half-typed text
Background Home, wait 10 s, dumpsys connectivity; watch logcat No callbacks, queries or downloads for an invisible screen
Main thread Time the ViewModel's combine with realistic data, on a release build Under a few ms per event; typing never waits for background work
Incomplete or hostile intents am start with missing data, missing extras, odd links The app opens normally; it never crashes

Check your work

Why did the singleton handoff survive rotation and crash after process death? Rotation keeps the process, so the object kept its value; process death restores the back stack from saved state but not objects in memory.

What did ?: "" turn the crash into? A market screen with no name saying "No prices from this market today", and a button that would save an empty home market: a plausible wrong answer instead of a stack trace.

Which thread does viewModelScope use, and what did 3,009 prices cost on it? Dispatchers.Main.immediate; 1.7 to 5.4 ms per keystroke on the emulator's release build.

Why did flowOn(Dispatchers.Default) lose letters? The field displayed uiState.query, which now came back asynchronously, so a stale value could overwrite a newer keystroke.

What did the rotation audit find in the real app, and what fixed it? The slow-connection note was held with remember and disappeared for 5 seconds after rotation; rememberSaveable keeps it.

What stayed registered after Home with collectAsState()? The app's network callback, still there 30 seconds later, along with the state-building upstream.

Practice

  1. Run the checklist on the Alerts screen. Type half an alert, then process-kill, rotate and switch dark mode. Note anything that does not survive, and decide whether it should.
  2. Reproduce the singleton crash in a scratch copy with its own applicationIdSuffix, then fix it by reading key.name. Uninstall the scratch app afterwards.
  3. Time PricesViewModel's combine on your own emulator with 3,000 made-up prices, on a release build. At what number of prices does it pass 2 ms?
  4. Break it on purpose: add .flowOn(Dispatchers.Default) and type quickly with adb shell input text, one letter at a time. Count how many runs in ten lose a letter. Then make the field read the query from SavedStateHandle directly and count again.
  5. Harder: MarketsViewModel.locationRefusedBefore uses SharingStarted.Eagerly. Using the background check from this lesson, find out what it keeps running after Home, decide whether that matters for a single DataStore flow, and write your conclusion as a comment above the line.

Official documentation

Next: Compose rules that keep screens fast.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship