Skip to content

Repository files navigation

AndroidViewModel

Latest release JitPack Android checks License: Apache 2.0

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:

  • ViewModel is the business base class.
  • StateViewModel<State> manages immutable state and emits state/general listeners.
  • ViewModelSpec declares how to build a ViewModel and whether it is shared by key.
  • ViewModelBinding is 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 Skill

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-model

Core resolution rules

Important

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) or by readViewModel(spec) in Compose, host classes, tests, and ViewModel-to-ViewModel dependencies; outside Compose, supply a binding lambda such as { viewModelBinding }.
  • watch and read both create or reuse an instance, establish lifecycle ownership, and observe handle disposal, including force-recycle. Only watch listens to the ViewModel's own notifyListeners().
  • 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 by delegates. Do not cache resolved instances with by lazy, remember { vm }, or another field; the delegate handles resolution after recycle.

Quick Start

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:

  • key participates in identity. Use it for intentional cross-binding sharing or multiple same-type instances in one binding.
  • tag is only a grouping/lookup label.
  • aliveForever skips automatic disposal when all ownership paths leave; explicit recycle and the complete ViewModel.reset() still force disposal.
  • Every aliveForever spec must have an explicit key, whether resolved by a root binding or another ViewModel. A missing or computed-null key throws ViewModelError before 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 }

Use property delegates consistently

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.

Local versus screen bindings

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.

Fixed versus deferred bindings

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.

Keep resolved ViewModels inside their ownership boundary

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.

Why not extend AndroidX ViewModel?

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.

Use From Git Source

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.

Basic Usage

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()
}

Compose

@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.

Activity / Fragment

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 }
    }
}

View

class CounterPanelView(context: Context) : LinearLayout(context) {
    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        val counter by watchViewModel(counterSpec) { viewModelBinding }
    }
}

Plain Class

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()
    }
}

Local scope: sharing one instance across pages

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.
  • watch and read both establish ownership. Use watch for a page that must react to notifications and read when 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 viewModelBinding for this destination lifetime. Use viewLifecycleViewModelBinding only when ownership should end with the Fragment view, and activityViewModelBinding only for an intentionally Activity-wide scope.

Android process boundary: sharing state, not instances

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,
) : Parcelable
  • ProcessStateStore synchronizes 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 version and sourceId so 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.

Binding access APIs

Primary: spec-based resolution (recommended)

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.

Advanced: cached lookup

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.

ViewModel-to-ViewModel dependencies

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.

Lifecycle controls

  • recycle(vm) is a destructive global escape hatch. It removes every owner and disposes the shared object, including aliveForever instances.
  • 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.

State and fine-grained observation

  • setState is the only operation that emits a state diff; notifyListeners() only reaches broad ViewModel listeners.
  • Full-state equality is constructor equalsViewModel.config.equals → reference identity.
  • listenStateSelect and Compose selectViewModelState compare selected values with local equalsViewModel.config.equals → Kotlin ==.
  • Each setState captures 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 watch subscription to the same instance.

Spec overrides

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.

Threading

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.

Testing

  • 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 --parallel to the verification command.
  • Put constructor calls inside viewModelSpec builders and resolve managed instances through a test binding; do not instantiate a ViewModel directly in a test body or setUp.
  • 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 runWithOverride for coroutine-based mocks. If using overrideWith, invoke its restore callback in finally; legacy setProxy / clearProxy also requires try/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()
}

Example

The example guide covers the delegate conventions. The example module demonstrates all supported host styles:

  • Compose with rememberScreenViewModelBinding
  • Activity with viewModelBinding
  • Fragment with viewLifecycleViewModelBinding and activityViewModelBinding
  • 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:assembleDebug

Run tests with:

./gradlew :android-view-model:testDebugUnitTest --no-parallel --max-workers=1

About

another viewModel for android

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages