Changelog: CHANGELOG · Releases: GitHub Releases
AndroidViewModel is a ViewModel registry, module-composition, and DI layer.
It keeps the core service model independent from any single Android host:
ViewModelis the business base class.StateViewModel<State>manages immutable state and emits state/general listeners.ViewModelSpecdeclares how to build a ViewModel and whether it is shared bykey.ViewModelBindingis the scoped container used by Activity, Fragment, Compose, View, or plain classes.
Every functional unit can be a ViewModel: UI state, repositories, services, coordinators, or domain capabilities. Each managed parent object generation owns a stable dependency binding. Child modules are created only when a delegated property is accessed, remain alive for at least the parent's lifetime, and are released automatically.
Instance identity is the resolved ViewModel type plus its effective key. An unkeyed spec uses a private key owned by the current binding, so repeated resolution of the same type reuses one instance inside that binding while different bindings remain isolated. Use explicit keys for cross-binding sharing or multiple instances of the same type in one binding.
Install the bundled skill for AI coding agents before working with the library:
npx skills add https://github.com/lwj1994/android_view_model --skill android-view-modelImportant
The default path is always stable spec → by watchViewModel(spec) / by readViewModel(spec).
A spec may contain a key or tag and should still be passed through these APIs;
knowing cache identity is not a reason to bypass the spec.
- Keep specs stable and module-level. Use
by watchViewModel(spec)orby readViewModel(spec)in Compose, host classes, tests, and ViewModel-to-ViewModel dependencies; outside Compose, supply a binding lambda such as{ viewModelBinding }. watchandreadboth create or reuse an instance, establish lifecycle ownership, and observe handle disposal, including force-recycle. Onlywatchlistens to the ViewModel's ownnotifyListeners().- Prefer binding-managed modules over global singletons. A normal feature, service, repository, or coordinator should use an unkeyed spec with
aliveForever = false. - Cached APIs are advanced lookup-only escape hatches. They cannot create a missing instance and should not replace spec-based dependency resolution.
- Declare ViewModel properties with
bydelegates. Do not cache resolved instances withby lazy,remember { vm }, or another field; the delegate handles resolution after recycle.
Add JitPack to your root settings.gradle.kts.
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven {
url = uri("https://jitpack.io")
content {
includeGroup("com.github.lwj1994")
}
}
}
}Add the dependency in your app or library module.
dependencies {
implementation("com.github.lwj1994:android_view_model:0.7.2")
}Create a ViewModel and a spec.
import milu.viewmodel.StateViewModel
import milu.viewmodel.viewModelSpec
data class CounterState(val count: Int = 0)
class CounterViewModel : StateViewModel<CounterState>(
initialState = CounterState(),
equals = { a, b -> a == b },
) {
fun increment() {
setState(state.copy(count = state.count + 1))
}
}
val counterSpec = viewModelSpec {
CounterViewModel()
}key, tag, and aliveForever have separate jobs:
keyparticipates in identity. Use it for intentional cross-binding sharing or multiple same-type instances in one binding.tagis only a grouping/lookup label.aliveForeverskips automatic disposal when all ownership paths leave; explicitrecycleand the completeViewModel.reset()still force disposal.- Every
aliveForeverspec must have an explicit key, whether resolved by a root binding or another ViewModel. A missing or computed-null key throwsViewModelErrorbefore the builder runs, and the Store enforces the same invariant for internal factories.
Bind it to the host you are using.
// Compose
ViewModelBindingProvider(binding = rememberScreenViewModelBinding()) {
val counter by watchViewModel(counterSpec)
}
// Activity
val counter by watchViewModel(counterSpec) { viewModelBinding }
// Fragment view lifecycle
val counter by watchViewModel(counterSpec) { viewLifecycleViewModelBinding }
// Plain class
val scope = ViewModelBindingScope()
val counter by readViewModel(counterSpec) { scope.viewModelBinding }Business code declares val vm by watchViewModel(spec) or readViewModel(spec).
Compose subscribes to notifications or generation changes during composition.
Outside Compose, choose a fixed binding receiver or a deferred binding lambda;
ownership begins on first access.
Internally, the delegate's getValue() calls binding watch/read(spec) on every
access without caching the VM. After recycle, it resolves the current generation
without waiting for recomposition.
class PageViewModel : ViewModel() {
val draft by watchViewModel(draftSpec) { viewModelBinding }
}
@Composable
fun DraftScreen() {
val draft by watchViewModel(draftSpec)
TextField(value = draft.title, onValueChange = { draft.updateTitle(it) })
}Use { vm.action() } for event callbacks; vm::action immediately resolves and
captures the current VM. Do not store val cached = vm, use remember { vm }, or
pass delegates across owners. A delegate belongs to its declared stable spec and
ownership boundary; old callbacks must not outlive that owner. For bindings that
can change, such as Fragment views and Views, retrieve the current binding inside
the binding lambda. watchViewModelState and selectViewModelState still return
render values, not VMs. Binding watch/read implements delegate resolution;
cached APIs remain advanced queries only.
| API | Ownership and sharing | Release boundary |
|---|---|---|
rememberViewModelBinding() |
Independent binding per composition call site; reused across recompositions. | The call leaves the composition. |
rememberScreenViewModelBinding() |
Reuses the current ViewModelStoreOwner binding; callers under the same owner share it. |
The owner's ViewModelStore is cleared. |
A screen owner is usually the destination's NavBackStackEntry in Navigation
Compose, or an Activity/Fragment outside navigation. It is not necessarily the
root navigation graph. Screen bindings survive configuration changes and can
outlive a composable that disappears; they do not restore instances after process
death. Without a ViewModelStoreOwner, the screen API falls back to a local binding.
ViewModelBindingProvider() creates a local binding by default. Descendant
watchViewModel/readViewModel consumers share that provider's binding. Without a
provider, each consumer call site uses a local binding. Opt into screen ownership
explicitly:
ViewModelBindingProvider(binding = rememberScreenViewModelBinding()) {
ScreenContent()
}Use the local default for dialogs and independent UI features that should release
ownership on exit. Use a screen binding when the owner should retain and share
instances beyond a particular composition. Under the same screen binding, unkeyed
specs of the same VM type reuse an instance. The screen API reads
LocalViewModelStoreOwner, not an enclosing ViewModelBindingProvider.
Use a receiver when the binding already exists and stays the same for the lifetime of the delegate:
val counter by binding.readViewModel(counterSpec)
val observedCounter by binding.watchViewModel(counterSpec)The receiver is captured when the delegate is declared. Each property access
still resolves the current VM generation, so recycle works normally. Reassigning
a variable named binding does not retarget an existing delegate; accessing it
after its captured binding is disposed fails.
Use a lambda when binding lookup must be deferred or the binding can change:
val counter by readViewModel(counterSpec) { viewLifecycleViewModelBinding }This looks up the binding on each property access. Keep this form for Activity or Fragment properties initialized before their host binding is available, Fragment view lifecycles, reattached Views, test fields whose binding is assigned in setup, and nested VMs whose dependency binding should be created lazily. Both forms resolve the same instances when they use the same binding and spec; neither caches a VM.
Compose uses the top-level composable functions, including when a local binding is already available:
val binding = rememberViewModelBinding()
val counter by readViewModel(counterSpec, binding = binding)binding.readViewModel(spec) and binding.watchViewModel(spec) do not establish
Compose recomposition subscriptions. The top-level read function observes
generation disposal; the top-level watch function also observes VM notifications.
Do not replace these composable calls with receiver extensions for UI access.
Do not pass a resolved ViewModel or StateViewModel instance across
components, layers, hosts, bindings, or owner boundaries. The stable spec is
the shareable declaration; the resolved instance belongs to the binding graph
and generation that resolved it.
Passing the instance does not register a new owner path. The receiver can retain
a disposed generation after recycle, outlive the binding that owns it, or keep
the instance alive outside its intended lifecycle. Instead:
- pass the stable spec and let each consumer resolve it through its own binding;
- use
by-delegated properties for ViewModel-to-ViewModel dependencies; - pass immutable render values and event callbacks across UI boundaries.
An explicit key allows consumers to resolve the same managed instance; it is not permission to transport that instance between owners.
The business milu.viewmodel.ViewModel intentionally does not extend AndroidX ViewModel.
AndroidX ViewModel is scoped to one ViewModelStoreOwner. This library needs a different lifecycle model: a keyed instance may be shared across multiple Activities, Fragments, Views, Compose scopes, and plain classes, and is disposed when the last ViewModelBinding releases its reference.
AndroidX is still used at the host layer. ViewModelStoreOwner.viewModelBinding stores an internal AndroidX ViewModel whose only job is to retain and clear the ViewModelBinding.
JitPack is the recommended integration path. If you want Gradle to clone and build the GitHub source directly, use Gradle source dependencies instead.
Gradle will clone the GitHub repository, check out the requested branch or tag, and build :android-view-model locally.
In your app's settings.gradle.kts:
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
sourceControl {
gitRepository(uri("https://github.com/lwj1994/android_view_model.git")) {
producesModule("android_view_model:android-view-model")
}
}In your app module's build.gradle.kts:
dependencies {
implementation("android_view_model:android-view-model") {
version {
branch = "main"
}
}
}For a stable dependency, prefer a Git tag once one exists:
dependencies {
implementation("android_view_model:android-view-model:0.7.2")
}When using Gradle source dependencies for Android builds, set ANDROID_HOME or ANDROID_SDK_ROOT. A root local.properties file is not visible to the Git checkout that Gradle builds as the dependency.
This avoids Maven for this library itself. google() and mavenCentral() are still required for Android Gradle Plugin, Kotlin, AndroidX, and Compose dependencies.
data class CounterState(val count: Int = 0)
class CounterViewModel : StateViewModel<CounterState>(
initialState = CounterState(),
equals = { a, b -> a == b },
) {
fun increment() {
setState(state.copy(count = state.count + 1))
}
}
val counterSpec = viewModelSpec {
CounterViewModel()
}@Composable
fun CounterScreen() {
ViewModelBindingProvider(binding = rememberScreenViewModelBinding()) {
val count = selectViewModelState(
factory = counterSpec,
selector = { it.count },
)
val counter by readViewModel(counterSpec)
Button(onClick = { counter.increment() }) {
Text("$count")
}
}
}Use watchViewModel(spec) for broad ViewModel notifications,
readViewModel(spec) for lifecycle-bound access without broad observation, and
selectViewModelState(spec, selector, equals?) for typed fine-grained state
observation. These APIs observe handle disposal; after recycle, Compose
re-resolves the spec and stops returning the disposed generation.
watchViewModel invalidates the composable scope that calls it. Observation is
not carried by the returned ViewModel reference. With Compose strong skipping,
a child that receives the same ViewModel instance may be skipped.
The resolved-instance ownership rule is strict at composable boundaries:
never pass a ViewModel or StateViewModel instance as a child composable
parameter. A composable boundary accepts immutable render values and event
callbacks, not a ViewModel:
@Composable
fun DraftRoute() {
val draft by watchViewModel(draftSpec)
DraftContent(draft) // Forbidden: observation does not cross this boundary.
}Resolve and observe the stable spec in the consuming composable, or read render values in the watching scope and pass those values to the child:
@Composable
fun DraftRoute() {
val draft by watchViewModel(draftSpec)
DraftContent(
title = draft.title,
onTitleChanged = { draft.updateTitle(it) },
)
}Values passed across a composable boundary must be immutable values or value snapshots. Passing the same mutable object reference preserves the same strong-skipping problem.
class MainActivity : FragmentActivity() {
private val counter: CounterViewModel by watchViewModel(counterSpec) { viewModelBinding }
}
class CounterFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val counter by watchViewModel(counterSpec) { viewLifecycleViewModelBinding }
}
}class CounterPanelView(context: Context) : LinearLayout(context) {
override fun onAttachedToWindow() {
super.onAttachedToWindow()
val counter by watchViewModel(counterSpec) { viewModelBinding }
}
}class CounterController : AutoCloseable {
private val scope = ViewModelBindingScope()
private val counter: CounterViewModel by readViewModel(counterSpec) { scope.viewModelBinding }
fun increment() = counter.increment()
override fun close() {
scope.close()
}
}A common flow has page A displaying a draft and page B editing it. Both pages
should resolve the same instance while either page remains in the navigation
scope. Use one stable parameterized spec with an explicit key; do not use
aliveForever = true merely to share across pages:
class DraftViewModel(
val documentId: String,
) : ViewModel() {
var title: String = ""
private set
fun updateTitle(value: String) = update {
title = value
}
}
val draftViewModelSpec = viewModelSpecWithArg<DraftViewModel, String>(
builder = ::DraftViewModel,
key = { documentId -> "draft:$documentId" },
)
@Composable
fun PageA(documentId: String) {
ViewModelBindingProvider(binding = rememberScreenViewModelBinding()) {
val draft by watchViewModel(draftViewModelSpec(documentId))
Text(draft.title)
}
}
@Composable
fun PageB(documentId: String) {
ViewModelBindingProvider(binding = rememberScreenViewModelBinding()) {
val draft by watchViewModel(draftViewModelSpec(documentId))
TextField(
value = draft.title,
onValueChange = { draft.updateTitle(it) },
)
}
}- A and B share one instance because the resolved ViewModel type and key are equal. They should both resolve the spec normally; cached lookup is not needed.
watchandreadboth establish ownership. Usewatchfor a page that must react to notifications andreadwhen it only invokes methods.- The lifetime is the union of all participating page bindings. Closing B releases only B; A keeps the instance alive. The final page leaving disposes it automatically.
- Include the document/session ID in the key when several edit flows may coexist. A key defines identity; it does not retain the instance forever.
- In Fragment navigation, use each Fragment's
viewModelBindingfor this destination lifetime. UseviewLifecycleViewModelBindingonly when ownership should end with the Fragment view, andactivityViewModelBindingonly for an intentionally Activity-wide scope.
The keyed sharing above is process-local. Separate Android processes have different ART heaps, registries, coroutine scopes, listeners, and ViewModel instances. Matching ViewModel types and keys cannot share an object across processes.
ProcessStateStore instead synchronizes versioned state snapshots:
main process ViewModel ─┐
├── ContentProvider / Binder ── state-store process
remote process ViewModel ┘
For Android IPC, make the state Parcelable—normally with @Parcelize—and use
the constrained ParcelableProcessStateStore boundary:
@Parcelize
data class ProcessCounterState(
val count: Int = 0,
) : ParcelableProcessStateStoresynchronizes state only. It never shares a ViewModel instance, lifecycle, coroutine, or listener across processes.- A real implementation must supply the IPC backend. Its
observe()flow must register for changes before reading the current record, emit that current record when present, then emit every accepted change without a read/observe gap. - Records carry
versionandsourceIdso concurrent writers can reject older snapshots and avoid echoing an applied remote state. - Parcelable is an IPC transport format, not a durable disk schema. If state must survive termination of the state-store process, persist it separately with an explicitly versioned format.
- Keep an app-private Provider or Service non-exported unless external apps are intentionally part of the protocol.
The runnable ProcessCounter example
uses a non-exported ContentProvider in :state_store, one Activity in the
main process, and another in :remote. Its Provider stores state in memory, so
the demo proves IPC synchronization but intentionally resets if the
:state_store process dies.
Normal application code should keep a stable spec and use one of these APIs:
| API | Creates if absent? | Establishes ownership? | VM notifyListeners() |
Handle disposal |
|---|---|---|---|---|
watch(spec) |
Yes | Yes | Yes | Yes |
read(spec) |
Yes | Yes | No | Yes |
Choose watch when ViewModel notifications should update the owner. Choose
read for lifecycle-bound access without subscribing to those notifications.
Caution
Do not use cached lookup as a substitute for spec-based dependency resolution. It reaches into an instance that another path must already have created, couples the caller to cache identity, creation order, and another owner's lifecycle, and cannot create a missing dependency. Use it only for an intentional cross-owner query of an existing cache entry.
| API | Creates if absent? | Establishes ownership? | VM notifyListeners() |
Handle disposal |
|---|---|---|---|---|
watchCached<T>(key/tag) |
No | Yes | Yes | Yes |
readCached<T>(key/tag) |
No | Yes | No | Yes |
maybeWatchCached<T> |
No; returns null |
Yes on hit | Yes | Yes |
maybeReadCached<T> |
No; returns null |
Yes on hit | No | Yes |
watchCachesByTag<T> |
No; returns all hits | Yes | Yes | Yes |
readCachesByTag<T> |
No; returns all hits | Yes | No | Yes |
Single-result non-maybe lookups throw on a miss, and tag lookup can be
ambiguous when several instances share a tag. If the caller has a spec—even a
keyed or tagged spec—use by watchViewModel(spec) / by readViewModel(spec) instead.
The maybe*Cached variants convert only a ViewModelError miss to null.
Programming errors and exceptions raised by key/tag implementations still
propagate.
listen, listenState, and listenStateSelect resolve through read and are automatically removed when the target handle or binding disposes. They are not migrated to another object. Register listeners once during initialization, never inside a delegate's binding lambda.
Expose nested ViewModels through by-delegated properties. Do not retain a child in a stored property or ad-hoc cache: explicit recycle or an asynchronous lifecycle race must allow the next access to resolve the current generation.
val sessionSpec = viewModelSpec { SessionViewModel() }
val cartSpec = viewModelSpec { CartViewModel() }
class CheckoutViewModel : ViewModel() {
val session: SessionViewModel by readViewModel(sessionSpec) { viewModelBinding }
val cart: CartViewModel by watchViewModel(cartSpec) { viewModelBinding }
}Use read when the parent only calls the child. Use watch to automatically
forward child notifications through the parent, ultimately refreshing bindings
that watch the parent. Removing the dependency-update hook does not make
read and watch equivalent: their ownership is the same, but only watch
subscribes to the child's own notifications. Both still observe handle disposal.
Synchronous propagation is transaction-based, so diamond dependency graphs
update each binding at most once.
There is no onDependencyNotify override hook. For business reactions, register
binding listen, listenState, or listenStateSelect once during
initialization, not in a delegate's binding lambda. For example:
class CartChangeViewModel : ViewModel() {
var cartChanges = 0
private set
init {
viewModelBinding.listen(cartSpec) {
update { cartChanges += 1 }
}
}
}A binding-owned listener uses read-style ownership and does not automatically
forward child notifications. In this example, update explicitly notifies about
the parent's own changed value. Subscriptions are removed when their target
handle or binding is disposed and are not migrated after recycle.
A keyed parent can be shared by several root bindings. Roots joining or leaving are mirrored to already-resolved children without changing an unkeyed child's identity. Ownership paths are source-aware: one root may own a keyed child directly and through several parents, and releasing one path does not remove the others. Every aliveForever spec must use an explicit key at both root and nested resolution sites.
Nested ViewModel delegate declarations create nothing until first accessed. After a child is resolved, the parent generation owns a parent → child lifecycle edge. The child may outlive its parent if another direct or parent path still owns it, but it cannot be disposed while that parent generation still owns it.
recycle(vm)is a destructive global escape hatch. It removes every owner and disposes the shared object, includingaliveForeverinstances.ViewModel.reset()is the complete process-wide test reset. It force-disposes all cached generations before clearing configuration and lifecycle observers; nested reset attempts during teardown are ignored until that sequence ends.
There is no in-place instance replacement API. To obtain an independent instance, use a new explicit key. If replacing the shared cached generation globally is intentional, call recycle(vm) and access the delegated property again; the delegate resolves the new generation automatically. The cache miss creates a new handle and dependency tree; owner paths, watch/listen subscriptions, and dependency edges are not migrated from the disposed object.
After recycle, access ViewModels through by-delegated properties; a stored reference keeps pointing at the disposed object.
Construction and dependency graphs are checked. Recursive construction and runtime ownership cycles throw ViewModelError; a failed build rolls back children created by that dependency scope.
setStateis the only operation that emits a state diff;notifyListeners()only reaches broad ViewModel listeners.- Full-state equality is constructor
equals→ViewModel.config.equals→ reference identity. listenStateSelectand ComposeselectViewModelStatecompare selected values with localequals→ViewModel.config.equals→ Kotlin==.- Each
setStatecaptures an immutable previous/current transition before dispatch; nested synchronous state changes cannot rewrite the pair seen by later listeners. - For selector-level UI observation, obtain the ViewModel with a read-style API and let the selector own updates; do not add a broad
watchsubscription to the same instance.
Every zero- through four-argument spec supports scoped overrides. The restore
callback from overrideWith is idempotent and supports nesting or out-of-order
restore. Always restore manual overrides in finally:
val restore = counterSpec.overrideWith(fakeCounterSpec)
try {
// Resolve counterSpec through a binding.
} finally {
restore()
}For suspending work, prefer runWithOverride. It restores after success or
failure and isolates overlapping coroutine scopes from one another:
counterSpec.runWithOverride(fakeCounterSpec) {
// The override remains active across suspension points in this scope.
}Legacy setProxy / clearProxy remains available. An active proxy owns its
complete builder/key/tag/retention definition, including an explicit null
key/tag or false aliveForever value.
The public ViewModel API is main-thread only. Core public classes/functions are annotated with @MainThread, and runtime assertions catch accidental calls from background threads.
Use viewModelScope for async work and hop back to the main thread before mutating state.
- Tests must run in one JVM fork and in runner order. Do not enable Gradle parallel test forks, test sharding, or concurrent test runners: registry, configuration, lifecycle, reset, and spec-proxy state are process-global.
- The library Gradle module enforces
maxParallelForks = 1; keep this invariant in downstream CI and do not add--parallelto the verification command. - Put constructor calls inside
viewModelSpecbuilders and resolve managed instances through a test binding; do not instantiate a ViewModel directly in a test body orsetUp. - Do not retain ViewModels in test fields. Use a
by readViewModel(spec) { binding }property when a shared fixture is needed. - Dispose every binding, and call the complete
ViewModel.reset()between isolated tests. - Prefer
runWithOverridefor coroutine-based mocks. If usingoverrideWith, invoke its restore callback infinally; legacysetProxy/clearProxyalso requirestry/finally.
private lateinit var binding: ViewModelBinding
private val counter: CounterViewModel by readViewModel(counterSpec) { binding }
@Before
fun setUp() {
ViewModel.reset()
binding = ViewModelBinding()
}
@After
fun tearDown() {
binding.dispose()
ViewModel.reset()
}The example guide covers the delegate conventions. The example module demonstrates all supported host styles:
- Compose with
rememberScreenViewModelBinding - Activity with
viewModelBinding - Fragment with
viewLifecycleViewModelBindingandactivityViewModelBinding - Custom View with
viewModelBinding - Plain class with
ViewModelBindingScope
The bundled skill also contains an English, multi-file
Instagram architecture example.
It demonstrates API, repository, feature-state, and startup-coordinator
ViewModels composed through stable specs and by-delegated properties. The
architecture example is intentionally excluded from the Gradle build.
Build it with:
./gradlew :example:assembleDebugRun tests with:
./gradlew :android-view-model:testDebugUnitTest --no-parallel --max-workers=1