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 resolver 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.
Important
The default path is always stable spec → watch(spec) / read(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
watch(spec)orread(spec)as the primary entry points in Compose, host classes, tests, and ViewModel-to-ViewModel dependencies. 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.
- Resolve ViewModels through resolver properties instead of
by lazyor stored references so explicit recycle and asynchronous lifecycle changes can return the current generation.
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:v0.4.0")
}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 = rememberRetainedViewModelBinding()) {
val counter = watchViewModel(counterSpec)
}
// Activity
val counter = viewModelBinding.watch(counterSpec)
// Fragment view lifecycle
val counter = viewLifecycleViewModelBinding.watch(counterSpec)
// Plain class
val scope = ViewModelBindingScope()
val counter = scope.viewModelBinding.read(counterSpec)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:v0.4.0")
}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 = rememberRetainedViewModelBinding()) {
val count = selectViewModelState(
factory = counterSpec,
selector = { it.count },
)
val counter = 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. All three observe handle disposal; after recycle, Compose
re-resolves the spec and stops returning the disposed generation.
class MainActivity : FragmentActivity() {
private val counter: CounterViewModel
get() = viewModelBinding.watch(counterSpec)
}
class CounterFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val counter = viewLifecycleViewModelBinding.watch(counterSpec)
}
}class CounterPanelView(context: Context) : LinearLayout(context) {
override fun onAttachedToWindow() {
super.onAttachedToWindow()
val counter = viewModelBinding.watch(counterSpec)
}
}class CounterController : AutoCloseable {
private val scope = ViewModelBindingScope()
private val counter: CounterViewModel
get() = scope.viewModelBinding.read(counterSpec)
fun increment() = counter.increment()
override fun close() {
scope.close()
}
}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 watch(spec) / read(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. Do not put a listen call in a repeatedly evaluated resolver property.
Expose nested ViewModels through resolver 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
get() = viewModelBinding.read(sessionSpec)
val cart: CartViewModel
get() = viewModelBinding.watch(cartSpec)
}Use read when the parent only calls the child. Use watch when child notifications should call parent.onDependencyNotify(child) and then notify the parent. Synchronous propagation is transaction-based, so diamond dependency graphs update each binding at most once.
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.
Getter declarations create nothing by themselves. 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 let resolver properties call watch(spec) / read(spec) again. 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 resolver 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 getter backed by the test binding 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
get() = binding.read(counterSpec)
@Before
fun setUp() {
ViewModel.reset()
binding = ViewModelBinding()
}
@After
fun tearDown() {
binding.dispose()
ViewModel.reset()
}The example module demonstrates all supported host styles:
- Compose with
rememberRetainedViewModelBinding - 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 resolver 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