Breaking down the ViewModel's Internal Mechanisms and Multiplatform
Breaking down the ViewModel's Internal Mechanisms and Multiplatform
Android's ViewModel has become an essential component of modern Android development, providing a lifecycle-aware container for UI-related data that survives configuration changes. While the API appears simple on the surface, the internal machinery reveals sophisticated design decisions around lifecycle management, multiplatform abstraction, resource cleanup, and thread-safe caching. Understanding how ViewModel works under the hood helps you make better architectural decisions and avoid subtle bugs.
In this article, you'll dive deep into how Jetpack ViewModel works internally, exploring how the ViewModelStore retains instances across configuration changes, how ViewModelProvider orchestrates creation and caching, how the factory pattern enables flexible instantiation, how CreationExtras enables stateless factories, how resource cleanup is managed through the Closeable pattern, and how viewModelScope integrates coroutines with ViewModel lifecycle. This isn't a guide on using ViewModel, it's an exploration of the internal machinery that makes lifecycle-aware state management possible.
The fundamental problem: Surviving configuration changes
Configuration changes present a fundamental challenge for Android development. When a user rotates their device, changes language settings, or triggers any configuration change, the system destroys and recreates the Activity. Any data stored in the Activity is lost:
class MyActivity : ComponentActivity() {
private var userData: User? = null // Lost on rotation!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Must reload data after every rotation
loadUserData()
}
}
The naive approach is to use onSaveInstanceState():
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelable("user", userData)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
userData = savedInstanceState?.getParcelable("user")
}
This works for small, serializable data. But what about large datasets, network connections, or objects that can't be serialized? What about ongoing operations like network requests? The Bundle approach fails for these cases, both because of size limitations and because serialization/deserialization is expensive.
ViewModel solves this by providing a lifecycle-aware container that survives configuration changes through a retained object pattern, not serialization.
The ViewModelStore: The retention mechanism
At the heart of ViewModel's configuration-change survival is ViewModelStore, a simple key-value store that holds ViewModel instances:
public open class ViewModelStore {
private val map = mutableMapOf<String, ViewModel>()
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun put(key: String, viewModel: ViewModel) {
val oldViewModel = map.put(key, viewModel)
oldViewModel?.clear()
}
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public operator fun get(key: String): ViewModel? {
return map[key]
}
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun keys(): Set<String> {
return HashSet(map.keys)
}
public fun clear() {
for (vm in map.values) {
vm.clear()
}
map.clear()
}
}
The implementation is remarkably straightforward, just a MutableMap<String, ViewModel>. The magic isn't in the store itself, it's in how the store is retained.
Key replacement behavior
Notice the put method's behavior:
public fun put(key: String, viewModel: ViewModel) {
val oldViewModel = map.put(key, viewModel)
oldViewModel?.clear()
}
If a ViewModel already exists with the same key, the old ViewModel is immediately cleared. This ensures proper cleanup when a ViewModel is replaced. You might wonder when this happens, it occurs when you request a ViewModel with the same key but a different type:
// First request creates TestViewModel1 with key "my_key"
val vm1: TestViewModel1 = viewModelProvider["my_key", TestViewModel1::class]
// Second request with same key but different type
val vm2: TestViewModel2 = viewModelProvider["my_key", TestViewModel2::class]
// vm1.onCleared() has been called, vm1 is no longer valid
This behavior is validated in the test suite:
@Test
fun twoViewModelsWithSameKey() {
val key = "the_key"
val vm1 = viewModelProvider[key, TestViewModel1::class]
assertThat(vm1.cleared).isFalse()
val vw2 = viewModelProvider[key, TestViewModel2::class]
assertThat(vw2).isNotNull()
assertThat(vm1.cleared).isTrue()
}
The ViewModelStoreOwner contract
The ViewModelStoreOwner interface defines who owns the store:
public interface ViewModelStoreOwner {
public val viewModelStore: ViewModelStore
}
This simple interface is implemented by ComponentActivity, Fragment, and NavBackStackEntry. The owner's responsibility is twofold:
- Retain the store across configuration changes: The store must survive Activity recreation
- Clear the store when truly finished: When the owner is destroyed without recreation, call
ViewModelStore.clear()
For Activities, this is typically implemented using NonConfigurationInstances, a special mechanism that allows objects to survive configuration changes. The Activity framework retains these objects during onRetainNonConfigurationInstance() and restores them in getLastNonConfigurationInstance().
Why a simple map works
You might expect a sophisticated caching mechanism, but a simple MutableMap is sufficient because:
- Bounded size: The number of ViewModels per screen is small (typically 1-5)
- String keys: Keys are generated from class names, making lookup O(1) with good hash distribution
- No eviction needed: ViewModels are cleared only when explicitly requested or when the owner is destroyed
- Thread safety: Access is synchronized at the ViewModelProvider level
ViewModelProvider: The orchestration layer
ViewModelProvider is the primary API for obtaining ViewModel instances. It orchestrates the interaction between the store, factory, and creation extras:
public actual open class ViewModelProvider
private constructor(private val impl: ViewModelProviderImpl) {
public constructor(
store: ViewModelStore,
factory: Factory,
defaultCreationExtras: CreationExtras = CreationExtras.Empty,
) : this(ViewModelProviderImpl(store, factory, defaultCreationExtras))
public constructor(
owner: ViewModelStoreOwner
) : this(
store = owner.viewModelStore,
factory = ViewModelProviders.getDefaultFactory(owner),
defaultCreationExtras = ViewModelProviders.getDefaultCreationExtras(owner),
)
@MainThread
public actual operator fun <T : ViewModel> get(modelClass: KClass<T>): T =
impl.getViewModel(modelClass)
@MainThread
public actual operator fun <T : ViewModel> get(key: String, modelClass: KClass<T>): T =
impl.getViewModel(modelClass, key)
}
The multiplatform abstraction
Notice the ViewModelProviderImpl delegation. The ViewModel library is a Kotlin Multiplatform library, supporting JVM, Android, iOS, and other platforms. Kotlin Multiplatform doesn't yet support expect classes with default implementations, so the common logic is extracted to internal implementation classes:
internal class ViewModelProviderImpl(
private val store: ViewModelStore,
private val factory: ViewModelProvider.Factory,
private val defaultExtras: CreationExtras,
) {
private val lock = SynchronizedObject()
@Suppress("UNCHECKED_CAST")
internal fun <T : ViewModel> getViewModel(
modelClass: KClass<T>,
key: String = ViewModelProviders.getDefaultKey(modelClass),
): T {
return synchronized(lock) {
val viewModel = store[key]
if (modelClass.isInstance(viewModel)) {
if (factory is ViewModelProvider.OnRequeryFactory) {
factory.onRequery(viewModel!!)
}
return@synchronized viewModel as T
}
val modelExtras = MutableCreationExtras(defaultExtras)
modelExtras[ViewModelProvider.VIEW_MODEL_KEY] = key
return@synchronized createViewModel(factory, modelClass, modelExtras).also { vm ->
store.put(key, vm)
}
}
}
}
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor