跪拜 Guibai
← All articles
Android · Kotlin

Compose Multiplatform ViewModels: Initializer Rules, NavEntry Scopes, and the iOS SharedFlow Replay Trap

By 传奇开心果 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Teams sharing ViewModel code across Android and non-JVM targets need these constraints up front; the iOS SharedFlow replay bug alone can replay navigation events and send users back to screens they already left.

Summary

Compose Multiplatform's ViewModel code has sharp edges that only show up off Android. On iOS and Wasm/JS, type reflection is unavailable, so every `viewModel()` call in commonMain must pass an initializer; the parameterless `viewModel<MyViewModel>()` form compiles on Android but crashes at runtime elsewhere. Desktop targets also need `kotlinx-coroutines-swing` because `viewModelScope` binds to `Dispatchers.Main.immediate`.

Scope management is where most architecture decisions land. In a single-Activity Compose app, the default ViewModelStoreOwner is the Activity, not the current screen, so ViewModels survive navigation and can leak. Navigation 3 fixes this with entry decorators: `rememberViewModelStoreNavEntryDecorator()` scopes a ViewModel to a NavEntry, and it must be paired with `rememberSaveableStateHolderNavEntryDecorator()` so `SavedStateHandle` works.

State and events follow two patterns. UI state should be one immutable data class exposed through a single StateFlow, avoiding the inconsistent intermediate states that multiple StateFlows produce. One-shot effects such as navigation and snackbars should use a Channel, because SharedFlow's replay cache has a known iOS bug that re-emits navigation events when a user returns to a screen. iOS also does not cancel `viewModelScope` when the app is backgrounded, so coroutine lifecycles need explicit management.

Takeaways
— CMP's ViewModel dependency is `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0`, declared in `commonMain`.
— On iOS and Wasm/JS, type reflection is unavailable, so every `viewModel()` call needs an initializer like `viewModel { MyViewModel() }`; bare `viewModel<MyViewModel>()` crashes at runtime.
— Desktop targets need `kotlinx-coroutines-swing` so `Dispatchers.Main.immediate` exists for `viewModelScope`.
— In a single-Activity Compose app, the default ViewModelStoreOwner is the Activity, so ViewModels outlive navigation destinations and can retain state or leak.
— Navigation 3 scopes ViewModels to a NavEntry through `rememberViewModelStoreNavEntryDecorator()` passed to `NavDisplay`'s `entryDecorators`.
— `ViewModelStoreNavEntryDecorator` must be combined with `rememberSaveableStateHolderNavEntryDecorator()` for `SavedStateHandle` access.
— Lifecycle 2.11.0-alpha01 adds `ViewModelStoreProvider` for child scopes that survive configuration changes but do not support state saving.
— UI state belongs in a single immutable data class exposed through one StateFlow, not multiple StateFlows that can show inconsistent intermediate states.
— Complex screens should route all user actions through a sealed interface and one `onEvent(event)` function; `when` then forces exhaustive handling.
— One-shot effects like navigation and snackbars should be sent through `Channel.receiveAsFlow()`, because SharedFlow replay can re-emit navigation events on iOS.
— `collectAsStateWithLifecycle()` is the recommended state collector in `commonMain`; iOS integration is looser, so behavior can differ from Android.
— Inject a `CoroutineDispatcher` or `CoroutineScope` into ViewModels instead of hardcoding `Dispatchers.Main`; tests replace it with `UnconfinedTestDispatcher`.
— Koin 4.x registers ViewModels with `@KoinViewModel` and resolves them with `koinViewModel()`; the compiler plugin injects constructor dependencies.
— Hand-written Fake repositories are preferred over Mock frameworks in `commonTest` because Fakes work on all KMP targets without reflection.
— iOS does not cancel `viewModelScope` when the app is backgrounded; coroutines keep running until the owning scope is cleared.
Conclusions

The non-JVM initializer constraint turns a familiar Android API into a runtime-only failure: code that works on Android compiles everywhere and then crashes on iOS, which makes the no-argument `viewModel()` call a legitimate code-review ban.

The iOS SharedFlow replay issue shows platform divergence inside shared code; Android's snapshot/lifecycle coordination hides the problem, so lifecycle-aware behavior is not identical across targets.

Scoping ViewModels to NavEntry matters most because default Activity scoping silently keeps screen state alive across navigation, which is both useful and a memory-leak source.

KMP's testing guidance to prefer Fakes over Mock frameworks follows from platform constraints: reflection-based mocking does not port cleanly to every target, so the test strategy changes even when production code does not.

The single UiState data class pattern changes atomicity: multiple StateFlows can render a loading=false/empty-list frame during updates, while one immutable data class keeps state consistent.

Concepts & terms
ViewModelStoreOwner
An object that owns a ViewModelStore and defines the lifetime of the ViewModels inside it. When the owner is destroyed, all its ViewModels receive `onCleared()`. In CMP, the default owner is often the Activity; Navigation 3 can supply a per-NavEntry owner.
Navigation 3 entry decorators
Arguments passed to `NavDisplay` that add per-entry infrastructure. `rememberViewModelStoreNavEntryDecorator()` creates a ViewModelStoreOwner for each NavEntry, and `rememberSaveableStateHolderNavEntryDecorator()` enables saved state.
SavedStateHandle
A map-like object a ViewModel can use to read and write state that should survive configuration changes and process death. In Navigation 3, it is only available when the ViewModel is scoped to a NavEntry and the saveable state decorator is present.
SharedFlow replay
SharedFlow can cache previously emitted values and send them to new collectors. In CMP on iOS, that replay cache can re-emit one-shot navigation events when a user returns to a screen, which is why the lesson recommends Channel for effects.
Channel.receiveAsFlow()
A Channel exposes events to a single consumer; `receiveAsFlow()` turns it into a Flow where each event is delivered once and not replayed. The lesson uses it for one-time UI effects.
collectAsStateWithLifecycle()
A Composable function that collects a StateFlow and updates Compose state only while the composable is at least in the RESUMED lifecycle state. The lifecycle-aware behavior is well coordinated on Android and looser on iOS.
UnconfinedTestDispatcher
A kotlinx-coroutines-test dispatcher that runs coroutines eagerly and without delay, making ViewModel tests synchronous.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗