ViewModel, and surviving rotation
Priya is a trader at Vashi. She types "pune", taps "Tomato", and turns her phone sideways to see the two columns module 4 built for her. The search box is empty and every price is back. Module 2 showed why: rotation destroys the activity, and lesson 1 showed that remember dies with it. The search needs an owner that outlives the activity, and Android has one built for exactly this job, the ViewModel. This lesson moves the filter into one, proves on the emulator that it survives rotation, and puts its filtering under unit test.
What a ViewModel is
A ViewModel is an object that holds a screen's state and logic, and that Android keeps alive across configuration changes. When the activity is destroyed with isChangingConfigurations=true, the activity's ViewModels are kept; the new activity is handed the same objects. When the activity finishes for good (Back, or the user swiping it from Recents), the ViewModel is cleared and its onCleared() runs.
| Event | Activity | remember |
ViewModel |
|---|---|---|---|
| Recomposition | Same | Kept | Kept |
| Rotation, dark mode, language change | New object | Lost | Kept |
| Back, then reopen | New object | Lost | Cleared, new one |
| Process killed in the background | New object, new process | Lost | Lost |
The last row matters, and lesson 4 deals with it. A ViewModel is an object in your process's memory. It survives the activity, not the process.
What a ViewModel is not for: data that must outlive the screen. A price alert the user set belongs on disk (the local-data module). A ViewModel holds the state of a screen while that screen exists.
Adding the libraries
Two libraries from the lifecycle family, the same family as the lifecycle-runtime-ktx the project already has:
lifecycle-viewmodel-composegives you theviewModel()function for composables.lifecycle-runtime-composegives youcollectAsStateWithLifecycle(), which turns a flow into Compose state.
They share a version with the rest of lifecycle. On Google Maven on 8 November 2026 the latest stable was 2.11.0, the version the catalog already uses (a 2.12.0 alpha exists; this course stays on stable releases). Check Google Maven yourself rather than trusting a number in a blog post. In gradle/libs.versions.toml, under [libraries], beside the existing lifecycle entry:
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
The ViewModel will use coroutines and flows. The tests at the end need kotlinx-coroutines-test, and its version should match the coroutines the app runs on. On Maven Central the latest was 1.11.0, but the app was resolving coroutines 1.9.0, pulled in by AndroidX libraries; adding the test library alone would have run the tests on 1.11.0 and the app on 1.9.0. So declare both, from one version:
[versions]
coroutines = "1.11.0"
[libraries]
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
and in app/build.gradle.kts:
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.kotlinx.coroutines.android)
// …
testImplementation(libs.kotlinx.coroutines.test)
To see which version a configuration really resolves, ask Gradle:
./gradlew :app:dependencyInsight --configuration debugRuntimeClasspath --dependency kotlinx-coroutines-core
Its first line read kotlinx-coroutines-core:1.9.0 (by constraint) before the change and kotlinx-coroutines-core:1.11.0 after.
PricesViewModel
Create ui/PricesViewModel.kt:
package com.riztechacademy.mandibhav.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.riztechacademy.mandibhav.data.SamplePrices
import com.riztechacademy.mandibhav.model.CommodityPrice
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
class PricesViewModel : 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)
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _selectedCommodity = MutableStateFlow<String?>(null)
val selectedCommodity: StateFlow<String?> = _selectedCommodity.asStateFlow()
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) {
_query.value = newQuery
}
/** A tap on a chip: null is "All"; tapping the selected chip again clears it. */
fun onCommodityClick(commodity: String?) {
_selectedCommodity.value =
if (commodity == _selectedCommodity.value) null else commodity
}
}
filterPrices and commoditiesIn move in unchanged, as module 4 promised when it took them out of the composable. Line by line:
MutableStateFlowis a holder of one current value that others can observe, the coroutines version ofmutableStateOf. The mutable one isprivate; the publicqueryis a read-onlyStateFlow, so only theViewModelcan change the filter. The underscore prefix for the private one is the Kotlin convention for this pair.combineproduces a new value whenever either input changes, andstateInturns that into aStateFlowwith a current value, so the screen always has a list to draw.viewModelScopeis a coroutine scope that belongs to theViewModeland is cancelled when it is cleared. Work started in it cannot outlive the screen's owner.WhileSubscribed(5_000)runs the filtering only while someone is collecting, and keeps it running for five seconds after the last collector leaves. A rotation takes well under a second, so the flow does not restart; an app left in the background for a minute stops filtering altogether.- The two filter rules live here now: what a query matches (in
filterPrices) and what a second tap on a chip means (here). The screen knows neither.
Module 7 will gather the three flows into a single UI state object. Three separate flows are fine for a screen this size, and easier to read the first time.
The owner changes, the screen does not
PricesRoute from lesson 2 swaps its remembers for the ViewModel:
@Composable
fun PricesRoute(
modifier: Modifier = Modifier,
viewModel: PricesViewModel = viewModel(),
) {
val query by viewModel.query.collectAsStateWithLifecycle()
val selectedCommodity by viewModel.selectedCommodity.collectAsStateWithLifecycle()
val visiblePrices by viewModel.visiblePrices.collectAsStateWithLifecycle()
PricesScreen(
prices = visiblePrices,
commodities = viewModel.commodities,
query = query,
selectedCommodity = selectedCommodity,
onQueryChange = viewModel::onQueryChange,
onCommodityClick = viewModel::onCommodityClick,
modifier = modifier,
)
}
New imports: androidx.lifecycle.viewmodel.compose.viewModel and androidx.lifecycle.compose.collectAsStateWithLifecycle. remember, mutableStateOf and setValue are no longer used in this file; getValue stays, for the by, and SamplePrices stays for the previews.
PricesScreen did not change at all. That is lesson 2 paying off: because the screen was stateless, replacing its owner was a change to one small function. This is also the arrangement module 3's previews lesson promised: the ViewModel lives one level up, in a composable that is not previewed, and everything inside it is.
viewModel() asks the nearest ViewModelStoreOwner, here the activity, for a PricesViewModel. The first call creates one; every later call, including the one in the recreated activity after a rotation, returns the same object. The default value of the parameter lets a test or a preview pass its own.
collectAsStateWithLifecycle() collects a flow into Compose state, so reading it recomposes when it changes. "With lifecycle" means it collects only while the activity is at least started, and stops when it is in the background. Combined with WhileSubscribed, a Mandi Bhav left behind another app does no filtering at all. Its plainer cousin collectAsState() keeps collecting in the background, which on a cheap phone costs battery for a screen nobody can see.
Proving it on the emulator
To see the objects, add two temporary log lines to the ViewModel, using the Log and Process classes module 2 used:
init {
Log.d("PricesVM", "created viewModel=${hashCode()} pid=${Process.myPid()} query=\"${query.value}\"")
}
override fun onCleared() {
Log.d("PricesVM", "onCleared viewModel=${hashCode()}")
}
and module 2's onCreate and onDestroy lines to MainActivity, under the same tag. Type "pune", tap "Tomato", rotate to landscape and back:
D PricesVM(21283): MainActivity onCreate activity=200314805 pid=21283
D PricesVM(21283): created viewModel=102638389 pid=21283 query=""
D PricesVM(21283): MainActivity onDestroy activity=200314805 isChangingConfigurations=true
D PricesVM(21283): MainActivity onCreate activity=142439399 pid=21283
D PricesVM(21283): MainActivity onDestroy activity=142439399 isChangingConfigurations=true
D PricesVM(21283): MainActivity onCreate activity=10147173 pid=21283
Three activity objects, one ViewModel, created once and never cleared. The landscape screen showed "pune" in the field, "Tomato" selected and the single Pune tomato card. Lesson 1's run of the same steps with remember showed an empty field and every price.
Now repeat module 2's process-death test: Home, then adb shell am kill com.riztechacademy.mandibhav, then reopen the app from Recents:
D PricesVM(21435): MainActivity onCreate activity=218345176 pid=21435
D PricesVM(21435): created viewModel=160256970 pid=21435 query=""
A new process, a new ViewModel, an empty query, and the screen showed every price with "All" selected. There was no onCleared line before it: a killed process runs no code at all. Lesson 4 fixes this.
A word on the text field
The text field's value now makes a round trip: a keystroke calls onQueryChange, which sets a StateFlow, which collectAsStateWithLifecycle turns back into state that the field displays. Android's text-field guide warns that a value-based field can misbehave if the value it shows is updated late, and steers new code towards the state-based TextField(state = …). In Mandi Bhav the update is immediate, and on the emulator adb shell input text lasalgaon, nine characters as fast as adb can send them, arrived intact in every run for this lesson. The rule that keeps it that way: the value the field shows must be set directly in onQueryChange. Any delay, a debounce or a database round trip, belongs on the derived list, never on the query the field displays.
The trap: keeping a Context in a ViewModel
The ViewModel outlives the activity, so anything it holds that belongs to one activity outlives it too. Hold the activity in a scratch copy to see it:
// DO NOT DO THIS: a demonstration of the leak.
var screen: Activity? = null
set it from MainActivity.onCreate the first time (if (viewModel.screen == null) viewModel.screen = this, with the ViewModel obtained by by viewModels()), and log what it holds. Two rotations:
D PricesVM(21539): MainActivity onCreate activity=200314805 pid=21539
D PricesVM(21539): viewModel holds activity=200314805 isDestroyed=false
D PricesVM(21539): MainActivity onDestroy activity=200314805 isChangingConfigurations=true
D PricesVM(21539): MainActivity onCreate activity=85903231 pid=21539
D PricesVM(21539): viewModel holds activity=200314805 isDestroyed=true
D PricesVM(21539): MainActivity onDestroy activity=85903231 isChangingConfigurations=true
D PricesVM(21539): MainActivity onCreate activity=35772888 pid=21539
D PricesVM(21539): viewModel holds activity=200314805 isDestroyed=true
The ViewModel is still holding the first activity, destroyed since the first rotation. That activity holds its whole window, every view and composition in it, and every resource it loaded, none of which can be freed while the ViewModel points at it. That is a memory leak, and on a 3 GB phone it ends in an OutOfMemoryError on some unrelated screen. Worse, anything the ViewModel does with that activity, such as showing a dialog, acts on a screen that no longer exists.
Lint flags it as a warning, which the build does not stop for:
PricesViewModel.kt:18: Warning: This field leaks a context object [StaticFieldLeak]
A ViewModel must never hold an Activity, a Context from one, a View, or anything from the composition: a composable lambda, a ScrollState, a SnackbarHostState, a NavController. They all belong to one activity's lifetime. If the ViewModel needs something from the UI, the UI passes it in as a plain value with the event. If it needs a Context to read a resource or a file, that is a sign the work belongs in the data layer, which module 7 builds; the one exception is the application-wide Context, which lives as long as the process.
Testing the ViewModel
The filter rules now live in a plain class, so a JVM test can check them in milliseconds. One complication: viewModelScope runs on Dispatchers.Main, Android's main thread, which a unit test on your laptop does not have. The test library's Dispatchers.setMain replaces it, and a small JUnit rule does that around every test. Create app/src/test/java/com/riztechacademy/mandibhav/MainDispatcherRule.kt:
package com.riztechacademy.mandibhav
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/**
* Replaces Dispatchers.Main for the length of each test. A unit test runs on
* the JVM, where there is no Android main thread, and viewModelScope uses
* Dispatchers.Main.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
Then app/src/test/java/com/riztechacademy/mandibhav/ui/PricesViewModelTest.kt:
package com.riztechacademy.mandibhav.ui
import com.riztechacademy.mandibhav.MainDispatcherRule
import com.riztechacademy.mandibhav.data.SamplePrices
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class PricesViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
/**
* visiblePrices only updates while someone collects it
* (SharingStarted.WhileSubscribed), so each test collects it the way the
* screen does, for as long as the test runs.
*/
private fun TestScope.collectVisiblePrices(viewModel: PricesViewModel) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.visiblePrices.collect {}
}
}
@Test
fun startsWithNoFilterAndEveryPrice() = runTest {
val viewModel = PricesViewModel()
collectVisiblePrices(viewModel)
assertEquals("", viewModel.query.value)
assertNull(viewModel.selectedCommodity.value)
assertEquals(SamplePrices.all, viewModel.visiblePrices.value)
}
@Test
fun queryAndChipFilterTogether() = runTest {
val viewModel = PricesViewModel()
collectVisiblePrices(viewModel)
viewModel.onQueryChange("pune")
viewModel.onCommodityClick("Tomato")
assertEquals(listOf(SamplePrices.puneTomato), viewModel.visiblePrices.value)
}
@Test
fun tappingTheSelectedChipAgainClearsIt() = runTest {
val viewModel = PricesViewModel()
collectVisiblePrices(viewModel)
viewModel.onCommodityClick("Onion")
assertEquals("Onion", viewModel.selectedCommodity.value)
assertEquals(3, viewModel.visiblePrices.value.size)
viewModel.onCommodityClick("Onion")
assertNull(viewModel.selectedCommodity.value)
assertEquals(SamplePrices.all, viewModel.visiblePrices.value)
}
}
./gradlew testDebugUnitTest ran 15 tests, all passing: these three and the twelve from earlier modules.
| Piece | What it does |
|---|---|
runTest { … } |
Runs a coroutine test body, skipping any delay instantly |
UnconfinedTestDispatcher |
Runs launched coroutines at once, so a value set is visible on the next line |
backgroundScope |
A scope inside the test that is cancelled when the test ends, for collectors that never finish |
Dispatchers.setMain / resetMain |
Swap the main dispatcher for a test one, and put it back |
Why each test collects visiblePrices. Remove the collectVisiblePrices(viewModel) line from queryAndChipFilterTogether and it fails:
java.lang.AssertionError: expected:<[CommodityPrice(commodity=Tomato, market=Pune (Gultekdi) APMC, …)]> but was:<[CommodityPrice(commodity=Onion, market=Lasalgaon APMC, …), …
With nobody collecting, WhileSubscribed never started the filtering, so the value was still the initial list of all nine. The test caught a real property of the code: the list only updates while the screen is watching it, which is the behaviour you wanted, and a test has to watch it too.
Check your work
What survives a rotation that remember did not. The ViewModel, because Android keeps it across configuration changes.
What the log proved. Three activity objects (three hashes) and one ViewModel, created once, in one process.
What a ViewModel does not survive. Process death: a new process got a new ViewModel with an empty query.
Why _query is private and query is a StateFlow. Only the ViewModel may change the filter; the screen can only read it.
What collectAsStateWithLifecycle adds over collectAsState. It stops collecting while the app is in the background.
Why PricesScreen did not change. It was already stateless; only its owner was replaced.
Why a ViewModel must not hold an activity. It outlives the activity, so it keeps the destroyed one, and everything that activity references, in memory.
Why the tests need MainDispatcherRule. viewModelScope uses Dispatchers.Main, which a JVM test does not have.
Practice 4. It fails the same way: expected:<3> but was:<9> on the onion count. Any test that reads visiblePrices.value needs a collector.
Practice 5. The answer, run on the emulator: after Back and reopening the app, the log showed onCleared viewModel=… when the activity finished and a new created viewModel=… with query="" on reopening. Back means the user is finished with the screen, so the ViewModel goes with it, as the table says.
Practice
- Add the libraries,
PricesViewModeland the newPricesRoute. Type "pune", tap "Tomato" and rotate twice. Then do the same in split screen, dragging the divider across 600 dp, which is also a configuration change. - Add the log lines and reproduce the rotation log and the process-death log. Remove them afterwards.
- Write the three tests and the rule, and run them.
- Break it on purpose: delete
collectVisiblePrices(viewModel)fromtappingTheSelectedChipAgainClearsIt, predict the failure message, then run it. - Predict what Back, then reopening the app, does to the
ViewModel. Check it with theonClearedlog line. - Harder: in a scratch copy, reproduce the leak, run
./gradlew lintDebug, and find theStaticFieldLeakwarning. Then say how theViewModelcould show a "Prices updated" message without holding anything from the activity. (Lesson 5's snackbar is one answer.)
Official documentation
- ViewModel overview — The lifecycle diagram, scoping, and the rule about not holding references to UI.
- StateFlow and SharedFlow —
StateFlow,stateInand the sharing strategies, includingWhileSubscribed. - Use Kotlin coroutines with lifecycle-aware components —
viewModelScope, and collecting flows only while the UI is visible. - Testing Kotlin coroutines on Android —
runTest, the test dispatchers, and the main-dispatcher rule used here.
Next: saved state, so the search comes back even after Android kills the process.
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