跪拜 Guibai
← Back to the summary

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

Compose Multiplatform Cross-Platform Declarative UI Practical Techniques, Lesson 9: Cross-Platform ViewModel and State Architecture

Lesson 8 completed the basics of Compose state management. This lesson lifts state management from the component level to the application architecture level: dependency configuration and initializer constraints for cross-platform ViewModel, scope management with ViewModelStoreOwner, design of the single state object pattern and event receiving pattern, platform differences in state collection, and dependency injection and testing strategies in production practice. This lesson is based on CMP 1.12.1, Lifecycle 2.10.0, and Navigation 3. At the end there are 15 accompanying exercises and a summary of this lesson's key points.

Table of Contents

1. Why you need a cross-platform ViewModel

1.1 Limitations of Compose state management

In Lesson 8, we used remember and rememberSaveable to manage state inside Composables. That approach is sufficient for simple local UI state. But when state involves business logic, asynchronous data loading, or sharing across screens, keeping state in a Composable creates three problems:

ViewModel separates state ownership and business logic from the UI layer, providing a state management unit that is platform-lifecycle-aware, testable, and shareable across platforms.

Here is a diagram of ViewModel's architectural position in CMP:

mermaid diagram

1.2 ViewModel's position and architectural boundaries in CMP

At the architecture level, CMP's ViewModel sits on the boundary between the UI layer and the domain layer. The official architecture boundary guide says a ViewModel should: hold screen-level state, expose state flows, receive events, and call into the domain layer (UseCase/Repository). It should not directly depend on platform APIs and should not hold UI framework types.

This boundary constraint is the core principle of cross-platform architecture. If platform types such as Context, UIApplication, or window appear in a ViewModel, the shared layer loses its cross-platform capability.

JetBrains builds multiplatform artifacts for AndroidX libraries such as Lifecycle, ViewModel, and Navigation, publishing them under a unified group ID. Android uses the native Android artifacts, iOS uses the corresponding iOS artifacts built by JetBrains, and Desktop and Web do the same; the mapping is handled automatically by the Compose Multiplatform Gradle plugin.

2. Dependency configuration and basic usage

2.1 Adding dependencies

In the KMP module's build.gradle.kts, add the dependency to the commonMain source set:

[versions]
androidx-viewmodel = "2.10.0"

[libraries]
androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-viewmodel" }
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.androidx.lifecycle.viewmodel.compose)
        }
    }
}

If the project includes a Desktop target, you also need to add kotlinx-coroutines-swing, because ViewModel.viewModelScope binds to Dispatchers.Main.immediate, and Desktop may not have a usable Main dispatcher by default.

2.2 Defining a ViewModel and architecture layers

Declare the ViewModel in commonMain, following the ViewModel → UseCase → Repository layering:

// Repository layer: data source abstraction
interface OrderRepository {
    suspend fun getPickupOptions(): List<PickupOption>
    suspend fun submitOrder(order: Order): Result<Unit>
}

// UseCase layer (optional, for complex business logic)
class SubmitOrderUseCase(private val repository: OrderRepository) {
    suspend operator fun invoke(order: Order): Result<Unit> = repository.submitOrder(order)
}

// ViewModel layer: screen state holder and event handler
class OrderViewModel(
    private val orderRepository: OrderRepository,
    private val submitOrder: SubmitOrderUseCase,
) : ViewModel() {
    private val _uiState = MutableStateFlow(OrderUiState())
    val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()

    init {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            val options = orderRepository.getPickupOptions()
            _uiState.update { it.copy(pickupOptions = options, isLoading = false) }
        }
    }

    fun onEvent(event: OrderEvent) { /* ... */ }
}

2.3 Getting a ViewModel in a Composable

@Composable
fun OrderScreen(
    viewModel: OrderViewModel = viewModel { OrderViewModel(orderRepository, submitOrder) },
) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    OrderContent(state = state, onEvent = viewModel::onEvent)
}

Here is a diagram of the full ViewModel layering and data flow:

mermaid diagram

3. Initializer constraints on non-JVM platforms

3.1 Why you cannot call viewModel() without arguments

On Android, viewModel<MyViewModel>() can automatically instantiate a ViewModel through type reflection. On non-JVM platforms (iOS, Wasm/JS), type reflection is unavailable, so instances cannot be created through reflection. The official documentation states: "On non-JVM platforms, objects cannot be instantiated using type reflection. Therefore, in common code, the viewModel() function cannot be called without arguments: every time a ViewModel instance is created, at least one initializer must be provided as an argument."

3.2 Correct and incorrect syntax

// ✅ Correct: provide an initializer
val vm = viewModel { MyViewModel() }

// ❌ Wrong: calling without arguments in commonMain will fail on non-JVM platforms
val vm = viewModel<MyViewModel>()

This is the most common pitfall when using CMP ViewModels — if you forget the initializer, it crashes at runtime on non-JVM platforms, and the crash message may not be obvious. Code review should treat parameterless viewModel() calls as a banned pattern.

3.3 Initializers with dependency injection

class OrderViewModel(
    private val orderRepository: OrderRepository,
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() { /* ... */ }

// In a Composable
val viewModel: OrderViewModel = viewModel {
    OrderViewModel(
        orderRepository = repository,
        savedStateHandle = createSavedStateHandle(),
    )
}

Here is a comparison of ViewModel initializer behavior across platforms:

mermaid diagram

4. ViewModel scope and ViewModelStoreOwner

4.1 The role of ViewModelStoreOwner

ViewModelStoreOwner is the manager of ViewModel scope. It holds a ViewModelStore and is responsible for creating, caching, and clearing ViewModels. When the ViewModelStoreOwner is destroyed, onCleared() is called on all ViewModels in it.

4.2 Pitfall of the default scope

In CMP, the default ViewModelStoreOwner is bound to the composition lifecycle. But in a typical single-Activity Compose app, viewModel() resolves to the nearest ViewModelStoreOwner, which means the default ViewModel scope is the Activity, not a single navigation destination. When users navigate between screens, the ViewModel is not cleared, and state still exists when they return — this may be desired behavior, or it may cause memory leaks.

4.3 Navigation 3 integration: scoping ViewModel to a NavEntry

This is the most important scoping practice in CMP architecture. To scope ViewModels and saveable Compose state to each navigation entry, you must pass a Navigation 3 entry decorator to NavDisplay when defining navigation destinations.

First add the navigation support artifact:

[versions]
compose-multiplatform-lifecycle = "2.10.0"

[libraries]
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" }

Then configure the decorators in NavDisplay:

NavDisplay(
    backStack = backStack,
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator(), // scopes to each entry
    ),
    entryProvider = entryProvider { /* ... */ }
)

ViewModelStoreNavEntryDecorator provides a ViewModelStoreOwner for the NavEntry's content and exposes it as LocalViewModelStoreOwner to that content. In Navigation 3 integration, this decorator now accepts a ViewModelStoreOwner so that CreationExtras and factories can be propagated to nested ViewModels.

Key constraint: ViewModelStoreNavEntryDecorator must be used with SaveableStateHolderNavEntryDecorator so that NavEntry-scoped ViewModels can correctly access SavedStateHandle.

4.4 Custom scopes

As of Lifecycle 2.10.0, rememberViewModelStoreOwner can create a ViewModelStoreOwner bound to the current Composable scope. Lifecycle 2.11.0-alpha01 further introduces ViewModelStoreProvider, which allows creating child scopes — these child scopes survive configuration changes but can be cleared independently, while providing wrappers that inherit the parent's default factory and creation arguments.

Important limitation: ViewModels created in this scope do not support state saving. If you need state saving, you must scope to a navigation entry or use a state-saving mechanism outside the ViewModel.

Here is a diagram of the relationship between ViewModel scope and ViewModelStoreOwner:

mermaid diagram

5. Single state object pattern

5.1 Why use a single data class instead of multiple StateFlows

// ❌ Multiple independent states can produce inconsistent intermediate states
class BadViewModel : ViewModel() {
    val isLoading: StateFlow<Boolean> = ...
    val items: StateFlow<List<Item>> = ...
    val error: StateFlow<String?> = ...
}

Multiple state flows can produce momentary inconsistencies during updates — for example, isLoading has already become false, but items has not been updated yet, so the UI briefly shows an empty list.

// ✅ Single state object
data class OrderUiState(
    val pickupOptions: List<PickupOption> = emptyList(),
    val selectedOption: PickupOption? = null,
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
)

class OrderViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(OrderUiState())
    val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()

    fun selectOption(option: PickupOption) {
        _uiState.update { it.copy(selectedOption = option) }
    }
}

5.2 UiState design principles

Here is a comparison of the single state object pattern and the multiple StateFlow pattern:

mermaid diagram

6. Event receiving pattern

6.1 The problem with multiple callback lambdas

For simple screens, passing callback lambdas directly is acceptable. But when a screen has many interactions, the parameter list becomes long and it is hard to trace where each event comes from.

6.2 Sealed interface event pattern

For complex screens, define all user events with a sealed interface and receive them through a single onEvent() function:

sealed interface OrderEvent {
    data class SelectOption(val option: PickupOption) : OrderEvent
    data object SubmitOrder : OrderEvent
    data object DismissError : OrderEvent
}

class OrderViewModel : ViewModel() {
    fun onEvent(event: OrderEvent) {
        when (event) {
            is OrderEvent.SelectOption -> selectOption(event.option)
            is OrderEvent.SubmitOrder -> submitOrder()
            is OrderEvent.DismissError -> dismissError()
        }
    }
}

Advantages: the when expression forces exhaustive handling of all event types; a single entry point simplifies state management; events and state are decoupled, which helps testing.

6.3 One-time events (Effects): platform differences between Channel and SharedFlow

One-time side effects such as navigation, Snackbar, and haptic feedback should not be placed in UiState, because they should not be replayed after configuration changes. In CMP, SharedFlow's replay cache has a known serious problem on iOS — navigation events can be unexpectedly replayed when the user returns.

// ✅ Recommended: Channel ensures each event is consumed only once
class OrderViewModel : ViewModel() {
    private val _effects = Channel<OrderEffect>(Channel.BUFFERED)
    val effects: Flow<OrderEffect> = _effects.receiveAsFlow()

    fun submitOrder() {
        viewModelScope.launch {
            submitOrderUseCase(_uiState.value)
                .onSuccess { _effects.send(OrderEffect.NavigateToConfirmation) }
                .onFailure { _effects.send(OrderEffect.ShowError(it.message)) }
        }
    }
}

Root cause analysis: iOS's snapshot system reads at different points in the rendering loop than Android, and there is no lifecycle event equivalent to Android's that triggers subscriber disposal. Android's collectAsStateWithLifecycle coordinates closely enough with the snapshot system to prevent this problem, while iOS integration is not as tight.

7. Platform differences and best practices for state collection

7.1 collectAsState vs collectAsStateWithLifecycle

In CMP's commonMain, use collectAsStateWithLifecycle() to collect state flows:

@Composable
fun OrderScreen(viewModel: OrderViewModel = viewModel { OrderViewModel() }) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    OrderContent(state = state, onEvent = viewModel::onEvent)
}

collectAsStateWithLifecycle() is lifecycle-aware: it starts collecting the flow only when the Composable enters the RESUMED state. collectAsState() is not lifecycle-aware.

Accurate understanding of platform differences: on Android, collectAsStateWithLifecycle coordinates closely with the snapshot system and reliably prevents inconsistency. On iOS, integration is looser — recomposition on iOS uses Compose for iOS's snapshot plus manual collection, and snapshot reads happen at different points in the rendering loop. Also, iOS has no lifecycle event that triggers subscriber disposal.

7.2 Cross-platform wrapper for state collection

For cases where you need unified state collection behavior in commonMain, you can wrap it with expect/actual:

// commonMain
@Composable
expect fun <T> StateFlow<T>.collectAsStateLifecycleAware(): State<T>

// androidMain
@Composable
actual fun <T> StateFlow<T>.collectAsStateLifecycleAware(): State<T> =
    collectAsStateWithLifecycle()

// iosMain / desktopMain / wasmJsMain
@Composable
actual fun <T> StateFlow<T>.collectAsStateLifecycleAware(): State<T> =
    collectAsState()

7.3 Using a Scheduler to manage coroutine dispatch

Inject a CoroutineDispatcher or CoroutineScope instead of hardcoding Dispatchers.Main:

class OrderViewModel(
    private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ViewModel() {
    fun loadOrders() {
        viewModelScope.launch(dispatcher) { /* ... */ }
    }
}

On Desktop, viewModelScope uses Dispatchers.Main.immediate, so kotlinx-coroutines-swing is needed. Injecting the dispatcher lets tests replace it with UnconfinedTestDispatcher.

8. Production practice: dependency injection and testing

8.1 Koin dependency injection

Koin 4.x is the most popular dependency injection framework in CMP. With the @KoinViewModel annotation and the compiler plugin, ViewModels are automatically registered and injected:

@KoinViewModel
class OrderViewModel(
    private val orderRepository: OrderRepository,
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() { /* ... */ }

// In a Composable
@Composable
fun OrderScreen(
    viewModel: OrderViewModel = koinViewModel(),
) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    // ...
}

@KoinViewModel registers the class as a ViewModel with correct lifecycle handling, and dependencies declared in the constructor are injected automatically by the compiler plugin. koinViewModel() automatically resolves the ViewModel and its entire dependency chain. If a ViewModel needs its own scoped dependencies, use the viewModelScope prototype — dependencies declared in that scope are bound to the ViewModel's lifecycle.

8.2 ViewModel testing strategy

ViewModel tests are written in commonTest. Using hand-written Fake implementations instead of Mock frameworks is the recommended practice in the Kotlin community for three reasons: Fakes do not depend on reflection and run on all KMP targets; Fakes behave closer to real implementations, making tests more reliable; ViewModels receive a Repository through the constructor, so tests can inject a Fake without needing a DI container or reflection.

class OrderViewModelTest {
    private val fakeRepository = FakeOrderRepository()

    @Test
    fun `selectOption updates uiState`() = runTest {
        val viewModel = OrderViewModel(
            orderRepository = fakeRepository,
            dispatcher = UnconfinedTestDispatcher(),
        )
        val option = PickupOption(id = 1, name = "Delivery")
        viewModel.onEvent(OrderEvent.SelectOption(option))
        assertEquals(option, viewModel.uiState.value.selectedOption)
    }
}

Testing points: inject UnconfinedTestDispatcher so coroutines run synchronously; use a Fake Repository instead of Mocks; assert on StateFlow.value instead of waiting for flow emissions; ViewModel tests are the minimum test coverage unit for each feature.

9. Cross-platform architecture notes

9.1 iOS lifecycle differences

This is the most important platform difference in CMP ViewModel architecture. On iOS, moving the app to the background does not cancel the ViewModel's viewModelScope — coroutines keep running. If you need to pause work when the app enters the background, you must listen to lifecycle changes and manage it explicitly.

The mapping between iOS native events and Lifecycle states is:

iOS native event Lifecycle state change
viewDidDisappear ON_STOP: STARTED → CREATED
viewWillAppear ON_START: CREATED → STARTED
didBecomeActive ON_RESUME
willEnterForeground CREATED → DESTROYED

This means viewModelScope is not canceled on iOS when the app goes into the background; developers need to explicitly manage coroutine lifecycles based on business requirements.

9.2 Isolating platform-specific APIs

ViewModel code cannot use platform-specific APIs directly. If a ViewModel constructor needs a platform-specific dependency, abstract it behind an interface:

// Define the interface in commonMain
interface PlatformLogger {
    fun log(message: String)
}

// Implement in androidMain
class AndroidPlatformLogger(private val context: Context) : PlatformLogger {
    override fun log(message: String) { Log.d("CMP", message) }
}

9.3 SwiftUI interop

When a CMP shared ViewModel is mixed with SwiftUI, the ViewModel needs to be exported to the iOS binary framework. SwiftUI has no built-in ViewModel equivalent, so you need to bridge Kotlin's StateFlow to SwiftUI's @Published property through an ObservableObject or @Observing wrapper.

Exercises (15 questions)

Group 1: Dependency configuration and initializers (4 questions)

Question 1 The dependency artifact for cross-platform ViewModel in CMP is ( )

A. androidx.lifecycle:lifecycle-viewmodel-compose B. org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose C. org.jetbrains.compose.viewmodel:viewmodel D. androidx.compose.viewmodel:viewmodel

Answer: B

Explanation: CMP provides the multiplatform ViewModel implementation through org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose.

Question 2 Which of the following ways to get a ViewModel is correct in CMP's commonMain? ( )

A. viewModel<OrderViewModel>() B. viewModel { OrderViewModel() } C. remember { OrderViewModel() } D. OrderViewModel()

Answer: B

Explanation: On non-JVM platforms (iOS, Wasm), type reflection is unavailable, so viewModel() cannot be called without arguments. You must provide an initializer: viewModel { OrderViewModel() }.

Question 3 Why does CMP require an initializer when creating a ViewModel? ( )

A. To improve performance B. Non-JVM platforms do not support type reflection to instantiate objects C. It is a requirement of the Compose compiler D. To support dependency injection

Answer: B

Explanation: Non-JVM platforms (such as iOS and Wasm/JS) do not support type reflection, so viewModel<MyViewModel>() cannot automatically instantiate a ViewModel.

Question 4 If the project includes a Desktop target, what dependency is needed in addition to the ViewModel dependency? ( )

A. kotlinx-coroutines-android B. kotlinx-coroutines-swing C. androidx.lifecycle:lifecycle-runtime D. org.jetbrains.kotlinx:kotlinx-datetime

Answer: B

Explanation: ViewModel.viewModelScope binds to Dispatchers.Main.immediate, and Desktop may not have a usable Main dispatcher by default. kotlinx-coroutines-swing provides the Swing event dispatcher.

Group 2: State patterns and event patterns (4 questions)

Question 5 The core advantage of exposing UiState as a single data class instead of multiple independent StateFlows is ( )

A. Less code B. Avoiding inconsistent intermediate states from multiple state flows C. Faster network requests D. Automatically saving state to a Bundle

Answer: B

Explanation: Multiple independent state flows can produce momentary inconsistencies during updates. A single immutable data class exposed through one StateFlow makes state updates atomic.

Question 6 Which of the following is NOT an advantage of using a sealed interface to define events on complex screens? ( )

A. The when expression forces exhaustive handling of all event types B. A single onEvent() entry point simplifies state management C. Events and state are decoupled, which helps testing D. Automatically generates event handling code

Answer: D

Explanation: A sealed interface does not automatically generate event handling code. Its advantages are exhaustiveness checking, a single entry point, and decoupling events from state.

Question 7 In CMP, how should one-time side effects such as navigation and Snackbar be exposed? ( )

A. Put them in the UiState data class B. Expose them as a Flow through a Channel C. Expose them through mutableStateOf D. Expose them through a SharedFlow

Answer: B

Explanation: In CMP, SharedFlow's replay cache has a known serious problem on iOS — navigation events can be unexpectedly replayed when returning. Channel's receiveAsFlow() is a safer choice.

Question 8 Which of the following statements about UiState design principles is wrong? ( )

A. UiState should be a data class with all properties as val B. UiState can contain properties of type Flow C. UiState should contain only basic types and immutable models D. The @Immutable annotation helps the Compose compiler infer type stability

Answer: B

Explanation: UiState should contain only basic immutable data and should not contain Flow or mutable collections.

Group 3: Scope and state collection (4 questions)

Question 9 In CMP, what is the default ViewModelStoreOwner bound to? ( )

A. The global application lifecycle B. The composition lifecycle C. The system process lifecycle D. The network connection lifecycle

Answer: B

Explanation: The default ViewModelStoreOwner is bound to the composition lifecycle. But in a single-Activity Compose app, viewModel() resolves to the nearest ViewModelStoreOwner, meaning the default ViewModel scope is the Activity.

Question 10 In Navigation 3, how do you scope a ViewModel to a navigation entry? ( )

A. Use remember { ViewModel() } B. Configure rememberViewModelStoreNavEntryDecorator() in NavDisplay C. Use the viewModel { } initializer D. Use ViewModelStoreProvider

Answer: B

Explanation: To scope a ViewModel to each navigation entry, you must pass the Navigation 3 entry decorator to NavDisplay when defining navigation destinations.

Question 11 Which decorator must ViewModelStoreNavEntryDecorator be paired with so a ViewModel can correctly access SavedStateHandle? ( )

A. rememberSaveableStateHolderNavEntryDecorator() B. rememberViewModelStoreOwner() C. rememberUpdatedState D. rememberCoroutineScope

Answer: A

Explanation: ViewModelStoreNavEntryDecorator must be used with SaveableStateHolderNavEntryDecorator so NavEntry-scoped ViewModels can correctly access SavedStateHandle.

Question 12 On iOS, what problem can exposing navigation events through a SharedFlow cause? ( )

A. Event loss B. Navigation events are unexpectedly replayed when returning C. Compilation error D. Performance degradation

Answer: B

Explanation: iOS's snapshot system reads at different points in the rendering loop than Android, and there is no lifecycle event equivalent to Android's that triggers subscriber disposal, so SharedFlow's replay cache can unexpectedly replay navigation events on iOS.

Group 4: Production practice and platform differences (3 questions)

Question 13 What is the annotation for registering a ViewModel in Koin? ( )

A. @Inject B. @KoinViewModel C. @ViewModel D. @Component

Answer: B

Explanation: Koin registers ViewModels with the @KoinViewModel annotation, and the compiler plugin handles dependency injection automatically.

Question 14 In ViewModel tests, the recommended coroutine dispatcher is ( )

A. Dispatchers.Main B. Dispatchers.IO C. UnconfinedTestDispatcher() D. Dispatchers.Default

Answer: C

Explanation: In ViewModel tests, injecting UnconfinedTestDispatcher() makes coroutines run synchronously, avoiding asynchronous waiting in tests.

Question 15 On iOS, when the app is moved to the background, coroutines in viewModelScope will ( )

A. Be canceled automatically B. Continue running and will not be canceled C. Be paused D. Throw an exception

Answer: B

Explanation: The mapping between iOS native events and Lifecycle states has no cancellation mechanism equivalent to Android's ON_STOP. Moving the app to the background does not cancel viewModelScope; coroutines keep running.

Summary of this lesson's key points

1. Cross-platform ViewModel dependencies and architectural position

CMP provides the multiplatform ViewModel implementation through org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0. ViewModel sits on the boundary between the UI layer and the domain layer. Its responsibilities are to hold screen state, expose state flows, receive events, and call the domain layer; it must not directly depend on platform APIs. When a Desktop target is included, you also need kotlinx-coroutines-swing.

2. Initializer constraints on non-JVM platforms

On non-JVM platforms (iOS, Wasm/JS), type reflection is unavailable, so you cannot call viewModel<MyViewModel>() without arguments; you must provide an initializer: viewModel { MyViewModel() }. This is the most common pitfall in CMP ViewModel usage; forgetting the initializer causes a runtime crash on non-JVM platforms.

3. Single state object and event patterns

Use a single immutable data class to express the complete screen state and expose it through one StateFlow, avoiding inconsistent intermediate states from multiple independent state flows. Complex screens use a sealed interface to define events and receive them through a single onEvent(). In CMP, one-time side effects must use a Channel rather than a SharedFlow — SharedFlow's replay cache on iOS can cause navigation events to be replayed unexpectedly.

4. ViewModel scope and Navigation 3 integration

The default ViewModel scope is the Activity. In navigation scenarios, you must scope ViewModels to NavEntry via rememberViewModelStoreNavEntryDecorator(), paired with SaveableStateHolderNavEntryDecorator so SavedStateHandle is available. ViewModelStoreProvider (Lifecycle 2.11.0-alpha01+) allows creating child scopes, but ViewModels in those scopes do not support state saving.

5. State collection and iOS lifecycle differences

In commonMain, prefer collectAsStateWithLifecycle(). On Android this API coordinates closely with the snapshot system; on iOS the integration is looser — snapshot reads happen at different points in the rendering loop, and there is no lifecycle event that triggers subscriber disposal. On iOS, entering the background does not cancel viewModelScope coroutines; you need to manage them explicitly.

6. Production practice

Koin 4.x uses the @KoinViewModel annotation and the koinViewModel() function for automatic registration and injection; the viewModelScope prototype supports ViewModel-scoped dependencies. ViewModel tests are written in commonTest, use UnconfinedTestDispatcher to run coroutines synchronously, and use hand-written Fakes instead of Mock frameworks, which is the recommended practice in the KMP community. ViewModels cannot use platform-specific APIs directly; abstract them behind interfaces.


Next lesson preview: Lesson 10 will go deeper into Compose Multiplatform navigation — Navigation 3 in practice, including Navigation 3's design philosophy (user-owned back stack, low-level building blocks, adaptive layout system), the migration path from Navigation 2 to Navigation 3, basic navigation patterns and deep link implementation, cross-platform back gesture handling, and combining adaptive multi-pane layouts with Navigation 3.