【Compose Multiplatform跨端声明式UI实用技术】第9课:跨平台 ViewModel 与状态架构

481 阅读19分钟

第8课完成了 Compose 状态管理基础的学习。本课将状态管理从组件级提升到应用架构级:深入跨平台 ViewModel 的依赖配置与初始化器约束,ViewModelStoreOwner 的作用域管理,单一状态对象模式与事件接收模式的设计,状态收集的平台差异处理,以及生产级实践中的依赖注入与测试策略。本课内容基于 CMP 1.12.1、Lifecycle 2.10.0 与 Navigation 3。 文末附有15道配套习题与本课知识点总结。

目录

一、为什么需要跨平台 ViewModel

1.1 Compose 状态管理的局限

第8课中,我们使用 remember 和 rememberSaveable 在 Composable 内部管理状态。这种方式对于简单的局部 UI 状态完全够用。但当状态涉及业务逻辑、异步数据加载、跨屏幕共享时,将状态放在 Composable 中会带来三个问题:

  • 生命周期绑定:remember 的状态与组合生命周期绑定,配置更改或进程终止后可能丢失
  • 测试困难:UI 逻辑与状态逻辑混合,难以独立测试
  • 复用困难:业务逻辑无法在 Android、iOS、Desktop、Web 之间共享

ViewModel 将状态持有和业务逻辑从 UI 层抽离,提供与平台生命周期感知的、可测试的、可跨平台共享的状态管理单元。

下面是 CMP 中 ViewModel 的架构定位示意图:

mermaid diagram

1.2 CMP 中 ViewModel 的定位与架构边界

从架构层面看,CMP 的 ViewModel 处于 UI 层与领域层之间的边界位置。官方架构边界指南指出,ViewModel 的职责是:持有屏幕级状态、暴露状态流、接收事件、调用领域层(UseCase/Repository),不直接依赖平台 API,不持有 UI 框架类型。

这一边界约束是跨平台架构的核心原则。如果 ViewModel 中出现了 Context、UIApplication、window 等平台类型,共享层就失去了跨平台能力。

JetBrains 为 Lifecycle、ViewModel、Navigation 等 AndroidX 库构建了多平台产物,以统一的 group ID 发布。Android 端使用原生 Android 产物,iOS 端使用 JetBrains 构建的对应 iOS 产物,Desktop 和 Web 端同理,映射由 Compose Multiplatform Gradle 插件自动处理。

二、依赖配置与基础用法

2.1 添加依赖

在 KMP 模块的 build.gradle.kts 中,将依赖添加到 commonMain 源集:

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

如果项目包含 Desktop 目标,还需要添加 kotlinx-coroutines-swing 依赖,因为 ViewModel.viewModelScope 绑定到 Dispatchers.Main.immediate,而 Desktop 默认可能没有可用的 Main 调度器。

2.2 定义 ViewModel 与架构分层

在 commonMain 中声明 ViewModel,遵循 ViewModel → UseCase → Repository 的分层结构:

// Repository 层:数据源抽象
interface OrderRepository {
    suspend fun getPickupOptions(): List<PickupOption>
    suspend fun submitOrder(order: Order): Result<Unit>
}

// UseCase 层(可选,复杂业务时引入)
class SubmitOrderUseCase(private val repository: OrderRepository) {
    suspend operator fun invoke(order: Order): Result<Unit> = repository.submitOrder(order)
}

// ViewModel 层:屏幕状态持有与事件处理
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 在 Composable 中获取 ViewModel

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

下面是 ViewModel 的完整分层与数据流示意图:

mermaid diagram

三、非 JVM 平台的初始化器约束

3.1 为什么不能无参调用 viewModel()

在 Android 平台上,viewModel<MyViewModel>() 可以通过类型反射自动实例化 ViewModel。但在非 JVM 平台(iOS、Wasm/JS)上,类型反射不可用,因此不能通过反射创建实例。官方文档明确指出:“在非 JVM 平台上,对象无法使用类型反射进行实例化。因此在通用代码中,不能不带参数地调用 viewModel() 函数:每次创建 ViewModel 实例时,至少需要提供一个初始化器作为参数”。

3.2 正确与错误的写法

// ✅ 正确:提供初始化器
val vm = viewModel { MyViewModel() }

// ❌ 错误:在 commonMain 中无参调用,非 JVM 平台会失败
val vm = viewModel<MyViewModel>()

这是 CMP ViewModel 使用中最常见的陷阱——如果忘记提供初始化器,运行时会在非 JVM 平台崩溃,且崩溃信息可能不够直观。建议在代码审查中将无参 viewModel() 调用作为禁止模式。

3.3 带依赖注入的初始化器

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

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

下面是 ViewModel 初始化器在不同平台上的行为对比:

mermaid diagram

四、ViewModel 的作用域与 ViewModelStoreOwner

4.1 ViewModelStoreOwner 的角色

ViewModelStoreOwner 是 ViewModel 作用域的管理者,持有 ViewModelStore,负责 ViewModel 的创建、缓存和清理。当 ViewModelStoreOwner 被销毁时,其中所有 ViewModel 的 onCleared() 被调用。

4.2 默认作用域的陷阱

在 CMP 中,默认的 ViewModelStoreOwner 与组合的生命周期绑定。但在典型的单 Activity Compose 应用中,viewModel() 解析的是最近的 ViewModelStoreOwner,这意味着 ViewModel 默认作用域限定到 Activity,而非单个导航目的地。当用户在屏幕之间导航时,ViewModel 不会被清理,返回时状态仍然存在——这可能是期望的行为,也可能导致内存泄漏。

4.3 Navigation 3 集成:将 ViewModel 限定到 NavEntry

这是 CMP 架构中最重要的作用域实践。要为每个导航条目限定 ViewModel 和可保存的 Compose 状态,必须在定义导航目的地时向 NavDisplay 传递 Navigation 3 条目装饰器。

首先添加导航支持构件:

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

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

然后在 NavDisplay 中配置装饰器:

NavDisplay(
    backStack = backStack,
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator(), // 作用域限定到每个 entry
    ),
    entryProvider = entryProvider { /* ... */ }
)

ViewModelStoreNavEntryDecorator 为 NavEntry 的内容提供 ViewModelStoreOwner,并将其作为 LocalViewModelStoreOwner 提供给内容使用。Navigation 3 集成中,该装饰器现在接受 ViewModelStoreOwner,以便将 CreationExtras 和工厂传播到嵌套的 ViewModel。

关键约束:ViewModelStoreNavEntryDecorator 需要配合 SaveableStateHolderNavEntryDecorator 使用,以确保 NavEntry 作用域的 ViewModel 能够正确访问 SavedStateHandle。

4.4 自定义作用域

从 Lifecycle 2.10.0 开始,rememberViewModelStoreOwner 可创建与当前 Composable 作用域绑定的 ViewModelStoreOwner。Lifecycle 2.11.0-alpha01 进一步引入了 ViewModelStoreProvider,允许创建子作用域——这些子作用域在配置更改后存活,但可以被独立清理,同时提供继承父级默认工厂和创建参数的包装器。

重要限制:在该作用域中创建的 ViewModel 不支持保存状态。如果需要保存状态,必须将作用域限定到导航条目或使用 ViewModel 外的状态保存机制。

下面是 ViewModel 作用域与 ViewModelStoreOwner 的关系图:

mermaid diagram

五、单一状态对象模式

5.1 为什么用单一 data class 而非多个 StateFlow

// ❌ 多个独立状态,可能产生不一致的中间状态
class BadViewModel : ViewModel() {
    val isLoading: StateFlow<Boolean> = ...
    val items: StateFlow<List<Item>> = ...
    val error: StateFlow<String?> = ...
}

多个状态流在更新时可能产生瞬间的不一致——例如 isLoading 已变为 false,但 items 还未更新,UI 会短暂显示空列表。

// ✅ 单一状态对象
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 的设计原则

  • 不可变性:UiState 应为 data class,所有属性为 val,通过 copy() 产生新实例
  • 只含基本不可变数据:不应包含 Flow 或可变集合
  • 默认值:为所有属性提供合理的默认值,使初始状态可构造
  • @Immutable 标注:帮助 Compose 编译器推断类型稳定性,减少不必要的重组

下面是单一状态对象模式与多 StateFlow 模式的对比:

mermaid diagram

六、事件接收模式

6.1 多个回调 lambda 的问题

对于简单屏幕,直接传递回调 lambda 是可接受的。但当屏幕包含大量交互时,参数列表会变得冗长,且难以追踪每个事件的来源。

6.2 密封接口事件模式

对于复杂屏幕,推荐使用密封接口定义所有用户事件,通过单一的 onEvent() 函数接收:

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

优势:when 表达式强制穷尽所有事件类型;单一入口简化状态管理;事件与状态解耦便于测试。

6.3 一次性事件(Effect):Channel vs SharedFlow 的平台差异

导航、Snackbar、触觉反馈等一次性副作用不应放在 UiState 中,因为它们不应在配置更改后重放。在 CMP 中,SharedFlow 的 replay 缓存在 iOS 上存在已知的严重问题——导航事件会在返回时被意外重放。

// ✅ 推荐:Channel 确保每个事件只被消费一次
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)) }
        }
    }
}

根因分析:iOS 的快照系统在渲染循环中的读取时机与 Android 不同,且没有与 Android 等效的生命周期事件来触发订阅者丢弃。Android 的 collectAsStateWithLifecycle 与快照系统协调得足够紧密,能够防止这类问题,而 iOS 的集成则不够紧密。

七、状态收集的平台差异与最佳实践

7.1 collectAsState 与 collectAsStateWithLifecycle

在 CMP 的 commonMain 中,推荐使用 collectAsStateWithLifecycle() 收集状态流:

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

collectAsStateWithLifecycle() 是生命周期感知的,在 Composable 进入 RESUMED 状态时才开始收集流。collectAsState() 不具备生命周期感知。

平台差异的准确理解:Android 上 collectAsStateWithLifecycle 与快照系统紧密协调,能可靠地防止不一致。iOS 端的集成则不够紧密——Recomposition 触发在 iOS 上使用 Compose for iOS 的快照 + 手动收集,快照读取发生在渲染循环的不同节点。此外,iOS 上没有生命周期事件来触发订阅者丢弃。

7.2 状态收集的跨平台封装

对于需要在 commonMain 中统一状态收集行为的场景,可以通过 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 用 Scheduler 管理协程调度

推荐注入 CoroutineDispatcher 或 CoroutineScope,而非硬编码 Dispatchers.Main:

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

在 Desktop 上,viewModelScope 使用 Dispatchers.Main.immediate,需要 kotlinx-coroutines-swing。注入调度器使 ViewModel 可在测试中替换为 UnconfinedTestDispatcher。

八、生产级实践:依赖注入与测试

8.1 Koin 依赖注入

Koin 4.x 是 CMP 中最流行的依赖注入框架。通过 @KoinViewModel 注解和编译器插件,ViewModel 自动注册和注入:

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

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

@KoinViewModel 将类注册为具有正确生命周期处理的 ViewModel,构造函数中声明的依赖由编译器插件自动注入。koinViewModel() 自动解析 ViewModel 及其整条依赖链。如果 ViewModel 需要自己的作用域依赖,使用 viewModelScope 原型——在该作用域内声明的依赖被绑定到 ViewModel 的生命周期。

8.2 ViewModel 测试策略

ViewModel 测试写在 commonTest 中。用手写的 Fake 实现替代 Mock 框架是 Kotlin 社区的推荐做法,原因有三:Fake 不依赖反射,在 KMP 所有目标上均可运行;Fake 的行为更接近真实实现,测试更可靠;ViewModel 通过构造函数接收 Repository,测试时注入 Fake 即可,不需要依赖注入容器或反射。

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

测试要点:注入 UnconfinedTestDispatcher 使协程同步执行;使用 Fake Repository 而非 Mock;断言 StateFlow.value 而非等待流发射;ViewModel 测试是每个功能的最低测试覆盖单元。

九、跨平台架构注意事项

9.1 iOS 生命周期差异

这是 CMP ViewModel 架构中最重要的平台差异。在 iOS 上,将应用切换到后台不会导致 ViewModel 的 viewModelScope 被取消——协程会继续运行。如果需要在应用进入后台时暂停工作,需要监听生命周期变化来主动管理。

iOS 原生事件与 Lifecycle 状态的映射关系如下:

iOS 原生事件Lifecycle 状态变化
viewDidDisappearON_STOP:STARTED → CREATED
viewWillAppearON_START:CREATED → STARTED
didBecomeActiveON_RESUME
willEnterForegroundCREATED → DESTROYED

这意味着 viewModelScope 在 iOS 上不会因应用进入后台而被取消,开发者需要根据业务需求显式管理协程的生命周期。

9.2 平台特定 API 的隔离

ViewModel 中不能直接使用平台特定 API。如果 ViewModel 构造函数需要平台特定的依赖,应通过接口抽象:

// commonMain 中定义接口
interface PlatformLogger {
    fun log(message: String)
}

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

9.3 与 SwiftUI 的互操作

当 CMP 共享 ViewModel 与 SwiftUI 混合使用时,ViewModel 需要被导出到 iOS 二进制框架。SwiftUI 没有内置的 ViewModel 等价物,因此需要通过 ObservableObject 或 @Observing 包装器桥接 Kotlin 的 StateFlow 到 SwiftUI 的 @Published 属性。

配套习题(15题)

第一组:依赖配置与初始化器(4题)

第1题 CMP 中跨平台 ViewModel 的依赖构件是( )

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

答案:B

解析:CMP 通过 org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose 提供多平台 ViewModel 实现。

第2题 在 CMP 的 commonMain 中,以下哪种 ViewModel 获取方式是正确的?( )

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

答案:B

解析:在非 JVM 平台(iOS、Wasm)上,类型反射不可用,因此不能无参调用 viewModel()。必须提供初始化器:viewModel { OrderViewModel() }。

第3题 为什么 CMP 要求 ViewModel 的创建必须提供初始化器?( )

A. 为了提高性能 B. 非 JVM 平台不支持类型反射来实例化对象 C. 这是 Compose 编译器的要求 D. 为了支持依赖注入

答案:B

解析:非 JVM 平台(如 iOS、Wasm/JS)不支持类型反射,无法通过 viewModel<MyViewModel>() 自动实例化 ViewModel。

第4题 如果项目包含 Desktop 目标,除了 ViewModel 依赖外还需要添加什么依赖?( )

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

答案:B

解析:ViewModel.viewModelScope 绑定到 Dispatchers.Main.immediate,而 Desktop 默认可能没有可用的 Main 调度器。kotlinx-coroutines-swing 提供了 Swing 事件调度器。

第二组:状态模式与事件模式(4题)

第5题 使用单一 data class 而非多个独立 StateFlow 暴露 UiState 的核心优势是( )

A. 减少代码量 B. 避免多个状态流产生不一致的中间状态 C. 提高网络请求速度 D. 自动保存状态到 Bundle

答案:B

解析:多个独立状态流在更新时可能产生瞬间的不一致,单一不可变 data class 通过一个 StateFlow 暴露,保证状态更新是原子的。

第6题 在复杂屏幕中,使用密封接口定义事件的优势不包括( )

A. when 表达式强制穷尽所有事件类型 B. 单一 onEvent() 入口简化状态管理 C. 事件与状态解耦,便于测试 D. 自动生成事件处理代码

答案:D

解析:密封接口不会自动生成事件处理代码。其优势在于穷尽性检查、单一入口和事件与状态解耦。

第7题 导航、Snackbar 等一次性副作用在 CMP 中应通过什么方式暴露?( )

A. 放在 UiState 的 data class 中 B. 通过 Channel 暴露为 Flow C. 通过 mutableStateOf 暴露 D. 通过 SharedFlow 暴露

答案:B

解析:在 CMP 中,SharedFlow 的 replay 缓存在 iOS 上存在已知的严重问题——导航事件会在返回时被意外重放。Channel 的 receiveAsFlow() 是更安全的选择。

第8题 关于 UiState 的设计原则,以下说法错误的是( )

A. UiState 应为 data class,所有属性为 val B. UiState 可以包含 Flow 类型的属性 C. UiState 应只包含基本类型和不可变模型 D. @Immutable 标注帮助 Compose 编译器推断类型稳定性

答案:B

解析:UiState 应只包含基本不可变数据,不应包含 Flow 或可变集合。

第三组:作用域与状态收集(4题)

第9题 在 CMP 中,默认的 ViewModelStoreOwner 与什么绑定?( )

A. 应用的全局生命周期 B. 组合的生命周期 C. 系统进程的生命周期 D. 网络连接的生命周期

答案:B

解析:默认的 ViewModelStoreOwner 与组合的生命周期绑定。但在单 Activity Compose 应用中,viewModel() 解析的是最近的 ViewModelStoreOwner,意味着 ViewModel 默认作用域限定到 Activity。

第10题 在 Navigation 3 中,将 ViewModel 作用域限定到导航条目的方式是( )

A. 使用 remember { ViewModel() } B. 在 NavDisplay 中配置 rememberViewModelStoreNavEntryDecorator() C. 使用 viewModel { } 初始化器 D. 使用 ViewModelStoreProvider

答案:B

解析:要为每个导航条目限定 ViewModel,必须在定义导航目的地时向 NavDisplay 传递 Navigation 3 条目装饰器。

第11题 ViewModelStoreNavEntryDecorator 需要配合哪个装饰器使用以确保 ViewModel 能正确访问 SavedStateHandle?( )

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

答案:A

解析:ViewModelStoreNavEntryDecorator 需要配合 SaveableStateHolderNavEntryDecorator 使用,以确保 NavEntry 作用域的 ViewModel 能够正确访问 SavedStateHandle。

第12题 在 iOS 上使用 SharedFlow 暴露导航事件可能导致什么问题?( )

A. 事件丢失 B. 导航事件在返回时被意外重放 C. 编译错误 D. 性能下降

答案:B

解析:iOS 的快照系统在渲染循环中的读取时机与 Android 不同,且没有与 Android 等效的生命周期事件来触发订阅者丢弃,导致 SharedFlow 的 replay 缓存在 iOS 上会意外重放导航事件。

第四组:生产级实践与平台差异(3题)

第13题 Koin 中注册 ViewModel 的注解是( )

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

答案:B

解析:Koin 通过 @KoinViewModel 注解注册 ViewModel,编译器插件自动处理依赖注入。

第14题 ViewModel 测试中,推荐使用的协程调度器是( )

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

答案:C

解析:在 ViewModel 测试中,注入 UnconfinedTestDispatcher() 使协程同步执行,避免测试中的异步等待。

第15题 在 iOS 上,将应用切换到后台时,viewModelScope 中的协程会( )

A. 被自动取消 B. 继续运行,不会被取消 C. 暂停执行 D. 抛出异常

答案:B

解析:iOS 原生事件与 Lifecycle 状态的映射中,没有与 Android ON_STOP 等效的取消机制。将应用切换到后台不会导致 viewModelScope 被取消,协程会继续运行。

本课知识点总结

1. 跨平台 ViewModel 的依赖与架构定位

CMP 通过 org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0 提供多平台 ViewModel 实现。ViewModel 处于 UI 层与领域层之间的边界位置,职责是持有屏幕状态、暴露状态流、接收事件、调用领域层,不直接依赖平台 API。包含 Desktop 目标时需额外添加 kotlinx-coroutines-swing 依赖。

2. 非 JVM 平台的初始化器约束

在非 JVM 平台(iOS、Wasm/JS)上,类型反射不可用,因此不能无参调用 viewModel<MyViewModel>(),必须提供初始化器:viewModel { MyViewModel() }。这是 CMP ViewModel 使用中最常见的陷阱,忘记提供初始化器会在非 JVM 平台运行时崩溃。

3. 单一状态对象与事件模式

使用单一不可变 data class 表达屏幕完整状态,通过一个 StateFlow 暴露,避免多个独立状态流产生不一致的中间状态。复杂屏幕使用密封接口定义事件,通过单一 onEvent() 接收。在 CMP 中,一次性副作用必须使用 Channel 而非 SharedFlow——SharedFlow 的 replay 缓存在 iOS 上会导致导航事件意外重放。

4. ViewModel 作用域与 Navigation 3 集成

默认 ViewModel 作用域限定到 Activity。导航场景中必须通过 rememberViewModelStoreNavEntryDecorator() 将 ViewModel 限定到 NavEntry,并配合 SaveableStateHolderNavEntryDecorator 使用以确保 SavedStateHandle 可用。ViewModelStoreProvider(Lifecycle 2.11.0-alpha01+)允许创建子作用域,但该作用域中的 ViewModel 不支持保存状态。

5. 状态收集与 iOS 生命周期差异

在 commonMain 中推荐使用 collectAsStateWithLifecycle()。Android 上该 API 与快照系统紧密协调,iOS 端集成不够紧密——快照读取发生在渲染循环的不同节点,且没有生命周期事件触发订阅者丢弃。iOS 上应用进入后台不会取消 viewModelScope 协程,需要根据业务需求显式管理。

6. 生产级实践

Koin 4.x 通过 @KoinViewModel 注解和 koinViewModel() 函数实现自动注册和注入,viewModelScope 原型支持 ViewModel 作用域依赖。ViewModel 测试写在 commonTest 中,使用 UnconfinedTestDispatcher 使协程同步执行,用手写 Fake 替代 Mock 框架是 KMP 社区的推荐做法。ViewModel 中不能直接使用平台特定 API,应通过接口抽象。


下一课预告:第10课将深入 Compose Multiplatform 导航——Navigation 3 实战,包括 Navigation 3 的设计哲学(用户拥有的返回栈、低层构建块、自适应布局系统),从 Navigation 2 到 Navigation 3 的迁移思路,基本导航模式与深层链接的实现,跨平台返回手势处理,以及自适应多窗格布局与 Navigation 3 的结合。