Unsplash@davidvig
Jetpack’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 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 the ViewModel lifecycle.
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) | |
| } | |
| } | |
| } | |
| } |
The get-or-create pattern
The getViewModel method implements a classic get-or-create pattern:
- Generate key: Default key is based on the class’s canonical name.
- Check cache: Look up existing ViewModel by key.
- Type check: Verify the cached instance is the correct type.
- Return cached: If valid, return the cached instance.
- Create new: If not found or wrong type, create via factory.
- Store: Put the new instance in the store.
Thread safety with synchronized access
The synchronized(lock) block ensures thread-safe access. While ViewModel access is typically from the main thread (as indicated by @MainThread), the synchronization protects against edge cases where background threads might access ViewModels, particularly in testing scenarios or when using viewModelScope.
The OnRequeryFactory callback
The OnRequeryFactory is a special mechanism for factories that need to perform actions when a cached ViewModel is retrieved:
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) | |
| public actual open class OnRequeryFactory { | |
| public actual open fun onRequery(viewModel: ViewModel) {} | |
| } |
This is used internally by SavedStateHandle to reconnect the ViewModel with the current SavedStateRegistry after configuration changes. When a ViewModel is retrieved from cache, the factory’s onRequery method is called, allowing it to update references that might have changed.
Key generation from class names
The default key generation prevents accidental conflicts:
| internal fun <T : ViewModel> getDefaultKey(modelClass: KClass<T>): String { | |
| val canonicalName = | |
| requireNotNull(modelClass.canonicalName) { | |
| "Local and anonymous classes can not be ViewModels" | |
| } | |
| return "$VIEW_MODEL_PROVIDER_DEFAULT_KEY:$canonicalName" | |
| } |
The prefix "androidx.lifecycle.ViewModelProvider.DefaultKey:" ensures keys don’t collide with custom keys users might provide. The canonical name requirement also explains why local and anonymous classes can’t be ViewModels, they don’t have canonical names:
| @Test | |
| fun localViewModel() { | |
| class LocalViewModel : ViewModel() | |
| try { | |
| viewModelProvider[LocalViewModel::class] | |
| fail("Expected `IllegalArgumentException`") | |
| } catch (e: IllegalArgumentException) { | |
| assertThat(e) | |
| .hasMessageThat() | |
| .contains("Local and anonymous classes can not be ViewModels") | |
| } | |
| } |
The Factory pattern: Flexible instantiation
ViewModelProvider.Factory is the mechanism for creating ViewModel instances:
| public actual interface Factory { | |
| public fun <T : ViewModel> create(modelClass: Class<T>): T = | |
| ViewModelProviders.unsupportedCreateViewModel() | |
| public fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T = | |
| create(modelClass) | |
| public actual fun <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T = | |
| create(modelClass.java, extras) | |
| } |
The method overload chain
The factory interface has three create method variants, forming a chain:
create(KClass<T>, CreationExtras): The Kotlin-first API, delegates to Java variantcreate(Class<T>, CreationExtras): The primary implementation point, defaults to no-extras variantcreate(Class<T>): Legacy API, throws by default
This chain allows backwards compatibility while encouraging the modern CreationExtras-based approach.
NewInstanceFactory: Reflection-based creation
The simplest factory uses reflection to create ViewModels with no-arg constructors:
| public open class NewInstanceFactory : Factory { | |
| public override fun <T : ViewModel> create(modelClass: Class<T>): T = | |
| JvmViewModelProviders.createViewModel(modelClass) | |
| } |
The JVM implementation uses reflection with careful error handling:
| internal object JvmViewModelProviders { | |
| fun <T : ViewModel> createViewModel(modelClass: Class<T>): T { | |
| val constructor = | |
| try { | |
| modelClass.getDeclaredConstructor() | |
| } catch (e: NoSuchMethodException) { | |
| throw RuntimeException("Cannot create an instance of $modelClass", e) | |
| } | |
| // Enforce public constructor for consistent behavior | |
| if (!Modifier.isPublic(constructor.modifiers)) { | |
| throw RuntimeException("Cannot create an instance of $modelClass") | |
| } | |
| return try { | |
| constructor.newInstance() | |
| } catch (e: InstantiationException) { | |
| throw RuntimeException("Cannot create an instance of $modelClass", e) | |
| } catch (e: IllegalAccessException) { | |
| throw RuntimeException("Cannot create an instance of $modelClass", e) | |
| } | |
| } | |
| } |
The public modifier check is important for test consistency. In instrumentation tests, R8 may strip access modifiers, making private constructors accessible. JVM tests enforce access restrictions strictly. This explicit check ensures consistent behavior across test environments.
AndroidViewModelFactory: Application-aware creation
AndroidViewModelFactory extends NewInstanceFactory to support AndroidViewModel, which requires an Application parameter. This factory maintains backwards compatibility while embracing the modern CreationExtras approach:
| public open class AndroidViewModelFactory | |
| private constructor( | |
| private val application: Application?, | |
| @Suppress("UNUSED_PARAMETER") unused: Int, | |
| ) : NewInstanceFactory() { | |
| override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T { | |
| return if (application != null) { | |
| create(modelClass) | |
| } else { | |
| val application = extras[APPLICATION_KEY] | |
| if (application != null) { | |
| create(modelClass, application) | |
| } else { | |
| if (AndroidViewModel::class.java.isAssignableFrom(modelClass)) { | |
| throw IllegalArgumentException( | |
| "CreationExtras must have an application by `APPLICATION_KEY`" | |
| ) | |
| } | |
| super.create(modelClass) | |
| } | |
| } | |
| } | |
| private fun <T : ViewModel> create(modelClass: Class<T>, app: Application): T { | |
| return if (AndroidViewModel::class.java.isAssignableFrom(modelClass)) { | |
| try { | |
| modelClass.getConstructor(Application::class.java).newInstance(app) | |
| } catch (e: NoSuchMethodException) { | |
| throw RuntimeException("Cannot create an instance of $modelClass", e) | |
| } | |
| } else super.create(modelClass) | |
| } | |
| } |
The factory implements a cascading resolution strategy: first checking if an Application was passed to the constructor (legacy approach), then looking in CreationExtras via APPLICATION_KEY (modern stateless approach), and finally falling back to NewInstanceFactory for regular ViewModels or throwing if an AndroidViewModel has no Application available.
The unused: Int parameter in the private constructor is a clever trick to differentiate between overloaded constructors, since the compiler couldn’t otherwise distinguish constructor() from constructor(application: Application?) when called with null.
The instantiation uses reflection (modelClass.getConstructor(Application::class.java).newInstance(app)), which means your AndroidViewModel subclass must have a constructor accepting exactly one Application parameter. For additional dependencies, you’ll need a custom factory or dependency injection frameworks like Hilt.
InitializerViewModelFactory: Lambda-based creation
For ViewModels with custom dependencies, InitializerViewModelFactory provides a DSL-based approach that reduces boilerplate:
| val factory = viewModelFactory { | |
| initializer { MyViewModel(get(MY_KEY)) } | |
| initializer { AnotherViewModel(get(ANOTHER_KEY)) } | |
| } |
The DSL is powered by a builder class that collects initializer lambdas, where each initializer pairs a KClass with its creation lambda:
| @ViewModelFactoryDsl | |
| public class InitializerViewModelFactoryBuilder public constructor() { | |
| private val initializers = mutableMapOf<KClass<*>, ViewModelInitializer<*>>() | |
| public fun <T : ViewModel> addInitializer( | |
| clazz: KClass<T>, | |
| initializer: CreationExtras.() -> T, | |
| ) { | |
| require(clazz !in initializers) { | |
| "A `initializer` with the same `clazz` has already been added: ${clazz.canonicalName}." | |
| } | |
| initializers[clazz] = ViewModelInitializer(clazz, initializer) | |
| } | |
| public fun build(): ViewModelProvider.Factory = | |
| ViewModelProviders.createInitializerFactory(initializers.values) | |
| } |
The @ViewModelFactoryDsl annotation is a DSL marker that prevents nested builder scopes from accidentally accessing outer scope methods. The initializer lambda receives CreationExtras as its receiver, allowing direct access to extras via get().
At creation time, the factory performs a linear search through initializers to find a matching class:
| internal fun <VM : ViewModel> createViewModelFromInitializers( | |
| modelClass: KClass<VM>, | |
| extras: CreationExtras, | |
| vararg initializers: ViewModelInitializer<*>, | |
| ): VM { | |
| val viewModel = | |
| initializers.firstOrNull { it.clazz == modelClass }?.initializer?.invoke(extras) as VM? | |
| return requireNotNull(viewModel) { | |
| "No initializer set for given class ${modelClass.canonicalName}" | |
| } | |
| } |
The linear search is acceptable because the number of ViewModels per factory is typically small (1–5) and ViewModel creation happens infrequently (once per lifecycle).
CreationExtras: Stateless factory configuration
CreationExtras is a type-safe, key-value container for passing configuration to factories without making them stateful. Instead of factories holding dependencies via constructor injection, dependencies are passed at creation time:
| public abstract class CreationExtras internal constructor() { | |
| internal val extras: MutableMap<Key<*>, Any?> = mutableMapOf() | |
| public interface Key<T> | |
| public abstract operator fun <T> get(key: Key<T>): T? | |
| public object Empty : CreationExtras() { | |
| override fun <T> get(key: Key<T>): T? = null | |
| } | |
| } |
Type-safe keys
Each key is parameterized by the value type it’s associated with, providing compile-time type safety. When you retrieve a value with extras[APPLICATION_KEY], the return type is automatically Application?:
| val APPLICATION_KEY: Key<Application> = CreationExtras.Companion.Key() | |
| val VIEW_MODEL_KEY: Key<String> = CreationExtras.Companion.Key() |
The key creation uses an inline function that creates a new anonymous object implementing the Key interface. Since each key is a unique object instance, keys are compared by identity (===), ensuring no accidental collisions even if two keys have the same type parameter:
| @JvmStatic | |
| public inline fun <reified T> Key(): Key<T> = object : Key<T> {} |
MutableCreationExtras for modification
The base CreationExtras class is read-only, while MutableCreationExtras allows adding entries. This separation follows Kotlin’s collection design philosophy (like List vs MutableList), preventing factories from accidentally modifying shared extras:
| public class MutableCreationExtras | |
| public constructor(initialExtras: CreationExtras = Empty) : CreationExtras() { | |
| init { | |
| extras += initialExtras.extras | |
| } | |
| public operator fun <T> set(key: Key<T>, t: T) { | |
| extras[key] = t | |
| } | |
| @Suppress("UNCHECKED_CAST") | |
| public override fun <T> get(key: Key<T>): T? = extras[key] as T? | |
| } |
Integration with ViewModelProvider
The ViewModelProvider automatically adds the ViewModel’s key to extras before calling the factory, which is essential for features like SavedStateHandle that need the key to scope persistence correctly:
| val modelExtras = MutableCreationExtras(defaultExtras) | |
| modelExtras[ViewModelProvider.VIEW_MODEL_KEY] = key | |
| return@synchronized createViewModel(factory, modelClass, modelExtras) |
HasDefaultViewModelProviderFactory
ViewModelStoreOwner implementations can provide default factory and extras through the HasDefaultViewModelProviderFactory interface. ComponentActivity and Fragment both implement this, providing factories that support SavedStateHandle with APPLICATION_KEY pre-populated:
| public interface HasDefaultViewModelProviderFactory { | |
| public val defaultViewModelProviderFactory: ViewModelProvider.Factory | |
| public val defaultViewModelCreationExtras: CreationExtras | |
| get() = CreationExtras.Empty | |
| } |
ComponentActivity and Fragment both implement this interface, providing factories that support SavedStateHandle and proper CreationExtras with APPLICATION_KEY pre-populated.
When creating a ViewModelProvider from a ViewModelStoreOwner, these defaults are automatically used:
| internal fun getDefaultFactory(owner: ViewModelStoreOwner): ViewModelProvider.Factory = | |
| if (owner is HasDefaultViewModelProviderFactory) { | |
| owner.defaultViewModelProviderFactory | |
| } else { | |
| DefaultViewModelProviderFactory | |
| } | |
| internal fun getDefaultCreationExtras(owner: ViewModelStoreOwner): CreationExtras = | |
| if (owner is HasDefaultViewModelProviderFactory) { | |
| owner.defaultViewModelCreationExtras | |
| } else { | |
| CreationExtras.Empty | |
| } |
This pattern enables progressive enhancement: simple ViewModels work with default factories, while complex ViewModels can receive rich configuration through extras without changing how you obtain the ViewModelProvider.
The ViewModel class: Resource management
The ViewModel class manages resource lifecycle through the AutoCloseable pattern. The expect keyword indicates this is a Kotlin Multiplatform declaration, where each platform provides its own actual implementation:
| public expect abstract class ViewModel { | |
| public constructor() | |
| public constructor(viewModelScope: CoroutineScope) | |
| public constructor(vararg closeables: AutoCloseable) | |
| public constructor(viewModelScope: CoroutineScope, vararg closeables: AutoCloseable) | |
| protected open fun onCleared() | |
| @MainThread internal fun clear() | |
| public fun addCloseable(key: String, closeable: AutoCloseable) | |
| public open fun addCloseable(closeable: AutoCloseable) | |
| public fun <T : AutoCloseable> getCloseable(key: String): T? | |
| } |
The ViewModelImpl internal implementation
The actual implementation is delegated to ViewModelImpl, following the multiplatform pattern where common logic is extracted to internal classes. The @Volatile annotation on isCleared ensures visibility across threads, which is crucial for the post-clear protection mechanism:
| internal class ViewModelImpl { | |
| private val lock = SynchronizedObject() | |
| private val keyToCloseables = mutableMapOf<String, AutoCloseable>() | |
| private val closeables = mutableSetOf<AutoCloseable>() | |
| @Volatile private var isCleared = false | |
| constructor() | |
| constructor(viewModelScope: CoroutineScope) { | |
| addCloseable(VIEW_MODEL_SCOPE_KEY, viewModelScope.asCloseable()) | |
| } | |
| constructor(vararg closeables: AutoCloseable) { | |
| this.closeables += closeables | |
| } | |
| constructor(viewModelScope: CoroutineScope, vararg closeables: AutoCloseable) { | |
| addCloseable(VIEW_MODEL_SCOPE_KEY, viewModelScope.asCloseable()) | |
| this.closeables += closeables | |
| } | |
| } |
Two-tier closeable storage
The implementation maintains two collections: keyToCloseables for resources that need retrieval after registration (like viewModelScope), and closeables for fire-and-forget cleanup. Using a Set for anonymous closeables prevents duplicate registration from causing double-closing.
The clearing sequence
When a ViewModel is cleared, resources are cleaned up in a specific order. The isCleared = true flag is set before the synchronized block to prevent concurrent addCloseable calls from adding resources that would be missed:
| @MainThread | |
| fun clear() { | |
| if (isCleared) return | |
| isCleared = true | |
| synchronized(lock) { | |
| for (closeable in keyToCloseables.values) { | |
| closeWithRuntimeException(closeable) | |
| } | |
| for (closeable in closeables) { | |
| closeWithRuntimeException(closeable) | |
| } | |
| // Clear only resources without keys to prevent accidental recreation | |
| closeables.clear() | |
| } | |
| } |
The anonymous closeables set is cleared, but keyToCloseables is intentionally kept. This prevents accidental recreation of resources like viewModelScope if code accesses it after clearing.
Post-clear protection
Adding closeables after clearing immediately closes them, preventing resource leaks in edge cases where coroutines are still running when the ViewModel is cleared:
| fun addCloseable(key: String, closeable: AutoCloseable) { | |
| if (isCleared) { | |
| closeWithRuntimeException(closeable) | |
| return | |
| } | |
| val oldCloseable = synchronized(lock) { keyToCloseables.put(key, closeable) } | |
| closeWithRuntimeException(oldCloseable) | |
| } |
Also, notice that when adding a keyed closeable, any existing closeable with the same key is automatically closed, enabling replacement patterns.
Nullable impl for mocking
The JVM implementation has a nullable impl to support mocking frameworks. When you mock a ViewModel, the mock doesn’t call the real constructor, so impl is never initialized. Without the nullable type and safe call (impl?.clear()), tests would crash with NullPointerException:
| public actual abstract class ViewModel { | |
| private val impl: ViewModelImpl? | |
| public actual constructor() { | |
| impl = ViewModelImpl() | |
| } | |
| @MainThread | |
| internal actual fun clear() { | |
| impl?.clear() | |
| onCleared() | |
| } | |
| } |
viewModelScope: Coroutine integration
The viewModelScope extension property provides a lifecycle-aware CoroutineScope that’s automatically cancelled when the ViewModel is cleared:
| public val ViewModel.viewModelScope: CoroutineScope | |
| get() = | |
| synchronized(VIEW_MODEL_SCOPE_LOCK) { | |
| getCloseable(VIEW_MODEL_SCOPE_KEY) | |
| ?: createViewModelScope().also { scope -> | |
| addCloseable(VIEW_MODEL_SCOPE_KEY, scope) | |
| } | |
| } |
Lazy creation with keyed storage
The scope is created lazily on first access and stored using the keyed closeable mechanism. The key is intentionally verbose to avoid collisions with user-defined keys:
| internal const val VIEW_MODEL_SCOPE_KEY = | |
| "androidx.lifecycle.viewmodel.internal.ViewModelCoroutineScope.JOB_KEY" |
CloseableCoroutineScope
The bridge between coroutines and the closeable system is CloseableCoroutineScope, which implements both CoroutineScope and AutoCloseable. When close() is called during ViewModel clearing, all running coroutines are cancelled:
| internal class CloseableCoroutineScope(override val coroutineContext: CoroutineContext) : | |
| AutoCloseable, CoroutineScope { | |
| constructor(coroutineScope: CoroutineScope) : this(coroutineScope.coroutineContext) | |
| override fun close() = coroutineContext.cancel() | |
| } |
Platform-aware dispatcher selection
As a Kotlin Multiplatform library, ViewModel works on platforms without a main thread concept by falling back to EmptyCoroutineContext. Notice the use of Dispatchers.Main.immediate rather than Dispatchers.Main, which avoids unnecessary redispatching when already on the main thread:
| internal fun createViewModelScope(): CloseableCoroutineScope { | |
| val dispatcher = | |
| try { | |
| Dispatchers.Main.immediate | |
| } catch (_: NotImplementedError) { | |
| // In Native environments where `Dispatchers.Main` might not exist (e.g., Linux) | |
| EmptyCoroutineContext | |
| } catch (_: IllegalStateException) { | |
| // In JVM Desktop environments where `Dispatchers.Main` might not exist (e.g., Swing) | |
| EmptyCoroutineContext | |
| } | |
| return CloseableCoroutineScope(coroutineContext = dispatcher + SupervisorJob()) | |
| } |
SupervisorJob for independent child failure
The scope uses SupervisorJob() which allows child coroutines to fail independently. With a regular Job, if one child fails it cancels all siblings. This design matches UI application expectations where a failing network request shouldn’t cancel an ongoing database operation.
ViewModelLazy: Kotlin property delegation
The viewModels() delegate uses ViewModelLazy:
| public class ViewModelLazy<VM : ViewModel> | |
| @JvmOverloads | |
| constructor( | |
| private val viewModelClass: KClass<VM>, | |
| private val storeProducer: () -> ViewModelStore, | |
| private val factoryProducer: () -> ViewModelProvider.Factory, | |
| private val extrasProducer: () -> CreationExtras = { CreationExtras.Empty }, | |
| ) : Lazy<VM> { | |
| private var cached: VM? = null | |
| override val value: VM | |
| get() { | |
| val viewModel = cached | |
| return if (viewModel == null) { | |
| val store = storeProducer() | |
| val factory = factoryProducer() | |
| val extras = extrasProducer() | |
| ViewModelProvider.create(store, factory, extras).get(viewModelClass).also { | |
| cached = it | |
| } | |
| } else { | |
| viewModel | |
| } | |
| } | |
| override fun isInitialized(): Boolean = cached != null | |
| } |
Double-caching architecture
The lazy delegate caches the ViewModel reference locally after first access. This is a double-caching pattern:
- ViewModelStore cache: The canonical cache that survives configuration changes.
- ViewModelLazy cache: A local cache to avoid repeated ViewModelProvider creation.
The local cache is an optimization, creating a ViewModelProvider and looking up the ViewModel is cheap, but caching avoids even that minimal overhead on subsequent accesses.
Lambda producers for deferred access
All three producers (store, factory, extras) are lambdas, allowing deferred evaluation:
| private val viewModel by viewModels { MyViewModelFactory() } | |
| // Factory is only created when viewModel is first accessed |
This is particularly important for fragments where the Activity might not be available during property initialization.
Android-specific: AGP desugaring compatibility
Android has a special compatibility layer for handling AGP (Android Gradle Plugin) desugaring issues that arise when mixing libraries compiled against different ViewModel versions:
| internal actual fun <VM : ViewModel> createViewModel( | |
| factory: ViewModelProvider.Factory, | |
| modelClass: KClass<VM>, | |
| extras: CreationExtras, | |
| ): VM { | |
| return try { | |
| factory.create(modelClass, extras) | |
| } catch (e: AbstractMethodError) { | |
| try { | |
| factory.create(modelClass.java, extras) | |
| } catch (e: AbstractMethodError) { | |
| factory.create(modelClass.java) | |
| } | |
| } | |
| } |
This cascade of try-catch blocks handles cases where factories compiled with older ViewModel versions lack newer create method variants. The AbstractMethodError occurs when the runtime calls a method that doesn’t exist in the compiled bytecode, typically when an old factory only has create(Class<T>) but the runtime expects create(Class<T>, CreationExtras). The defensive cascade gracefully degrades through method variants, ensuring ViewModel creation works regardless of library version mismatches.
Performance characteristics and design trade-offs
The ViewModel system makes several deliberate design trade-offs that balance correctness, flexibility, and performance.
Synchronization overhead
Both ViewModelProviderImpl and ViewModelImpl use synchronized blocks for thread-safe access:
| synchronized(lock) { | |
| val viewModel = store[key] | |
| // ... | |
| } |
This adds minimal overhead since ViewModel access typically happens on the main thread, the critical sections are short (simple map operations), and lock contention is rare. The design prioritizes correctness over micro-optimization, since a rare race condition would be far more costly than the imperceptible synchronization overhead.
Reflection cost
NewInstanceFactory uses Java reflection to dynamically instantiate ViewModel classes:
| modelClass.getDeclaredConstructor().newInstance() |
Reflection is slower than direct constructor calls due to runtime type checking and dynamic method resolution. However, this cost is negligible because ViewModels are created infrequently (once per lifecycle), and the flexibility of instantiating any ViewModel subclass without compile-time knowledge far outweighs the minor overhead.
Map-based storage
Using MutableMap for ViewModelStore provides O(1) lookup but O(n) clear since each ViewModel must be individually cleared:
| public fun clear() { | |
| for (vm in map.values) { | |
| vm.clear() | |
| } | |
| map.clear() | |
| } |
Since ViewModelStores typically hold only 1–5 ViewModels, the linear-time clear has no practical impact. The simplicity of using a standard HashMap makes the code easier to understand and maintain, providing more value than theoretical optimizations for this bounded use case.
Nullable impl for mockability
The nullable impl in JVM ViewModel adds a null check on every operation:
| impl?.clear() |
This microscopic overhead enables proper mocking behavior. When mocking frameworks create ViewModel mocks, they bypass the real constructor, leaving impl uninitialized. The nullable type prevents NullPointerException in tests, a worthwhile trade-off.
Real-world patterns: Understanding ViewModel usage
Understanding the internals helps you recognize patterns and anti-patterns in real-world usage.
Pattern 1: Scoped ViewModel sharing
ViewModels can be shared across fragments by using the Activity’s ViewModelStore instead of each Fragment’s own store:
| class FragmentA : Fragment() { | |
| private val sharedViewModel: SharedViewModel by activityViewModels() | |
| } | |
| class FragmentB : Fragment() { | |
| private val sharedViewModel: SharedViewModel by activityViewModels() | |
| } |
Both fragments get the same ViewModel instance because they use the same ViewModelStore (the Activity’s). This enables sibling fragment communication through shared state. The internal mechanism:
| // activityViewModels() uses: | |
| ViewModelProvider( | |
| store = requireActivity().viewModelStore, // Same store for both fragments | |
| factory = ..., | |
| ) |
Pattern 2: Custom keys for multiple instances
You can have multiple instances of the same ViewModel class by providing custom keys:
| val viewModel1: MyViewModel = viewModelProvider["user_1", MyViewModel::class] | |
| val viewModel2: MyViewModel = viewModelProvider["user_2", MyViewModel::class] |
The key includes the custom string, creating separate cache entries in the ViewModelStore. This is useful for scenarios like managing multiple user profiles or chat conversations simultaneously.
Pattern 3: Factory injection with CreationExtras
Modern factories are stateless, receiving all dependencies via CreationExtras at creation time:
| class MyViewModelFactory : ViewModelProvider.Factory { | |
| override fun <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T { | |
| val repository = extras[REPOSITORY_KEY]!! | |
| val userId = extras[USER_ID_KEY]!! | |
| return MyViewModel(repository, userId) as T | |
| } | |
| } |
This allows the factory to be a singleton while the configuration varies per-call, avoiding the need to create a new factory instance for each ViewModel with different dependencies.
Anti-pattern: Accessing Context in ViewModel
| // Bad: Leaks Activity context | |
| class MyViewModel(private val context: Context) : ViewModel() | |
| // Good: Use Application context via AndroidViewModel | |
| class MyViewModel(application: Application) : AndroidViewModel(application) | |
| // Better: Don't hold Context at all, pass it to methods | |
| class MyViewModel : ViewModel() { | |
| fun loadData(context: Context) { ... } | |
| } |
The first approach can leak the Activity because the ViewModel survives configuration changes while holding a reference to the destroyed Activity. Use AndroidViewModel for Application context, or pass Context to individual methods when needed.
Anti-pattern: Blocking in onCleared
Did you know that onCleared() is called on the main thread? Blocking operations like network shutdown or database cleanup should be offloaded to background threads using addCloseable with coroutines:
| // Bad: Blocking call in onCleared | |
| override fun onCleared() { | |
| networkClient.shutdown() // May block the main thread until completing the shutdown process! | |
| } | |
| // Good: Use addCloseable for managed cleanup | |
| init { | |
| addCloseable { | |
| scope.launch { networkClient.shutdown() } | |
| } | |
| } |
Job Offers
Conclusion
Jetpack ViewModel’s internal machinery reveals a carefully designed system for lifecycle-aware state management. The ViewModelStore provides simple but effective caching through a retained map, while ViewModelProvider orchestrates creation with thread-safe access and flexible factory support. CreationExtras enables stateless factories by externalizing configuration, and the AutoCloseable integration ensures proper resource cleanup.
The multiplatform architecture extracts common logic into internal implementation classes, allowing the library to support JVM, Android, iOS, and other platforms. The viewModelScope integration provides automatic coroutine cancellation through the closeable mechanism, with platform-aware dispatcher selection for environments without a main thread.
If you’re looking to stay sharp with the latest skills, news, technical articles, interview questions, and practical code tips, check out Dove Letter. And for a deeper dive into interview prep, don’t miss the ultimate Android interview guide: Manifest Android Interview.
This article was previously published on proandroiddev.com



