RizTech Academy logo
RizTech Academy
State and LifecycleLesson 4 of 535 min

Saved state, and surviving process death

Kavita grows onions near Lasalgaon. She opens Mandi Bhav, types "pune", taps "Tomato" to see what her neighbour's tomatoes might fetch, and then her phone rings. She takes the call, checks a message, opens the camera to photograph a receipt. Twenty minutes later she comes back to Mandi Bhav and the search is gone. Nothing crashed. Android needed the memory for the camera, killed Mandi Bhav's process while it sat in the background, and the ViewModel from lesson 3 died with it. On a phone with 3 GB of RAM this is not a rare event; it is a normal afternoon. This lesson makes the filter survive it.

Seeing it fail, on purpose

Module 2 showed how to cause process death on demand. With the lesson 3 build installed, type "pune", tap "Tomato", then:

  1. Press Home, so the app is in the background (Android will not kill a foreground app this way).
  2. Run adb shell am kill com.riztechacademy.mandibhav.
  3. Open Recents and tap Mandi Bhav's card.

With lesson 3's log lines, the run for this lesson showed:

D PricesVM(21283): created viewModel=102638389 pid=21283 query=""
…
D PricesVM(21435): MainActivity onCreate activity=218345176 pid=21435
D PricesVM(21435): created viewModel=160256970 pid=21435 query=""

A new process (21283 became 21435) and a new ViewModel starting from an empty query. The screen showed every price, "All" selected, an empty field.

One thing in the Recents screen is worth noticing first. Its card for Mandi Bhav still showed the filtered screen, search text and all: that thumbnail is a picture taken when the app went to the background, not the app. Tapping it starts a new process that has to rebuild the screen from whatever was saved. Users see the thumbnail, expect that screen, and get a reset one; that is why this bug feels so much worse to them than it looks to you.

Where saved state lives

Module 2's log had the answer. When the activity stopped, onSaveInstanceState ran, and after the kill the new activity reported restored=true: Android had kept a Bundle for it outside your process, in the system, and handed it back. Lesson 1's rememberSaveable writes into that Bundle. The ViewModel needs the same thing, and it is called SavedStateHandle.

A SavedStateHandle is a key-value map that the ViewModel receives in its constructor. Whatever you put in it is written into the activity's saved-state Bundle when the activity stops, and a ViewModel created after process death receives a handle already holding those values.

You do not create it. Add it as a constructor parameter and the default factory behind viewModel() supplies it; there is no factory code to write.

PricesViewModel, saving its filter

Change ui/PricesViewModel.kt to this:

package com.riztechacademy.mandibhav.ui

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.riztechacademy.mandibhav.data.SamplePrices
import com.riztechacademy.mandibhav.model.CommodityPrice
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn

/**
 * Owns the prices screen's filter: the search query and the chosen commodity.
 *
 * The ViewModel outlives a rotation; the two filter values are also kept in
 * [savedStateHandle], so they come back after Android kills the process.
 * The price list itself is not saved: it is data, and is loaded again.
 */
class PricesViewModel(
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() {

    // Sample data for now; module 7 replaces this with a repository.
    private val prices: List<CommodityPrice> = SamplePrices.all

    /** One chip per commodity, in the order it first appears. */
    val commodities: List<String> = commoditiesIn(prices)

    /** What the user has typed in the search field. */
    val query: StateFlow<String> = savedStateHandle.getStateFlow(KEY_QUERY, "")

    /** The commodity chip that is selected, or null for "All". */
    val selectedCommodity: StateFlow<String?> =
        savedStateHandle.getStateFlow(KEY_COMMODITY, null)

    /** The prices the screen shows: [prices] filtered by the two values above. */
    val visiblePrices: StateFlow<List<CommodityPrice>> =
        combine(query, selectedCommodity) { query, commodity ->
            filterPrices(prices, query, commodity)
        }.stateIn(
            scope = viewModelScope,
            // Keep filtering for 5 s after the screen stops collecting, so a
            // rotation (a second or less) does not restart the work.
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = filterPrices(prices, query.value, selectedCommodity.value),
        )

    fun onQueryChange(newQuery: String) {
        savedStateHandle[KEY_QUERY] = newQuery
    }

    /** A tap on a chip: null is "All"; tapping the selected chip again clears it. */
    fun onCommodityClick(commodity: String?) {
        savedStateHandle[KEY_COMMODITY] =
            if (commodity == selectedCommodity.value) null else commodity
    }

    private companion object {
        const val KEY_QUERY = "query"
        const val KEY_COMMODITY = "commodity"
    }
}

What changed from lesson 3:

  • The two MutableStateFlows are gone. savedStateHandle.getStateFlow(key, initialValue) returns a StateFlow backed by the handle: its value is whatever the handle holds for that key, or the initial value if it holds nothing.
  • Writes go to the handle. savedStateHandle[KEY_QUERY] = newQuery stores the value and updates the flow at once, so the text field still gets its value immediately, as lesson 3 required.
  • The keys are constants, private to the class. A typo in a key string is a value that silently never comes back; one constant per key makes that impossible.
  • The public surface is identical: query, selectedCommodity, visiblePrices and the two functions. PricesRoute and PricesScreen do not change, and neither does viewModel().

Run it, and repeat the three steps. With a temporary log line in init printing the handle's keys and the two values:

D PricesVM(21679): created viewModel=102638389 pid=21679 keys=[query, commodity] query="" commodity=null
D PricesVM(21796): created viewModel=160256970 pid=21796 keys=[commodity, query] query="pune" commodity=Tomato

A new process, a new ViewModel, and the filter came back. The screen showed "pune" in the field, "Tomato" selected and the one Pune tomato card, exactly what Kavita left.

Rotation and process death, compared

What happened Activity ViewModel Values in SavedStateHandle
Rotation New Same object, so the handle is the same too Never left memory
Process death New, new process New, built with a restored handle Read back from the Bundle
Back, or swiped from Recents Finished Cleared Discarded: the user is done
App force-stopped, or a new version installed Gone Gone Discarded

The last two rows are correct behaviour. A user who backs out of the screen expects it to start fresh, and the runs for this lesson confirmed all three: Back, then reopening, logged onCleared and a new ViewModel with query=""; so did adb shell am force-stop, and so did installing a new build over the old one while the app sat in the background. Saved state is for "the system interrupted me", not for "remember this next week". That second job belongs to disk, which the local-data module covers.

What belongs in saved state

Saved state is copied out of your process every time the user presses Home or turns the phone, and the system holds on to it for as long as the user could come back. So it is for small values that let you rebuild the screen, never for the data itself:

Value Saved state? Why
Search query Yes Small, typed by the user, impossible to get back otherwise
Selected commodity, tab or filter Yes Small, and it decides what the screen shows
The id of the price or market being viewed Yes Enough to load the rest again
A half-written form, such as a price-alert threshold Yes, if short The user's own work
Scroll position Already saved rememberScrollState() saves it for you
The list of prices No It is data: reload it from the network or the database
Images, files, anything large No Size, below
Passwords and tokens No The system keeps the Bundle outside your process

The rule: save what the user chose; reload what the server or the database knows.

The trap: saving the price list

It is tempting, because the list is right there in the ViewModel. In a scratch copy, add one line to init:

savedStateHandle["prices"] = prices

It compiles. It runs. The screen works. Press Home, and the app crashed:

E AndroidRuntime: FATAL EXCEPTION: main
E AndroidRuntime: java.lang.IllegalArgumentException: Parcel: unknown type for value CommodityPrice(commodity=Onion, market=Lasalgaon APMC, date=2026-11-07, minPrice=1200, maxPrice=2851, modalPrice=2450)
E AndroidRuntime: 	at android.os.Parcel.getValueType(Parcel.java:2719)

A Bundle can hold strings, numbers, booleans, arrays and lists of those, and classes that implement Parcelable; CommodityPrice is none of those. Note when it failed: not when the value was stored, but at the first Home press, which is the first moment Android tried to write the Bundle. A developer who never presses Home while testing ships this.

Making CommodityPrice Parcelable would get past that error and into the next one. Change the line to store the list as text, 3,000 prices, about what a busy day at Vashi reports:

savedStateHandle["prices"] = ArrayList(List(3_000) { i -> prices[i % prices.size].toString() })

Home again:

E JavaBinder: !!! FAILED BINDER TRANSACTION !!!  (parcel size = 767328)
E AndroidRuntime: java.lang.RuntimeException: android.os.TransactionTooLargeException: data parcel size 767328 bytes
E AndroidRuntime: Bundle stats:
E AndroidRuntime:   androidx.lifecycle.BundlableSavedStateRegistry.key [size=766428]
E AndroidRuntime:     androidx.lifecycle.internal.SavedStateHandlesProvider [size=765640]
E AndroidRuntime:       androidx.lifecycle.ViewModelProvider.DefaultKey:com.riztechacademy.mandibhav.ui.PricesViewModel [size=765424]
E AndroidRuntime:         prices [size=765328]

Read the numbers. The saved state was 767,328 bytes, under the 1 MB you may have read about, and it still crashed. The 1 MB is the size of the buffer a process uses for all its transactions with the system at once, so the real ceiling for one Bundle is lower and depends on what else is happening. Android's documentation recommends keeping saved state under 50 KB. Mandi Bhav's two strings come to well under one.

The "Bundle stats" are worth knowing about: when you hit this in a real app, they name the key that is too big, nested inside the ViewModel that owns it.

Remove the line before you go on.

SavedStateHandle or rememberSaveable

Both write to the same Bundle. The choice follows from lesson 2's rule about owners:

State owned by Save it with
A ViewModel SavedStateHandle
A composable, as UI element state rememberSaveable

The filter is owned by PricesViewModel, so it uses the handle. If the query lived in rememberSaveable in PricesRoute and was copied into the ViewModel, you would have lesson 2's two sources of truth again.

Testing saved state

A SavedStateHandle is a plain class, so the tests can make one. First, every PricesViewModel() in PricesViewModelTest becomes PricesViewModel(SavedStateHandle()), with the import androidx.lifecycle.SavedStateHandle. Then two new tests:

@Test
fun filterIsWrittenToSavedState() {
    val handle = SavedStateHandle()
    val viewModel = PricesViewModel(handle)

    viewModel.onQueryChange("lasalgaon")
    viewModel.onCommodityClick("Onion")

    assertEquals("lasalgaon", handle.get<String>("query"))
    assertEquals("Onion", handle.get<String>("commodity"))
}

@Test
fun newViewModelRestoresTheFilterFromSavedState() = runTest {
    // What Android does after process death: a brand-new ViewModel,
    // handed the values that were saved before the process was killed.
    val handle = SavedStateHandle(mapOf("query" to "lasalgaon", "commodity" to "Onion"))
    val restored = PricesViewModel(handle)
    collectVisiblePrices(restored)

    assertEquals("lasalgaon", restored.query.value)
    assertEquals("Onion", restored.selectedCommodity.value)
    assertEquals(listOf(SamplePrices.lasalgaonOnion), restored.visiblePrices.value)
}

The second test is process death without an emulator: SavedStateHandle(mapOf(…)) is how a restored handle looks to the ViewModel. It runs in milliseconds, so it runs on every build, where the emulator check runs when you remember to. You still do the emulator check before calling the screen done, because only it proves Android really saved and restored the Bundle. The test proves your ViewModel reads it correctly; the emulator proves the platform does its part.

./gradlew testDebugUnitTest now runs 17 tests.

Check your work

Why the lesson 3 ViewModel lost the filter. It lives in the process's memory, and the process was killed.

What the Recents thumbnail showed, and why it misleads. The screen as it was; it is a picture, and tapping it starts a new process.

Where SavedStateHandle values go when the app is backgrounded. Into the activity's saved-state Bundle, which Android keeps outside the process.

How the ViewModel gets a SavedStateHandle. As a constructor parameter, supplied by the default factory behind viewModel().

What getStateFlow(key, initial) returns. A StateFlow of the handle's value for that key, or the initial value if there is none.

Why the price list must not be saved. It is data that can be reloaded, it is large, and a CommodityPrice cannot be put in a Bundle at all.

When the CommodityPrice crash happened. At the first Home press, when Android wrote the Bundle, not when the value was stored.

Why 767,328 bytes failed although it is under 1 MB. The 1 MB buffer is shared by all the process's transactions; aim for under 50 KB.

Which to use for state a ViewModel owns. SavedStateHandle.

Practice 4. The first assertion fails, org.junit.ComparisonFailure: expected:<[lasalgaon]> but was:<[]> (JUnit brackets the part of two strings that differs, here all of it): the handle holds "lasalgaon" under a key the ViewModel never reads, so getStateFlow returned its initial value. The commodity still comes back, so on a phone the user would see every onion, with the search they typed gone. One constant per key, used for every read and write, is what stops this; a test like this one is what catches it when someone works around the constant.

Practice

  1. Make the change to PricesViewModel, run the three-step process-death test, and confirm the filter comes back. Compare the pids.
  2. Rotate as well, and confirm rotation still keeps the same ViewModel object.
  3. Update the existing tests and add the two new ones. Run them.
  4. Break it on purpose: in newViewModelRestoresTheFilterFromSavedState, change the map's "query" key to "search", as if someone had renamed the key in the ViewModel but not everywhere else. Predict which assertion fails and with what message, then run it.
  5. Reproduce the CommodityPrice crash in a scratch copy and read the stack trace. Then reproduce the TransactionTooLargeException and find the key in the "Bundle stats".
  6. Harder: Mandi Bhav will later open a price's detail screen. Decide what that screen's ViewModel should keep in its SavedStateHandle so that it can rebuild itself after process death, and what it should load instead. Write the two lists, and say what the screen should show while the load is in progress.

Official documentation

  • Saved State module for ViewModel — SavedStateHandle, getStateFlow, the supported types, and the Compose saveable helper.
  • Save UI states — The comparison of ViewModel, saved state and persistent storage, with what each survives.
  • Parcelables and bundles — The shared 1 MB binder buffer, TransactionTooLargeException, and the 50 KB recommendation.

Next: side effects, for the things the screen has to do that are not drawing.

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