第8课完成了 Compose 状态管理基础的学习。本课将状态管理从组件级提升到应用架构级:深入跨平台 ViewModel 的依赖配置与初始化器约束,ViewModelStoreOwner 的作用域管理,单一状态对象模式与事件接收模式的设计,状态收集的平台差异处理,以及生产级实践中的依赖注入与测试策略。本课内容基于 CMP 1.12.1、Lifecycle 2.10.0 与 Navigation 3。 文末附有15道配套习题与本课知识点总结。
目录
- [一、为什么需要跨平台 ViewModel](#一、为什么需要跨平台 ViewModel "#%E4%B8%80%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%E8%B7%A8%E5%B9%B3%E5%8F%B0-viewmodel")
- [1.1 Compose 状态管理的局限](#1.1 Compose 状态管理的局限 "#11-compose-%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86%E7%9A%84%E5%B1%80%E9%99%90")
- [1.2 CMP 中 ViewModel 的定位与架构边界](#1.2 CMP 中 ViewModel 的定位与架构边界 "#12-cmp-%E4%B8%AD-viewmodel-%E7%9A%84%E5%AE%9A%E4%BD%8D%E4%B8%8E%E6%9E%B6%E6%9E%84%E8%BE%B9%E7%95%8C")
- 二、依赖配置与基础用法
- [2.1 添加依赖](#2.1 添加依赖 "#21-%E6%B7%BB%E5%8A%A0%E4%BE%9D%E8%B5%96")
- [2.2 定义 ViewModel 与架构分层](#2.2 定义 ViewModel 与架构分层 "#22-%E5%AE%9A%E4%B9%89-viewmodel-%E4%B8%8E%E6%9E%B6%E6%9E%84%E5%88%86%E5%B1%82")
- [2.3 在 Composable 中获取 ViewModel](#2.3 在 Composable 中获取 ViewModel "#23-%E5%9C%A8-composable-%E4%B8%AD%E8%8E%B7%E5%8F%96-viewmodel")
- [三、非 JVM 平台的初始化器约束](#三、非 JVM 平台的初始化器约束 "#%E4%B8%89%E9%9D%9E-jvm-%E5%B9%B3%E5%8F%B0%E7%9A%84%E5%88%9D%E5%A7%8B%E5%8C%96%E5%99%A8%E7%BA%A6%E6%9D%9F")
- [3.1 为什么不能无参调用 viewModel()](#3.1 为什么不能无参调用 viewModel() "#31-%E4%B8%BA%E4%BB%80%E4%B9%88%E4%B8%8D%E8%83%BD%E6%97%A0%E5%8F%82%E8%B0%83%E7%94%A8-viewmodel")
- [3.2 正确与错误的写法](#3.2 正确与错误的写法 "#32-%E6%AD%A3%E7%A1%AE%E4%B8%8E%E9%94%99%E8%AF%AF%E7%9A%84%E5%86%99%E6%B3%95")
- [3.3 带依赖注入的初始化器](#3.3 带依赖注入的初始化器 "#33-%E5%B8%A6%E4%BE%9D%E8%B5%96%E6%B3%A8%E5%85%A5%E7%9A%84%E5%88%9D%E5%A7%8B%E5%8C%96%E5%99%A8")
- [四、ViewModel 的作用域与 ViewModelStoreOwner](#四、ViewModel 的作用域与 ViewModelStoreOwner "#%E5%9B%9Bviewmodel-%E7%9A%84%E4%BD%9C%E7%94%A8%E5%9F%9F%E4%B8%8E-viewmodelstoreowner")
- [4.1 ViewModelStoreOwner 的角色](#4.1 ViewModelStoreOwner 的角色 "#41-viewmodelstoreowner-%E7%9A%84%E8%A7%92%E8%89%B2")
- [4.2 默认作用域的陷阱](#4.2 默认作用域的陷阱 "#42-%E9%BB%98%E8%AE%A4%E4%BD%9C%E7%94%A8%E5%9F%9F%E7%9A%84%E9%99%B7%E9%98%B1")
- [4.3 Navigation 3 集成:将 ViewModel 限定到 NavEntry](#4.3 Navigation 3 集成:将 ViewModel 限定到 NavEntry "#43-navigation-3-%E9%9B%86%E6%88%90%E5%B0%86-viewmodel-%E9%99%90%E5%AE%9A%E5%88%B0-naventry")
- [4.4 自定义作用域](#4.4 自定义作用域 "#44-%E8%87%AA%E5%AE%9A%E4%B9%89%E4%BD%9C%E7%94%A8%E5%9F%9F")
- 五、单一状态对象模式
- [5.1 为什么用单一 data class 而非多个 StateFlow](#5.1 为什么用单一 data class 而非多个 StateFlow "#51-%E4%B8%BA%E4%BB%80%E4%B9%88%E7%94%A8%E5%8D%95%E4%B8%80-data-class-%E8%80%8C%E9%9D%9E%E5%A4%9A%E4%B8%AA-stateflow")
- [5.2 UiState 的设计原则](#5.2 UiState 的设计原则 "#52-uistate-%E7%9A%84%E8%AE%BE%E8%AE%A1%E5%8E%9F%E5%88%99")
- 六、事件接收模式
- [6.1 多个回调 lambda 的问题](#6.1 多个回调 lambda 的问题 "#61-%E5%A4%9A%E4%B8%AA%E5%9B%9E%E8%B0%83-lambda-%E7%9A%84%E9%97%AE%E9%A2%98")
- [6.2 密封接口事件模式](#6.2 密封接口事件模式 "#62-%E5%AF%86%E5%B0%81%E6%8E%A5%E5%8F%A3%E4%BA%8B%E4%BB%B6%E6%A8%A1%E5%BC%8F")
- [6.3 一次性事件(Effect):Channel vs SharedFlow 的平台差异](#6.3 一次性事件(Effect):Channel vs SharedFlow 的平台差异 "#63-%E4%B8%80%E6%AC%A1%E6%80%A7%E4%BA%8B%E4%BB%B6effectchannel-vs-sharedflow-%E7%9A%84%E5%B9%B3%E5%8F%B0%E5%B7%AE%E5%BC%82")
- 七、状态收集的平台差异与最佳实践
- [7.1 collectAsState 与 collectAsStateWithLifecycle](#7.1 collectAsState 与 collectAsStateWithLifecycle "#71-collectasstate-%E4%B8%8E-collectasstatewithlifecycle")
- [7.2 状态收集的跨平台封装](#7.2 状态收集的跨平台封装 "#72-%E7%8A%B6%E6%80%81%E6%94%B6%E9%9B%86%E7%9A%84%E8%B7%A8%E5%B9%B3%E5%8F%B0%E5%B0%81%E8%A3%85")
- [7.3 用 Scheduler 管理协程调度](#7.3 用 Scheduler 管理协程调度 "#73-%E7%94%A8-scheduler-%E7%AE%A1%E7%90%86%E5%8D%8F%E7%A8%8B%E8%B0%83%E5%BA%A6")
- 八、生产级实践:依赖注入与测试
- [8.1 Koin 依赖注入](#8.1 Koin 依赖注入 "#81-koin-%E4%BE%9D%E8%B5%96%E6%B3%A8%E5%85%A5")
- [8.2 ViewModel 测试策略](#8.2 ViewModel 测试策略 "#82-viewmodel-%E6%B5%8B%E8%AF%95%E7%AD%96%E7%95%A5")
- 九、跨平台架构注意事项
- [9.1 iOS 生命周期差异](#9.1 iOS 生命周期差异 "#91-ios-%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E5%B7%AE%E5%BC%82")
- [9.2 平台特定 API 的隔离](#9.2 平台特定 API 的隔离 "#92-%E5%B9%B3%E5%8F%B0%E7%89%B9%E5%AE%9A-api-%E7%9A%84%E9%9A%94%E7%A6%BB")
- [9.3 与 SwiftUI 的互操作](#9.3 与 SwiftUI 的互操作 "#93-%E4%B8%8E-swiftui-%E7%9A%84%E4%BA%92%E6%93%8D%E4%BD%9C")
- 配套习题(15题)
- 本课知识点总结
一、为什么需要跨平台 ViewModel
1.1 Compose 状态管理的局限
第8课中,我们使用 remember 和 rememberSaveable 在 Composable 内部管理状态。这种方式对于简单的局部 UI 状态完全够用。但当状态涉及业务逻辑、异步数据加载、跨屏幕共享时,将状态放在 Composable 中会带来三个问题:
- 生命周期绑定 :
remember的状态与组合生命周期绑定,配置更改或进程终止后可能丢失 - 测试困难:UI 逻辑与状态逻辑混合,难以独立测试
- 复用困难:业务逻辑无法在 Android、iOS、Desktop、Web 之间共享
ViewModel 将状态持有和业务逻辑从 UI 层抽离,提供与平台生命周期感知的、可测试的、可跨平台共享的状态管理单元。
下面是 CMP 中 ViewModel 的架构定位示意图:

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 源集:
toml
[versions]
androidx-viewmodel = "2.10.0"
[libraries]
androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-viewmodel" }
kotlin
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 的分层结构:
kotlin
// 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
kotlin
@Composable
fun OrderScreen(
viewModel: OrderViewModel = viewModel { OrderViewModel(orderRepository, submitOrder) },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
OrderContent(state = state, onEvent = viewModel::onEvent)
}
下面是 ViewModel 的完整分层与数据流示意图:

三、非 JVM 平台的初始化器约束
3.1 为什么不能无参调用 viewModel()
在 Android 平台上,viewModel<MyViewModel>() 可以通过类型反射自动实例化 ViewModel。但在非 JVM 平台(iOS、Wasm/JS)上,类型反射不可用 ,因此不能通过反射创建实例。官方文档明确指出:"在非 JVM 平台上,对象无法使用类型反射进行实例化。因此在通用代码中,不能不带参数地调用 viewModel() 函数:每次创建 ViewModel 实例时,至少需要提供一个初始化器作为参数"。
3.2 正确与错误的写法
kotlin
// ✅ 正确:提供初始化器
val vm = viewModel { MyViewModel() }
// ❌ 错误:在 commonMain 中无参调用,非 JVM 平台会失败
val vm = viewModel<MyViewModel>()
这是 CMP ViewModel 使用中最常见的陷阱 ------如果忘记提供初始化器,运行时会在非 JVM 平台崩溃,且崩溃信息可能不够直观。建议在代码审查中将无参 viewModel() 调用作为禁止模式。
3.3 带依赖注入的初始化器
kotlin
class OrderViewModel(
private val orderRepository: OrderRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() { /* ... */ }
// 在 Composable 中
val viewModel: OrderViewModel = viewModel {
OrderViewModel(
orderRepository = repository,
savedStateHandle = createSavedStateHandle(),
)
}
下面是 ViewModel 初始化器在不同平台上的行为对比:

四、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 条目装饰器。
首先添加导航支持构件:
toml
[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 中配置装饰器:
kotlin
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 的关系图:

五、单一状态对象模式
5.1 为什么用单一 data class 而非多个 StateFlow
kotlin
// ❌ 多个独立状态,可能产生不一致的中间状态
class BadViewModel : ViewModel() {
val isLoading: StateFlow<Boolean> = ...
val items: StateFlow<List<Item>> = ...
val error: StateFlow<String?> = ...
}
多个状态流在更新时可能产生瞬间的不一致------例如 isLoading 已变为 false,但 items 还未更新,UI 会短暂显示空列表。
kotlin
// ✅ 单一状态对象
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 模式的对比:

六、事件接收模式
6.1 多个回调 lambda 的问题
对于简单屏幕,直接传递回调 lambda 是可接受的。但当屏幕包含大量交互时,参数列表会变得冗长,且难以追踪每个事件的来源。
6.2 密封接口事件模式
对于复杂屏幕,推荐使用密封接口 定义所有用户事件,通过单一的 onEvent() 函数接收:
kotlin
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 上存在已知的严重问题------导航事件会在返回时被意外重放。
kotlin
// ✅ 推荐: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() 收集状态流:
kotlin
@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 封装:
kotlin
// 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:
kotlin
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 自动注册和注入:
kotlin
@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 即可,不需要依赖注入容器或反射。
kotlin
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 状态变化 |
|---|---|
viewDidDisappear |
ON_STOP:STARTED → CREATED |
viewWillAppear |
ON_START:CREATED → STARTED |
didBecomeActive |
ON_RESUME |
willEnterForeground |
CREATED → DESTROYED |
这意味着 viewModelScope 在 iOS 上不会因应用进入后台而被取消,开发者需要根据业务需求显式管理协程的生命周期。
9.2 平台特定 API 的隔离
ViewModel 中不能直接使用平台特定 API。如果 ViewModel 构造函数需要平台特定的依赖,应通过接口抽象:
kotlin
// 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 的结合。