Blog Infos
Author
Published
Topics
, , , ,
Published

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 ComponentActivityFragment, and NavBackStackEntry. The owner’s responsibility is twofold:

  1. Retain the store across configuration changes: The store must survive Activity recreation.
  2. 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:

  1. Bounded size: The number of ViewModels per screen is small (typically 1–5).
  2. String keys: Keys are generated from class names, making lookup O(1) with good hash distribution.
  3. No eviction needed: ViewModels are cleared only when explicitly requested or when the owner is destroyed.
  4. 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:

  1. Generate key: Default key is based on the class’s canonical name.
  2. Check cache: Look up existing ViewModel by key.
  3. Type check: Verify the cached instance is the correct type.
  4. Return cached: If valid, return the cached instance.
  5. Create new: If not found or wrong type, create via factory.
  6. 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)
}
view raw Factory.kt hosted with ❤ by GitHub
The method overload chain

The factory interface has three create method variants, forming a chain:

  1. create(KClass<T>, CreationExtras): The Kotlin-first API, delegates to Java variant
  2. create(Class<T>, CreationExtras): The primary implementation point, defaults to no-extras variant
  3. create(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> {}
view raw KeyFunction.kt hosted with ❤ by GitHub
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)
}
view raw AddCloseable.kt hosted with ❤ by GitHub

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:

  1. ViewModelStore cache: The canonical cache that survives configuration changes.
  2. 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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Sharing code across platforms is a wonderful superpower. But sometimes, sharing 100% of your codebase isn’t the goal. Maybe you’re migrating existing apps to multiplatform, maybe you have platform-specific libraries or APIs you want to…
Watch Video

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Developer

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform ...

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Deve ...

Jobs

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

Menu