Engineering

A shared ViewModel has no lifecycle, so you have to give it one

androidx.lifecycle.ViewModel is Android's. The shared class needs an explicit clear() and two platforms that call it.

MobileByteSensei 9 min read
kmparchitecturecompose

TL;DR

androidx.lifecycle.ViewModel gives you two things: a scope that is cancelled automatically, and survival across configuration change. Neither is a language feature — both come from an Android ViewModelStoreOwner.

A shared ViewModel in commonMain has neither. If you hand-roll one, you own clear(), and two platforms have to call it at the right moment — which they define differently. Android destroys and recreates on rotation; SwiftUI creates and discards views constantly and has no equivalent event.

There is now a multiplatform lifecycle-viewmodel artifact, and it is the right default. Knowing what it is doing for you is what lets you debug it when a coroutine outlives a screen.

The case this post is about

A shared ViewModel, hand-rolled because the Android one is not available in commonMain:

// commonMain
open class SharedViewModel {
    protected val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    open fun clear() = scope.cancel()
}

class ReadingViewModel(repo: ReadingRepository) : SharedViewModel() {
    val state: StateFlow<ReadingState> = repo.items()
        .map(::toState)
        .stateIn(scope, SharingStarted.WhileSubscribed(5_000), ReadingState.Loading)
}

Then each platform wires it up, which is where the two platforms diverge:

// Android — Compose
@Composable
fun ReadingRoute() {
    val vm = remember { ReadingViewModel(get()) }    // ← survives nothing, clears nothing
    ReadingListContent(vm.state.collectAsState().value, …)
}
// iOS — SwiftUI
struct ReadingScreen: View {
    @StateObject private var holder = VMHolder()     // ← when does clear() run?
    …
}

Both compile. Both work in the happy path. Both leak.

The symptom

Four behaviours, one per platform pair.

Android: a new ViewModel on every rotation. remember is keyed to composition, so rotation produces a fresh instance — losing state and starting a second network fetch:

rotate device
  GET /items   (new ViewModel, new stateIn, new upstream)
  previous ViewModel's scope: never cancelled

Android: scopes accumulating. Nothing calls clear(), so every rotation leaves a live SupervisorJob with a WhileSubscribed upstream in its grace window:

rotate ×10 → 10 live scopes, 10 coroutines in the Recomposer's dispatcher

iOS: work continuing after the screen is gone. SwiftUI's @StateObject is deallocated when the view's identity goes away, and deinit on a Swift holder is not a reliable hook for a Kotlin object's teardown — particularly if a Kotlin closure captured the holder, which is a retain cycle across the bridge.

And the one that cost the most time: state loss on process death. Neither platform was saving anything, so a backgrounded app resumed to a blank screen. The hand-rolled base class had no notion of saved state at all, and nothing in the code hinted that the problem existed.

Why the obvious fix failed

"Call clear() from a Compose DisposableEffect." Closest honest attempt, and it is wrong on rotation:

DisposableEffect(Unit) { onDispose { vm.clear() } }

A configuration change disposes the composition. So this clears the ViewModel precisely when you wanted it to survive, converting a leak into state loss.

"Keep a global map of ViewModels by screen key." A hand-rolled ViewModelStore, which is genuinely what is needed — and doing it yourself means owning the keying, the eviction, and the "was this a rotation or a real exit" question that you have no signal for in commonMain.

"Use rememberSaveable for the state instead." Addresses state restoration for primitives and not the scope lifetime, and it cannot hold the ViewModel itself.

"deinit on the Swift holder." Does not fire when a Kotlin closure holds the holder — the cross-runtime retain cycle — so the cleanup is gated on the thing it is cleaning up.

The actual mechanism

Three separable responsibilities, usually bundled into the word "ViewModel".

Scope ownership. Something must cancel the coroutine scope exactly once, when the screen is genuinely finished. viewModelScope on Android is cancelled by onCleared().

Survival across recreation. Something must hold the instance through a configuration change and hand the same one back. That is the ViewModelStore, held by an Activity or a NavBackStackEntry — the ViewModelStoreOwner.

Saved state across process death. Something must serialise a minimal snapshot and restore it. That is SavedStateHandle, backed by the platform's saved-instance-state mechanism.

Only the first is portable. The second and third are owner concepts, and the two platforms have genuinely different owners:

Android has an explicit recreate-with-identity event, and a store that outlives it.

iOS has no configuration change in the Android sense. A SwiftUI view is created and discarded freely; identity is managed by SwiftUI, and the closest analogue to "finished" is the view's identity leaving the hierarchy.

Three separable responsibilities behind the word ViewModel: a coroutine scope that must be cancelled exactly once, instance survival across recreation which requires an owner that outlives the view, and saved state across process death which requires the platform's instance-state mechanism — with only the first portable, and Android's recreate-with-identity event having no SwiftUI equivalent

A ViewModel's lifecycle is not a property of the ViewModel. It is a property of its owner, and commonMain has no owners. So either you supply one per platform, or you use the artifact that already does.

And the SwiftUI failure mode is worth being specific about, because it is silent in both directions. A body is re-evaluated whenever SwiftUI decides to, so where the instance is held decides which bug you get:

struct ArticleView: View {
    let viewModel = ArticleViewModel()        // new instance on every body evaluation:
                                              // state resets, in-flight work orphaned
    @StateObject var vm = ArticleWrapper()    // created once per view IDENTITY — the owner
}

The first line never throws and never logs. It presents as a screen that intermittently forgets what the user typed, at a frequency determined by SwiftUI's diffing — which is to say, irreproducibly, and never on the simulator while you are watching.

The mirror image is holding it too long: a view model stored in an app-level object and keyed by nothing is never cancelled, so its scope and its collectors outlive the screen. On Android that would be a leaked viewModelScope and onCleared() would have caught it. On iOS nothing is going to tell you — there is no onCleared() to not-get-called.

Which is the practical reading of the rule above: the per-platform work is not writing a ViewModel base class, it is deciding what counts as "this screen is finished" on a platform that does not have that event, and then making exactly one thing responsible for saying so.

The fix

Use the multiplatform lifecycle-viewmodel artifact, and understand the two things it is doing.

// build.gradle.kts
commonMain.dependencies {
    implementation(libs.androidx.lifecycle.viewmodel)          // multiplatform
    implementation(libs.androidx.lifecycle.runtime.compose)    // collectAsStateWithLifecycle
}
// commonMain — a real ViewModel, in shared code
class ReadingViewModel(
    repo: ReadingRepository,
    private val saved: SavedStateHandle,
) : ViewModel() {

    val state: StateFlow<ReadingState> = repo.items()
        .map(::toState)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReadingState.Loading)

    // survives process death; the scope does not need to
    var scrollIndex: Int
        get() = saved["scrollIndex"] ?: 0
        set(v) { saved["scrollIndex"] = v }

    override fun onCleared() { /* extra teardown, if any */ }
}
// commonMain — Compose, both platforms
@Composable
fun ReadingRoute() {
    val vm: ReadingViewModel = viewModel()        // resolved from the local store owner
    val state by vm.state.collectAsStateWithLifecycle()
    ReadingListContent(state, …)
}

Four things that resolves:

viewModelScope is cancelled exactly once, by onCleared(), when the owner says the screen is finished — not when the composition is disposed. That is the rotation fix.

The instance survives recreation, because viewModelScope's owner is the store, not the composition. One instance, one upstream, one fetch across rotations.

SavedStateHandle works on both platforms, which closes the process-death gap the hand-rolled base class could not express.

And on iOS the owner is provided for you. Compose Multiplatform supplies a ViewModelStoreOwner tied to the Compose root, so viewModel() resolves without Swift-side plumbing — which is what removes the unreliable deinit hook.

If you must hand-roll — a non-Compose iOS UI, for instance — then be explicit about who owns the clear, and make it a handle rather than a destructor:

// commonMain — exported to a non-Compose iOS UI
class ReadingViewModelHandle(private val vm: ReadingViewModel) {
    fun observe(onState: (ReadingState) -> Unit): Subscription = vm.observe(onState)
    fun close() = vm.clear()                   // Swift calls this from onDisappear
}

Explicit close() called from the platform is honest about the ownership. A deinit that fires only when a cycle is already broken is not.

How we proved it

One instance across rotations. A counting constructor plus a rotation loop:

// androidInstrumentedTest
@Test
fun rotationReusesTheSameViewModel() {
    val first = scenario.withActivity { currentViewModel() }
    scenario.recreate()
    val second = scenario.withActivity { currentViewModel() }
    assertSame(first, second)
}
before (remember):  FAILED — two distinct instances
after (viewModel()): PASSED

No accumulating scopes. The direct measurement, because "it leaks" needs a number:

rotate ×10, then read live coroutine count in the ViewModel's scope

before:  10 live scopes, 10 WhileSubscribed upstreams
after:    1 live scope,   1 upstream

One fetch across rotations, which is the user-visible consequence:

rotate ×10
before:  GET /items × 11
after:   GET /items × 1

And clearing happens on a real exit, not a rotation — the assertion that distinguishes the two cases the DisposableEffect attempt conflated:

@Test
fun clearedOnFinishButNotOnRecreate() {
    scenario.recreate()
    assertFalse(viewModel.isCleared)          // rotation: must survive
    scenario.moveToState(Lifecycle.State.DESTROYED)
    assertTrue(viewModel.isCleared)           // genuine exit: must clear
}

That test is the one worth keeping, because both halves failed in different earlier attempts: the hand-rolled version failed the second, and the DisposableEffect version failed the first.

One limit: the rotation assertions are Android instrumented tests, because rotation is an Android concept. The iOS half was verified by the same deinit-counter technique from the memory post — navigate in and out ten times, confirm ten onCleared() calls — which is cruder but checks the property that matters.

What this generalizes to

Lifecycle belongs to the owner, not the owned. A ViewModel, a connection, a cache, a subscription — none of them know when they are finished. Something above them does. When you port a component to a context with no owner, the lifecycle is the part that does not come with it.

Three bundled responsibilities will not port together. "ViewModel" means scope, survival, and saved state. The portable part is the one that needs no platform event; the rest needs an owner. Any framework abstraction worth porting is worth decomposing first.

"Dispose" and "finished" are different events. The composition being torn down is not the user leaving. Conflating them turns a leak into data loss, which is a worse bug — and the test that catches it has to assert both directions.

Explicit teardown beats a destructor you cannot trust. close() called by the platform is visible and debuggable. A deinit or finaliser that depends on reference counting across a runtime boundary is a hook that fires when it feels like it.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS