Skip to main content

AndroidX ViewModel Integration

viewcompose-viewmodel-androidx connects ViewCompose composition scopes to AndroidX ViewModelStoreOwner, ViewModelProvider, creation extras, and SavedStateHandle. Android hosts provide owner locals; navigation destinations and graph scopes override them so model lifetime follows declarative page ownership rather than always expanding to the Activity.

Artifact and stability​

dependencies {
implementation("com.viewcompose:viewcompose-viewmodel-androidx:0.1.0-alpha02")
}
  • Stability: Alpha. Owner, key, factory, and saved-state contracts are reviewed and tested; naming may still evolve between alphas.
  • Platform: Android library with a minimum SDK inherited from the repository Android policy.
  • UI Foundation and AndroidX Lifecycle 2.11 ViewModel/SavedState support are exposed transitively because their builder, owner, factory, creation-extra, ViewModel, and SavedStateHandle types appear in public APIs.
  • It does not create or clear host owners; ownership remains with Activity, Fragment, navigation, or a custom container.

Owner propagation​

Standard Android hosts install their nearest owner as LocalViewModelStoreOwner. Navigation renders install a destination owner by default; ProvideNavGraphOwner replaces it with the selected graph owner for that subtree. This is what allows page ViewModels to clear on pop while graph-scoped models survive across multiple destinations in one graph instance.

LocalViewModelStoreOwner.current is nullable for optional infrastructure. viewModel() requires an owner and reports a direct configuration error when none is installed. Custom hosts can use ProvideViewModelStoreOwner(owner) { ... }. Providing an owner never clears its store; the component that created it must clear at the intended terminal lifecycle boundary.

Delayed child sessions capture this local with their declaration context, avoiding accidental fallback to a different Activity owner when overlay or retained navigation content renders later.

/** Installs a custom store owner for a nested subtree. */
fun UiTreeBuilder.provideViewModelStoreOwnerSample(
owner: ViewModelStoreOwner,
): ProfileViewModel {
lateinit var model: ProfileViewModel
ProvideViewModelStoreOwner(owner) {
model = viewModel()
}
return model
}

Retained child scopes​

Use one ViewModelScopeProvider when a Pager page, tab, lazy item, overlay, or custom container needs a lifetime below its Activity or Fragment but longer than one visible render. The provider delegates child-store allocation and reference counting to Lifecycle 2.11 ViewModelStoreProvider; ViewCompose adds prepared-composition commit/rollback, stable-key namespacing, terminal no-resurrection, and idempotent lease closure.

/** Retains one profile subtree below a stable parent and child identity. */
fun UiTreeBuilder.retainedViewModelScopeSample(
parentOwner: ViewModelStoreOwner,
parentLifecycleOwner: LifecycleOwner,
): ProfileViewModel {
val provider = rememberViewModelScopeProvider(
key = "profile-pane-provider",
parentOwner = parentOwner,
lifecycleOwner = parentLifecycleOwner,
)
val profileOwner = rememberViewModelStoreOwner(
key = "primary-profile-pane",
provider = provider,
)
lateinit var model: ProfileViewModel
ProvideViewModelStoreOwner(profileOwner) {
model = viewModel()
}
return model
}

/** Sends the terminal signal only when the logical profile pane is permanently removed. */
fun removeRetainedProfileScope(provider: ViewModelScopeProvider) {
provider.clear("primary-profile-pane")
}

The APIs separate three roles while sharing one implementation core:

  1. rememberViewModelScopeProvider binds a stable provider key to the parent store and lifecycle. Normal removal of the final committed binding clears all children. Parent destruction preserves them for configuration recreation, while the parent's own store remains the finishing fallback.
  2. rememberViewModelStoreOwner transactionally acquires one child lease. A failed first candidate is cleared; aborting a candidate for an already committed child preserves the existing store. Forgetting the call releases only temporary use.
  3. Retained container engines call acquireOwner and close the returned ViewModelStoreOwnerLease directly. They call clear(key) exactly once for permanent logical removal and clearAll() for permanent provider disposal.

Provider and child keys must be non-null stable values owned by the application or container. Equal provider keys in one parent share state; equal child keys share only inside that provider. Position, mutable objects, and incrementing counters are invalid retained identities. Calling clear with active leases marks the child terminal and defers physical cleanup; new acquisition fails until all old leases close, after which the same key creates a fresh scope. close, clear, and clearAll are idempotent cleanup operations.

The default child Factory and CreationExtras come from the parent and are captured when the provider is created. Pass a SavedStateRegistryOwner to acquireOwner, or let rememberViewModelStoreOwner use the current combined owner, when scoped models need SavedStateHandle. Equal live scopes reject inconsistent saved-state or lifecycle boundaries instead of falling back to an Activity or process-global store.

Resolving a ViewModel​

class ProfileViewModel : ViewModel()

class SavedProfileViewModel(
val handle: SavedStateHandle,
val profileId: String,
) : ViewModel()

/** Resolves one instance from the owner installed by the current Android host. */
fun UiTreeBuilder.viewModelSample(): ProfileViewModel {
return viewModel()
}

/** Keeps two instances of the same class in one store under stable application keys. */
fun UiTreeBuilder.keyedViewModelSample(
owner: ViewModelStoreOwner,
): Pair<ProfileViewModel, ProfileViewModel> {
val primary = viewModel(
modelClass = ProfileViewModel::class,
key = "primary-profile",
owner = owner,
)
val comparison = viewModel(
modelClass = ProfileViewModel::class,
key = "comparison-profile",
owner = owner,
)
return primary to comparison
}

/** Creates a ViewModel with constructor dependencies and the owner's restored state handle. */
fun UiTreeBuilder.initializerViewModelSample(
owner: ViewModelStoreOwner,
): SavedProfileViewModel {
return viewModel(owner = owner) {
SavedProfileViewModel(
handle = createSavedStateHandle(),
profileId = "primary-profile",
)
}
}

/** Uses the initializer contract when the model class is selected at runtime. */
fun UiTreeBuilder.kClassInitializerViewModelSample(
owner: ViewModelStoreOwner,
): SavedProfileViewModel {
return viewModel(
modelClass = SavedProfileViewModel::class,
owner = owner,
) {
SavedProfileViewModel(
handle = createSavedStateHandle(),
profileId = "runtime-selected-profile",
)
}
}

Resolution follows AndroidX ViewModelProvider:

  1. use the explicit owner, otherwise LocalViewModelStoreOwner.current;
  2. use the explicit factory, otherwise the owner's default factory, otherwise NewInstanceFactory;
  3. use explicit creation extras, otherwise copy the owner's default extras, otherwise use empty extras;
  4. query the owner's store by explicit key or AndroidX's class-derived default key.

The initializer overloads accept either a reified type or a runtime KClass. Their CreationExtras.() -> VM callback receives the owner's default extras, so constructor dependencies and createSavedStateHandle() remain one creation operation. Existing entries ignore later initializer callbacks; a failed callback publishes no entry and can be retried.

Calls must run on the Android main thread during composition. The owner's ViewModelStore is the only ViewModel instance cache. Each executed call performs a bounded provider query, so clearing the store is observable on the next composition instead of returning a stale remembered model.

Keys and lookup identity​

A null key selects the default identity derived from the ViewModel class. Every non-null string is an explicit AndroidX key and is preserved byte-for-byte, including empty and whitespace-only strings. Supply a stable application key to keep multiple instances of the same type in one owner, as shown by the compiled keyedViewModelSample above.

Every executed call performs a fresh provider lookup. Changing owner or key can address a different entry; changing Factory, extras, or initializer does not force recreation when the addressed entry already exists. Requesting a different model class under one explicit key follows AndroidX replacement semantics and clears the previous model.

Do not use a changing object or call-order counter as a key. Navigation already supplies independent owners for distinct destination and graph instances; add application keys only for multiple models that intentionally share one store.

Factory and CreationExtras​

An explicit factory has priority over the owner's default. Explicit extras likewise have priority. When defaults are used, ViewCompose copies the owner's extras into MutableCreationExtras; it does not expose or mutate a potentially shared owner object.

Factories and extras affect initial creation, not existing store entries. If a model needs a SavedStateHandle, use an owner that implements AndroidX's saved-state factory/extras contract or use the initializer overload and createSavedStateHandle(). Constructor, initializer, and Factory failures propagate without publishing a partial model; model recoverable creation failures explicitly at the host boundary.

SavedStateHandle ownership​

class ProfileFiltersViewModel(
handle: SavedStateHandle,
) : ViewModel() {
val selectedFilter = handle.getMutableStateFlow("selected-filter", "all")
}

/** Gives one ViewModel sole write ownership of restored business state. */
fun UiTreeBuilder.savedStateViewModelSample(): ProfileFiltersViewModel {
return viewModel(key = "profile-filters") {
ProfileFiltersViewModel(createSavedStateHandle())
}
}

The ViewModel is the only writable owner of restored business state. Use its constructor with the owner's default saved-state Factory, or call createSavedStateHandle() inside a viewModel initializer. Expose getMutableStateFlow() or read-only domain operations from the ViewModel and observe them through the lifecycle integration. Do not create a second snapshot-state adapter or handle-only model for the same value.

Process-death restoration additionally requires a saved-state-aware owner, default factory, and creation extras. Activity, Fragment, navigation destination, and navigation graph owners provide that integration. A bare ViewModelStoreOwner with NewInstanceFactory cannot construct or persist the handle automatically.

UI-only state remains owned by ViewCompose rememberSaveable; it must not also be written through a SavedStateHandle. The removed savedStateHandle() and SavedStateHandleHolderViewModel APIs have no compatibility aliases. Migrate their stable key to the actual business ViewModel key and move each value into that model's handle before upgrading.

  • Navigation entries and graphs lease stores from the same ViewModelScopeProvider used by arbitrary retained subtrees; navigation owns identity, lifecycle, and terminal signals rather than a second store allocator.
  • A destination-scoped ViewModel survives recomposition, transition, temporary invisibility, and retained-tab switching, and configuration recreation below the same parent store, then clears when its entry permanently leaves all stacks.
  • A graph-scoped ViewModel survives destination changes inside that graph instance and clears after its final descendant leaves retained navigation state.
  • Pushing the same route twice creates separate destination owners.
  • Entering the same graph route again later creates a new graph owner and model store.
  • Activity-scoped models require an explicit Activity owner when destination content has overridden the current local.

These rules keep page state independent without requiring Activity or Fragment per destination.

Testing​

Phase 1 runs 21 focused resolution tests, compared with seven in the same test owner before this change: 14 additional contracts and a normalized threefold suite size (+200%). Phase 2 adds 20 scoped-owner contract tests, bringing the owning module to 44/44 passing tests with zero skips, failures, or errors. The new cases cover provider sharing and isolation, multiple leases, idempotent close, temporary absence, terminal clear, no resurrection, parent-store cleanup, Factory/extras/default arguments, inconsistent saved-state boundaries, composition commit and abort, configuration recreation, delayed-local capture, Pager/lazy/overlay reorder, and INITIALIZED/DESTROYED lifecycle diagnostics. Phase 3 additionally passes 151/151 Navigation Android tests and 21/21 aggregate-host cases. The navigation suite grew from 148 tests with three focused contracts for missing-owner failure, configuration-retained entry ViewModels, and prior-format state migration. Aggregate-host source coverage grew from 10 to 11 test methods and now distinguishes Activity ViewTree discovery from Fragment explicit-owner precedence.

Phase 4 replaces the removed helper guard with two SavedStateViewModelIntegrationTest contracts: the default Factory injects default arguments into a constructor handle, and an initializer-created handle plus mutable state flow survives a process-style new-owner/new-store restoration. The owning module now passes 45/45 tests with zero skips, failures, or errors; Navigation remains 151/151, Preview runner remains 12/12, and the migrated Demo compiles. Relative to Phase 3, the module suite has one net additional test because two restoration contracts replace one helper-only guard.

The aggregate Phase 4 acceptance command, ./gradlew qaQuick qaPreview -PviewComposeReleaseBaseRevision=8c79f2b4, also completed successfully: 2270 actionable tasks, with 237 executed and 2033 up-to-date. This confirms that the hard cut remains compatible with the repository-wide quick and preview gates. Because most aggregate tasks reused verified outputs, the clean focused runs above remain the absolute test-result evidence; the aggregate run is integration- gate evidence rather than a fresh performance comparison.

Phase 5 adds seven defect-pressure contracts: explicit-owner precedence over an unrelated local, nested-local restoration after failure, isolated SavedStateHandle namespaces, single initializer and provider registration per key, successive restoration without replay, removed-API runtime and source guards, and a one-store-allocator structural guard. The module grows from 45 to 52 tests, an absolute increase of seven and a normalized increase of 15.6%; all 52/52 pass with zero skips, failures, or errors.

The clean affected-layer rerun also passed 151/151 Navigation Android, 21/21 aggregate Android, and 52/52 Host Android tests. Together with the owning module, this is 276/276 with a normalized pass rate of 100% and zero skips, failures, or errors. This is broader contract and integration coverage, not evidence of a runtime-performance change.

Repository-wide qaQuick qaPreview acceptance then completed all 2,270 actionable tasks: 171 executed and 2,099 were up to date. Both gates completed at 100%. This reuses the clean focused outputs above and is integration-gate evidence rather than a performance comparison.

Two Debug journeys then passed on one Xiaomi MI 6 running Android 9/API 28. The navigation journey changed PID from 19002 to 19078 and preserved two stacks plus five independently seeded destination or graph namespaces, including every rememberSaveable and SavedStateHandle value. The ordinary Activity-root journey changed PID from 19210 to 19286 and restored the exact value 41. After PID normalization, both before/after status records had zero differences.

Conclusion: improved. Lookup, creation, general scoped ownership, navigation integration, host owner selection, regression deletion, and real process recreation now have direct evidence, while restored business state has one ViewModel owner instead of a framework holder. The device result is one Android 9 model and one Debug build; release-mode behavior, a broader Android/device matrix, memory retention, and runtime performance remain inconclusive for Phase 6.

Use a real ViewModelStore in unit tests, render the same call repeatedly, and clear the store during teardown. Saved-state-aware Robolectric or instrumented owners remain required for process- death SavedStateHandle tests.

The complete generated reference is available in the viewcompose-viewmodel-androidx API tree.

Compatibility notes​

The Lifecycle 2.11 baseline hard-cuts two Alpha behaviors. Only null selects the default key, so a caller that previously passed "" or whitespace as a default sentinel must pass null; blank keys now identify explicit entries. The resolved ViewModel is no longer remembered by composition, so a store clear becomes visible immediately. Initializer overloads replace ad hoc one-class factories for constructor dependencies. For a lifetime below the host, migrate custom child-store maps to one stable-keyed ViewModelScopeProvider; keep logical removal separate from temporary render absence. The owner and stable scope key—not composition call position—remain the authoritative lifetime boundary.