ViewModel Internals Deep Dive: The Fountain of Youth Across Rotations and State Management
ViewModel Internals Deep Dive: The "Fountain of Youth" Across Rotations and State Management
ViewModel is one of the most core and widely adopted architecture components in Android Jetpack. All official samples and third-party libraries (like Compose, Navigation, and Hilt) are deeply coupled with it. For many developers, their understanding of ViewModel stops at: "It's the place where data lives in the MVVM architecture," and "It doesn't get destroyed when the screen rotates."
But why doesn't it get destroyed? Where does it actually live? Can it survive when the system kills the process due to low memory? And how exactly does viewModelScope automatically cancel coroutines?
In this article, we will discard superficial API tutorials and plunge directly into the depths of the Android Framework and Jetpack source code. We'll uncover the true inner workings of ViewModel and expose the potentially fatal pitfalls it harbors in industrial-grade production code.
1. The Secret of the "Fountain of Youth": How Does ViewModel Survive Configuration Changes?
In Android, Configuration Changes (like screen rotation, system language shifts, or dark mode toggles) are notorious headaches. When they occur, the current Activity is ruthlessly destroyed and recreated. If your network data is stored directly in the Activity, a screen rotation wipes it out, forcing a re-fetch.
ViewModel's core magic is exactly this: The Activity dies, but the ViewModel lives on; when the Activity is reborn, the ViewModel automatically remounts to the new instance.
1.1 ViewModelStore: The Safe Haven for Data
A ViewModel does not exist in isolation; it is housed within a class called ViewModelStore. You can think of ViewModelStore as a secure vault:
public class ViewModelStore {
private final HashMap<String, ViewModel> mMap = new HashMap<>();
final void put(String key, ViewModel viewModel) {
ViewModel oldViewModel = mMap.put(key, viewModel);
if (oldViewModel != null) {
oldViewModel.onCleared();
}
}
final ViewModel get(String key) {
return mMap.get(key);
}
public final void clear() {
for (ViewModel vm : mMap.values()) {
vm.clear(); // Internally invokes ViewModel.onCleared()
}
mMap.clear();
}
}
As you can see, ViewModelStore is essentially just a HashMap caching all instantiated ViewModels. Therefore, the real mechanism keeping ViewModels alive is how the system guarantees that ViewModelStore itself isn't destroyed when the Activity is destroyed and recreated.
1.2 onRetainNonConfigurationInstance: The Framework's Smuggling Tunnel
Tracing the ComponentActivity source code reveals that the system exploits a legacy Android mechanism: onRetainNonConfigurationInstance().
When the system prepares to destroy an Activity due to a configuration change, it calls this method. Developers can return any Object here. This object is "smuggled" and temporarily cached by the ActivityThread. When the new Activity is created, the system hands this exact object back to you via getLastNonConfigurationInstance().
In ComponentActivity, this tunnel is cleverly hijacked to smuggle the ViewModelStore:
// ComponentActivity.java (Simplified Source Code)
@Override
public final Object onRetainNonConfigurationInstance() {
// 1. Retrieve developer-provided custom config object (if any)
Object custom = onRetainCustomNonConfigurationInstance();
// 2. Retrieve the current ViewModelStore
ViewModelStore viewModelStore = mViewModelStore;
// If viewModelStore is null but existed previously (e.g., recreated but unused), attempt to get the old one
if (viewModelStore == null) {
NonConfigurationInstances nc =
(NonConfigurationInstances) getLastNonConfigurationInstance();
if (nc != null) {
viewModelStore = nc.viewModelStore;
}
}
// 3. Package the ViewModelStore into NonConfigurationInstances
NonConfigurationInstances nci = new NonConfigurationInstances();
nci.custom = custom;
nci.viewModelStore = viewModelStore; // CORE: The vault is stuffed into the smuggling package!
return nci;
}
During Activity initialization (e.g., in the constructor or before onCreate), the framework retrieves this smuggled vault:
// ComponentActivity.java retrieving ViewModelStore
public ViewModelStore getViewModelStore() {
if (mViewModelStore == null) {
NonConfigurationInstances nc =
(NonConfigurationInstances) getLastNonConfigurationInstance();
if (nc != null) {
// Perfect! We retrieved the ViewModelStore left by the previous Activity
mViewModelStore = nc.viewModelStore;
}
if (mViewModelStore == null) {
mViewModelStore = new ViewModelStore();
}
}
return mViewModelStore;
}
[Hardcore Takeaway]
The fundamental reason a ViewModel survives is that the underlying Android ActivityThread.performDestroyActivity flow invokes onRetainNonConfigurationInstance to capture state, temporarily storing it in the ActivityClientRecord. When the Activity is recreated, that Record's state is passed to the new instance. This completely bypasses memory-level object garbage collection.
2. The Process Death Dilemma and the SavedStateHandle Breakthrough
Screen rotations are trivial. Let's consider a much more extreme scenario: Process Death.
Imagine a user backgrounds your App and launches a memory-hungry game. System memory tightens, and your backgrounded App process is summarily killed by the Out Of Memory (OOM) killer. Later, the user returns to your App via the multi-tasking menu.
Is the ViewModel still there? The answer is: Dead as a doornail.
Because the entire process was wiped out, the ViewModelStore and its HashMap in memory have vanished. If your app relies solely on ViewModel to hold draft states, the user will return to find that the form they spent 30 minutes filling out has been entirely erased!
2.1 The Predicament of Traditional onSaveInstanceState
Before SavedStateHandle, the only solution was to write to a Bundle in the Activity's onSaveInstanceState. But this shatters the purity of MVVM: the state belongs in the ViewModel. Are you really going to extract the state from the ViewModel right before the Activity dies, serialize it into a Bundle, and then upon recreation, extract it from the Bundle in the Activity and inject it back into the ViewModel? The code would be atrocious.
2.2 SavedStateHandle: The Source-Level Breakthrough
Google recognized this and introduced SavedStateHandle. Its goal: To inject Bundle read/write capabilities directly into the ViewModel.
Usage is straightforward:
class MyViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
// Treat it just like a normal Map
var searchQuery: String
get() = savedStateHandle.get("QUERY") ?: ""
set(value) { savedStateHandle.set("QUERY", value) }
// You can even extract a Flow/LiveData directly
val queryFlow = savedStateHandle.getStateFlow("QUERY", "")
}
How is this achieved?
The mechanism behind this is the SavedStateRegistry, a registry system spanning the Activity and the ViewModel.
- Registration Phase: When the ViewModel is created, the
SavedStateHandleregisters itself as aSavedStateProviderwithin the Activity'sSavedStateRegistry. - Save Phase: When the system invokes the Activity's
onSaveInstanceState(Bundle), theSavedStateRegistryiterates over all Providers (including our ViewModel'sSavedStateHandle). - Serialization: The
SavedStateHandleconverts its internal data structures into a sub-Bundleand hands it to the Activity's mainBundle. This mainBundleis transmitted across processes (via Binder) into the system'sActivityManagerService (AMS)memory (stored within theActivityRecord). - Restoration Phase: After a process restart, the new Activity receives the large
Bundlereturned by AMS inonCreate(Bundle). TheSavedStateRegistryperforms reverse-dispatch. - ViewModel Reconstruction: When the Factory recreates the ViewModel, it extracts the specific sub-
Bundlebelonging to this ViewModel from theSavedStateRegistry, assembles it into aSavedStateHandle, and passes it into the ViewModel's constructor.
This is the closed-loop that crosses the boundaries of process life and death. However, be warned: The Bundle space allocated by AMS for each Activity is highly restricted (typically under 1MB, constrained by the global Binder buffer limit). You must NEVER store large images or massive list data inside a SavedStateHandle!
3. How Are ViewModel Instances Fabricated? (Provider & Factory)
Having understood storage, let's look at instantiation. Why can't we simply call new MyViewModel()? Why must we delegate via by viewModels() or ViewModelProvider?
If you directly new the object, it belongs to you; it's exempt from the system's lifecycle controls, and a screen rotation will turn it into garbage for the GC. The object must pass through ViewModelProvider to be registered into the aforementioned ViewModelStore.
3.1 ViewModelProvider's Dual Retrieval Logic
When you execute ViewModelProvider(this).get(MyViewModel::class.java), the internal event is a classic cached-load operation:
// ViewModelProvider.java
public <T extends ViewModel> T get(String key, Class<T> modelClass) {
// 1. Search the vault (ViewModelStore) to see if a ViewModel matches this key
ViewModel viewModel = mViewModelStore.get(key);
// 2. If found and types match, return the old one immediately! This is why state persists across rotations!
if (modelClass.isInstance(viewModel)) {
return (T) viewModel;
}
// 3. If not found (first creation, or Activity was completely destroyed), ask the Factory to build one
if (mFactory instanceof KeyedFactory) {
viewModel = ((KeyedFactory) mFactory).create(key, modelClass);
} else {
viewModel = mFactory.create(modelClass);
}
// 4. After creation, deposit it into the vault
mViewModelStore.put(key, viewModel);
return (T) viewModel;
}
3.2 Kotlin Property Delegation: by viewModels()
In Kotlin, we universally use val vm: MyViewModel by viewModels(). Behind the by keyword lies a ViewModelLazy object.
It leverages Kotlin's Lazy mechanism: The Provider retrieval logic is only triggered upon the first access of the vm variable. This is why we can declare it safely at the Activity's global variable level without worrying about the Activity not having reached onCreate yet (direct execution would crash, as the ViewModelStore isn't ready).
4. viewModelScope: Seamless Coroutine Lifecycle Management
Modern Android development is synonymous with Coroutines. The standard practice for initiating network requests within a ViewModel is using viewModelScope.launch { ... }.
The most breathtaking design detail is: When the ViewModel is destroyed (onCleared), how are these coroutines automatically cancelled? The developer never calls cancel() manually.
4.1 Unveiling the viewModelScope Extension Property
Diving into the source of viewModelScope, we discover it's actually a Kotlin Extension Property:
// ViewModel.kt
public val ViewModel.viewModelScope: CoroutineScope
get() {
// 1. Attempt to fetch cache from an internal ConcurrentHashSet inside the ViewModel
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
// 2. If not found, create a new CloseableCoroutineScope
// Note the use of SupervisorJob and Dispatchers.Main.immediate
return setTagIfAbsent(
JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
}
The core magic lies in CloseableCoroutineScope and setTagIfAbsent.
Beyond maintaining its core data, a ViewModel actually houses a Key-Value tag collection: mBagOfTags. When we access viewModelScope, it creates a CloseableCoroutineScope and stuffs it into this "bag."
4.2 The Ingenuity of the Closeable Interface
Let's examine the implementation of CloseableCoroutineScope:
internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
override val coroutineContext: CoroutineContext = context
// The crux of the magic! Implements java.io.Closeable
override fun close() {
coroutineContext.cancel() // Cancels all coroutines within this scope
}
}
Bringing It All Together!
Remember the ViewModelStore.clear() code we examined earlier? When the Activity genuinely dies (e.g., the user presses the back button), the system calls the ViewModel's clear() method:
// ViewModel.java
final void clear() {
mCleared = true;
// Iterate through all tags in the bag
if (mBagOfTags != null) {
synchronized (mBagOfTags) {
for (Object value : mBagOfTags.values()) {
// If this object implements Closeable, call its close() method!!!
closeWithRuntimeException(value);
}
}
}
onCleared(); // Callback reserved for developers
}
private static void closeWithRuntimeException(Object obj) {
if (obj instanceof Closeable) {
try {
((Closeable) obj).close(); // <--- Coroutine cancel() is triggered!
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
Absolutely elegant. Google engineers used the humble java.io.Closeable interface to bind the coroutine's lifecycle to the ViewModel's covert tag storage mechanism, realizing a near-magical automatic cancellation system.
5. Industrial-Grade Development: ViewModel Pitfalls and Demining Guide
While brilliantly designed, ViewModel frequently leads to severe bugs in real-world business logic due to developer misuse. Here are the fatal traps you must absolutely avoid:
5.1 The Lethal Trap: Memory Leaks Caused by ViewModel
Rule: A ViewModel's lifespan can massively outlast the View, Activity, or Fragment. You must ABSOLUTELY NEVER hold references to a View, Activity Context, or Fragment inside a ViewModel.
Anti-Pattern:
class BadViewModel : ViewModel() {
var myTextView: TextView? = null // Fatal Error 1
var context: Context? = null // Fatal Error 2
fun passContext(ctx: Context) {
this.context = ctx // If this is an Activity Context, LeakCanary will definitively scream on rotation
}
}
Underlying Cause:
Upon screen rotation, the old Activity is destroyed by the system and should be GC'd. However, because the ViewModel remains alive in the ViewModelStore (smuggled inside the ActivityThread), if the ViewModel's property points to the old Activity or a View within it, it pins an entire massive View hierarchy in memory, causing a colossal memory leak.
Correct Solution:
- Never pass Views or Contexts. UI logic belongs in the UI layer.
- If Context is strictly required for resources (e.g.,
getString), useAndroidViewModelto accept theApplicationContext. Even then, best practices dictate resolving resources in the UI layer; the ViewModel should output state enums or Resource IDs.
5.2 Scope Chaos: Shared ViewModels in Navigation
A ViewModel's Scope is determined by whom you ask the ViewModelProvider for.
ViewModelProvider(activity).get()$\rightarrow$ Scope is the entire Activity.ViewModelProvider(fragment).get()$\rightarrow$ Scope is isolated to that Fragment.
When using the Navigation Component, there is a frequent requirement: Sharing a single ViewModel across a specific sub-flow (e.g., a 3-step registration wizard involving 3 Fragments).
The Trap: If you use an Activity-scoped ViewModel to share this data, you'll encounter data residue. If a user finishes registration, goes to the home page, and later clicks register again, the Activity-scoped ViewModel hasn't been destroyed. The user's previous password will eerily persist in the input field.
The Breakthrough: Utilize NavGraph Scoping
The Navigation framework provides an independent ViewModelStore for every <navigation> node in the back stack.
// In Fragment 1, 2, and 3, request the ViewModel bound to the shared navGraph node
val sharedViewModel: RegisterViewModel by navGraphViewModels(R.id.register_graph)
The underlying principle is that Navigation's NavBackStackEntry implements ViewModelStoreOwner. When the user exits the register_graph route flow, the Navigation engine proactively calls ViewModelStore.clear() on this Entry. This surgically destroys the ViewModel that was valid only for this specific business flow, guaranteeing no dirty data residue.
5.3 "Zombie Subscriptions" in Background Coroutines
When collecting StateFlow exposed by a ViewModel in the UI, developers often make a fatal mistake: directly calling collect inside lifecycleScope.launch.
Anti-Pattern:
// MyFragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleScope.launch {
viewModel.locationFlow.collect { loc ->
// Update Map location
}
}
}
Risk Analysis:
lifecycleScope is only cancelled when the Fragment undergoes onDestroy. When the app is backgrounded, the Fragment is not Destroyed (it is merely Stopped). This means the coroutine above remains alive, frantically listening to the Flow and consuming resources! If this is a high-frequency location stream, your App will drastically drain the battery even while running in the background.
The Mandatory Standard (Lifecycle-Aware Collection):
// Correct Practice: using repeatOnLifecycle
viewLifecycleOwner.lifecycleScope.launch {
// Only executes when the Lifecycle is at STARTED or higher.
// When backgrounded and dropping to STOPPED, the internal coroutine is automatically cancelled!
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.locationFlow.collect { loc ->
// Safe UI updates
}
}
}
Alternatively, use the cleaner Kotlin extension: flowWithLifecycle.
Conclusion
ViewModel is not just a simple "Data Class." It is the critical intersection of the Android framework layer, configuration change management, process state rescue (SavedState), and Kotlin coroutine lifecycle management.
- Understanding
NonConfigurationInstancesexplains why it fears no screen rotations. - Understanding
SavedStateRegistryexplains how it resurrects after forced process termination. - Understanding
Closeablebinding unravels the ingenious automated teardown ofviewModelScope.
Writing code isn't just about calling APIs; it's about studying the source code to appreciate the architectural paradigms system engineers use to tackle extreme lifecycle scenarios. Once you master this, every keystroke you make within the MVVM architecture will be precise, robust, and completely bulletproof.