【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
本课目标:理解 ViewModel 在 CMP 中的跨平台实现与生命周期管理,掌握 StateFlow 与 Compose 状态收集的正确方式,学会用 Event Sink 模式收敛事件入口,理解一次性事件与持久状态的区分,建立 MVVM 分层架构的完整认知。
系列整体规划
| 课次 | 主题 | 核心内容 | 难度 |
|---|---|---|---|
| 第1课 | 从零开始 | 技术概览、环境搭建、第一个应用、代码解读 | ⭐ |
| 第2课 | Compose 基础语法 | @Composable、状态管理、重组机制、Modifier 体系 | ⭐⭐ |
| 第3课 | 布局与组件 | Column/Row/Box、LazyColumn、Material3 组件库 | ⭐⭐ |
| 第4课 | 导航与路由 | Navigation Compose、类型安全路由、深层链接 | ⭐⭐⭐ |
| 第5课 | 网络与数据层 | Ktor 客户端、序列化、Repository 模式 | ⭐⭐⭐ |
| 第6课 | 状态管理与架构 | ViewModel、单向数据流、依赖注入 | ⭐⭐⭐⭐ |
| 第7课 | 平台适配与互操作 | expect/actual、SwiftUI 互操作、平台特定 API | ⭐⭐⭐⭐ |
| 第8课 | 资源管理与主题 | 多平台资源、图片加载、深浅色主题 | ⭐⭐⭐ |
| 第9课 | 测试与调试 | Compose UI 测试、单元测试、性能分析 | ⭐⭐⭐⭐ |
| 第10课 | 发布与部署 | Android/iOS/桌面/Web 打包发布、CI/CD | ⭐⭐⭐⭐⭐ |
第6课 状态管理与架构
一、为什么需要 ViewModel
1.1 从第5课的遗留问题说起
第5课结束时,我们在 Composable 中直接调用了 Repository,用 remember { mutableStateOf() } 管理加载状态。这个方案能跑,但存在几个结构性问题。
状态与 UI 耦合。isLoading、error、posts 这些状态分散在 Composable 中,和 UI 渲染逻辑混在一起。当页面逻辑变复杂时,Composable 会变得臃肿,一个屏幕的函数体可能长达数百行。
状态无法在配置更改后存活。remember 的状态在 Android 屏幕旋转、桌面端窗口大小调整后会丢失。虽然 rememberSaveable 可以解决一部分问题,但它只适合保存简单的可序列化数据,不适合保存完整的页面状态。想象一下用户在表单页填写了大量内容,屏幕旋转后所有输入丢失——这是不可接受的体验。
业务逻辑无法测试。网络请求、数据转换、状态校验这些逻辑写在 Composable 中,无法脱离 Compose 运行时进行单元测试。测试一个 Composable 需要启动完整的 Compose 环境,成本远高于测试一个普通类。
状态修改入口分散。任何持有状态引用的代码都可以直接修改它,缺少统一的“修改入口”来保证状态变更的一致性和可追溯性。当出现 bug 时,很难定位是哪个代码路径修改了状态。
ViewModel 正是为了解决这些问题而存在的。
1.2 ViewModel 在 CMP 中的定位
在 Android 开发中,ViewModel 是 androidx.lifecycle 库的一部分,与 Activity/Fragment 的生命周期绑定。在 CMP 中,JetBrains 将 ViewModel 抽象为跨平台组件,通过 org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose 提供公共实现。
CMP 的 ViewModel 与 Android 版本在 API 层面几乎一致,但有两个关键差异:
非 JVM 平台不支持反射。在 wasmJs 和 iOS 上,无法通过类型反射自动创建 ViewModel 实例。因此,在 commonMain 中调用 viewModel() 时必须显式提供初始化器:
val viewModel: CounterViewModel = viewModel { CounterViewModel() }
如果忘记提供初始化器,运行时会在非 JVM 平台崩溃。这是 CMP ViewModel 使用中最常见的陷阱,且崩溃只发生在 iOS 或 Web 上,Android 上一切正常,非常隐蔽。
桌面端需要额外依赖。ViewModel.viewModelScope 绑定到 Dispatchers.Main.immediate,桌面端默认可能没有这个调度器。需要添加 kotlinx-coroutines-swing 依赖来保证协程正常工作。
1.3 ViewModel 的生命周期优势
ViewModel 的生命周期比 Composable 更长。当 Composable 因重组而重新执行时,ViewModel 的实例保持不变,其中的状态得以保留。
这个特性在导航场景中尤为重要。假设用户从列表页导航到详情页,再返回列表页。如果列表页的状态存在 ViewModel 中,返回时状态完好;如果存在 remember 中,返回时状态已丢失,用户需要重新加载列表、重新滚动到之前的位置。
理解 ViewModel 的生命周期边界很关键:ViewModel 在第一次调用 viewModel() 时创建,在所属的 ViewModelStoreOwner 被清理时销毁。在 Android 上,这通常对应 Activity 或 Fragment 的销毁;在 CMP 中,与导航条目或应用的生命周期对齐。
一个关键的设计原则:ViewModel 不应该持有任何与 UI 框架相关的引用(如 Context、NavController)。ViewModel 只关心数据和逻辑,UI 相关的东西通过事件回传给 Composable 处理。
二、状态模型设计
2.1 单一状态对象
第2课介绍了“状态容器”的概念,ViewModel 是它的正式实现。一个核心设计原则是:用一个不可变的数据类封装屏幕的全部状态。
data class ItemListState(
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val searchQuery: String = "",
)
为什么用一个对象而不是多个独立的 mutableStateOf?
原子性。当 isLoading 从 true 变为 false,同时 items 被填充,这两个变化应该被视为一个整体。如果用多个独立状态,中间可能有一帧显示 isLoading = false 但 items 还是空的,导致 UI 闪烁——用户会看到“加载完成但列表为空”的瞬间,然后再看到数据出现。
可测试性。一个状态对象可以被完整地断言和快照。测试时只需要比较两个状态对象是否相等,而不需要分别验证多个独立状态。
可追溯性。状态变化通过 copy() 产生新对象,旧状态保持不变。调试时可以对比前后状态对象,清楚地看到哪些字段变化了。
2.2 StateFlow 作为状态暴露机制
ViewModel 内部用 MutableStateFlow 持有状态,对外暴露只读的 StateFlow:
class ItemListViewModel(
private val repository: ItemRepository,
) : ViewModel() {
private val _state = MutableStateFlow(ItemListState())
val state: StateFlow<ItemListState> = _state.asStateFlow()
fun onSearch(query: String) {
_state.update { it.copy(searchQuery = query) }
loadItems(query)
}
}
asStateFlow() 返回的只读视图阻止了外部直接修改状态。所有修改必须通过 ViewModel 的公开方法,这保证了单向数据流——状态只能由 ViewModel 修改,UI 只能读取和触发事件。
命名约定:内部可变状态用 _state,对外暴露的只读状态用 state。这个下划线前缀是 Kotlin 社区的事实标准,一眼就能区分“可修改的内部状态”和“只读的外部接口”。
2.3 在 Compose 中收集 StateFlow
在 Composable 中收集 StateFlow,使用 collectAsStateWithLifecycle():
@Composable
fun ItemListScreen(
viewModel: ItemListViewModel = viewModel { ItemListViewModel(repository) },
) {
val state by viewModel.state.collectAsStateWithLifecycle()
ItemListContent(
state = state,
onSearch = viewModel::onSearch,
)
}
collectAsStateWithLifecycle() 比普通的 collectAsState() 更安全——它在 Composable 不可见时(如页面被覆盖、应用进入后台)自动停止收集,避免不必要的重组和资源消耗。
一个实用的模式:把“有状态的 Composable”(连接 ViewModel)和“无状态的 Composable”(纯渲染)分开。ItemListScreen 负责连接 ViewModel,ItemListContent 只接收状态和回调。这样 ItemListContent 可以在 Preview 中独立渲染,也可以独立测试。
2.4 状态更新的一致性
MutableStateFlow.update {} 保证状态更新的原子性:
_state.update { it.copy(items = newItems, isLoading = false) }
如果分成两步:
_state.value = _state.value.copy(items = newItems)
_state.value = _state.value.copy(isLoading = false)
中间可能有一帧显示新数据但仍在加载状态,或者反过来。update {} 将两个修改合并为一个原子操作,避免了中间状态。
另一个细节:update {} 的 lambda 可能被多次执行(在并发场景下),因此 lambda 内不应该有副作用。它应该是纯函数——只根据旧状态计算新状态。
三、Event Sink 模式
3.1 多回调的问题
当一个屏幕有多个交互点时,Composable 需要接收多个回调:
ItemListContent(
state = state,
onSearch = viewModel::onSearch,
onDelete = viewModel::onDelete,
onRefresh = viewModel::onRefresh,
onItemClick = viewModel::onItemClick,
)
参数数量随功能增加而膨胀,且每个回调的签名可能不同。当回调数量超过 4-5 个时,函数签名变得难以维护。更糟糕的是,每次新增一个交互,都需要修改 Composable 的函数签名,导致调用链上所有相关的 Composable 都要改。
3.2 用密封接口收敛事件
Event Sink 模式将所有的交互事件收敛到一个密封接口中:
sealed interface ItemListEvent {
data class Search(val query: String) : ItemListEvent
data class Delete(val itemId: String) : ItemListEvent
data object Refresh : ItemListEvent
data class ItemClick(val itemId: String) : ItemListEvent
}
ViewModel 提供一个统一的事件处理方法:
fun onEvent(event: ItemListEvent) {
when (event) {
is ItemListEvent.Search -> onSearch(event.query)
is ItemListEvent.Delete -> onDelete(event.itemId)
ItemListEvent.Refresh -> onRefresh()
is ItemListEvent.ItemClick -> onItemClick(event.itemId)
}
}
Composable 只接收一个 onEvent: (ItemListEvent) -> Unit 参数:
ItemListContent(
state = state,
onEvent = viewModel::onEvent,
)
3.3 Event Sink 的优势
签名稳定。无论增加多少事件类型,onEvent 的签名不变。新增交互只需要在密封接口中加一个子类,Composable 的函数签名完全不动。
可追溯。所有事件通过一个入口进入 ViewModel,便于日志记录和埋点。在 onEvent 的 when 表达式开头加一行日志,就能记录所有用户交互。
可测试。ViewModel 的测试可以直接构造事件对象,不需要模拟多个回调。测试代码更简洁、更聚焦。
编译期安全。密封接口的 when 表达式要求穷尽所有分支,新增事件类型时编译器会提示遗漏。这是 Kotlin 密封类比普通接口或枚举更强大的地方——它既有枚举的穷尽性,又支持携带数据。
3.4 Event Sink 的适用边界
Event Sink 不是万能的。它适合交互点较多的复杂屏幕。对于一个只有一两个交互的简单屏幕(如纯展示页 + 一个返回按钮),直接用回调更直观。
判断标准:当回调数量超过 4 个,或者你发现每次新增功能都要改 Composable 签名时,就应该引入 Event Sink。
四、一次性事件与持久状态的区分
4.1 状态与事件的本质区别
在单向数据流架构中,有一个容易被忽略但非常重要的区分:状态(State)是持久的,事件(Event)是瞬态的。
状态是 UI 在任意时刻的完整描述——加载中、有数据、有错误。无论 UI 何时读取状态,它都能完整地描述当前应该显示什么。
事件是“发生了一件事”的通知——导航到主页、显示一个 Snackbar、播放一个动画。事件只在发生的瞬间有意义,被消费后就应该消失。
4.2 用状态处理导航的陷阱
考虑登录成功后的导航。如果把它作为状态的一部分:
data class LoginState(
val username: String = "",
val password: String = "",
val loginSuccess: Boolean = false, // 错误:把事件当状态
)
Composable 中:
if (state.loginSuccess) {
onNavigateToHome()
}
这个写法会导致重复导航。因为 loginSuccess 是持久状态,每次重组时它都为 true,onNavigateToHome() 会被反复调用。用户可能会发现导航栈中堆叠了多个主页实例。
4.3 用 Channel 暴露一次性事件
正确的做法是用 Channel 或 SharedFlow 暴露一次性事件:
sealed interface LoginUiEvent {
data object NavigateToHome : LoginUiEvent
}
class LoginViewModel : ViewModel() {
private val _events = Channel<LoginUiEvent>()
val events: Flow<LoginUiEvent> = _events.receiveAsFlow()
private fun submit() {
viewModelScope.launch {
// ... 登录逻辑
_events.send(LoginUiEvent.NavigateToHome)
}
}
}
Composable 中收集事件:
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
LoginUiEvent.NavigateToHome -> onNavigateToHome()
}
}
}
Channel 是冷流,每个事件只被消费一次。事件被发送后进入队列,消费者取走后就从队列中移除,不会重复触发。
为什么用 Channel 而不是 SharedFlow:SharedFlow 可以有多个订阅者,且可以配置重放。对于一次性事件,我们希望事件只被消费一次,且不重放。Channel 配合 receiveAsFlow() 正好满足这个需求。
4.4 导航是否应该用事件
关于“导航是否应该用一次性事件”存在一些争论。一种观点认为导航本身就是状态——当前在哪个页面是状态的一部分。另一种观点认为导航是从一个状态到另一个状态的转换,是瞬态事件。
在实践中,对于“用户操作触发的导航”(如登录成功、提交表单后跳转),用一次性事件更安全。对于“状态驱动的导航”(如根据登录状态决定显示登录页还是主页),用状态更自然。
选择的标准是:如果导航是由一个具体的动作触发的,用事件;如果导航是由持续的状态决定的,用状态。
五、依赖注入:Koin 在 CMP 中的集成
5.1 为什么需要依赖注入
到目前为止,ViewModel 的创建是这样的:
viewModel { ItemListViewModel(repository) }
repository 从哪来?如果 Repository 又依赖 HttpClient,HttpClient 又依赖引擎配置,这条依赖链会越来越长。手动传递这些依赖(“依赖地狱”)会导致代码难以维护——每新增一个依赖,从创建点到使用点的整条链都要修改。
依赖注入框架自动解析和组装依赖链。你只需要声明“谁依赖谁”,框架负责创建实例并注入。
5.2 Koin 的跨平台优势
Koin 是纯 Kotlin 的运行时 DI 框架,与 Dagger/Hilt 的编译期代码生成不同,它基于 DSL 配置,运行时解析依赖。在 CMP 中,Koin 的优势尤为明显:同一份 DI 配置,在 Android、iOS、桌面、Web 上完全共享,不需要平台特定的 DI 代码。
Dagger/Hilt 的问题在于它依赖注解处理器(KSP/KAPT),而 KSP 在 Kotlin/Native 和 Kotlin/Wasm 上的支持相对有限。Koin 的纯运行时方案在 CMP 中更稳定,也更简单。
5.3 配置 Koin
添加依赖:
[versions]
koin = "4.2.0"
[libraries]
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
在 commonMain 中定义模块:
val appModule = module {
single { HttpClient { /* 配置 */ } }
single<ItemRepository> { ItemRepositoryImpl(get()) }
viewModel { ItemListViewModel(get()) }
}
single 注册单例,viewModel 注册 ViewModel 工厂。get() 告诉 Koin:“自动从容器中查找并注入这个依赖”。
在应用入口启动 Koin:
@Composable
fun App() {
KoinApplication(application = {
modules(appModule)
}) {
MaterialTheme {
// 导航和页面
}
}
}
在 Composable 中注入:
@Composable
fun ItemListScreen(
viewModel: ItemListViewModel = koinViewModel(),
) {
// ...
}
koinViewModel() 自动解析 ViewModel 及其整条依赖链,不需要手动传递 Repository 或 HttpClient。
5.4 跨平台注意事项
Koin 的 single 在 iOS 和 Web 上同样有效,但需要注意:single 的实例在应用生命周期内唯一,这意味着 HttpClient 的连接池、缓存等资源在整个应用运行期间保持活跃。这是设计上的选择,不是缺陷——HttpClient 本来就应该复用。
如果某个依赖需要在特定时机释放(如页面级资源),应该使用 factory 而不是 single,或者在 ViewModel 的 onCleared() 中手动释放。
一个实用建议:在开发初期,可以先手动传递依赖,等到依赖链变复杂时再引入 Koin。过早引入 DI 框架会增加理解成本,而过晚引入会导致重构成本。判断时机:当你发现创建 ViewModel 需要传递 3 个以上的依赖时,就该引入 DI 了。
六、完整架构分层
6.1 分层职责
经过第5课和第6课,应用的分层结构已经清晰:
| 层 | 职责 | 产物 |
|---|---|---|
| UI 层 | 渲染状态、转发事件 | Composable、ViewModel |
| 领域层 | 业务逻辑、数据转换 | Repository(可选 UseCase) |
| 数据层 | 网络请求、本地存储 | Ktor Client、数据库 |
ViewModel 属于 UI 层,不是领域层。这个定位很重要。它的职责是持有 UI 状态、处理 UI 事件、调用领域层。ViewModel 不应该包含业务规则——那些属于 Repository 或 UseCase。
判断一个逻辑是否属于 ViewModel:如果这个逻辑在没有 UI 的情况下也有意义,它就不属于 ViewModel。 比如“密码必须包含数字”是业务规则,属于领域层;“密码输入框是否显示错误提示”是 UI 逻辑,属于 ViewModel。
6.2 数据流向
单向数据流的完整链路:
用户交互 → Composable 调用 onEvent → ViewModel 处理事件 →
Repository 执行操作 → ViewModel 更新 _state →
Compose 收集到新状态 → 重组 → UI 更新
这个链路中,状态只在一个方向流动:从 ViewModel 到 UI。UI 永远不直接修改状态,只通过事件触发 ViewModel 的修改。
单向数据流的核心价值在于可预测性。因为状态只能由 ViewModel 修改,且修改只能通过事件触发,所以任意时刻的状态都可以通过“初始状态 + 事件序列”推导出来。这为调试、测试、回放提供了基础。
6.3 导航与 ViewModel 的作用域
在 CMP 中使用 Navigation 时,ViewModel 默认可能绑定到整个 Activity 而非单个屏幕。要实现每个导航条目独立的 ViewModel 作用域,需要使用 Navigation 3 的 rememberViewModelStoreNavEntryDecorator()。
NavDisplay(
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
// ...
)
这个配置确保当用户导航离开某个屏幕时,该屏幕的 ViewModel 被正确清理,不会泄漏。如果没有这个配置,所有屏幕的 ViewModel 都会在应用生命周期内累积,占用内存。
6.4 UseCase 层:何时引入
Repository 和 ViewModel 之间,有时会插入一个 UseCase 层。UseCase 的职责是封装单个业务操作,把多个 Repository 调用组合成一个有意义的业务动作。
class LoginUseCase(
private val authRepository: AuthRepository,
private val userRepository: UserRepository,
) {
suspend operator fun invoke(username: String, password: String): NetworkResult<User> {
val authResult = authRepository.login(username, password)
if (authResult is NetworkResult.Error) return authResult
return userRepository.getProfile()
}
}
何时引入 UseCase:当 ViewModel 中的某个业务逻辑变得复杂(涉及多个 Repository、需要复用、需要独立测试),就应该提取为 UseCase。对于简单的 CRUD 操作,直接在 ViewModel 中调用 Repository 即可,不需要为每个操作都创建一个 UseCase 类。
过度使用 UseCase 会导致“贫血 ViewModel”问题——ViewModel 变成了一层简单的转发,而 UseCase 只是一对一的 Repository 包装。这不是好的设计。UseCase 应该只在真正有业务价值时存在。
七、习题与参考答案
本课习题分为三类:概念理解(1-5 题)、代码实践(6-11 题)、综合设计(12-15 题)。
概念理解
习题 1:ViewModel 与 remember 的区别
题目:remember { mutableStateOf() } 和 ViewModel 中的 MutableStateFlow 在状态保持上有什么区别?
参考答案:remember 的状态存储在 Composable 的 Slot Table 中,Composable 离开组合时状态丢失。ViewModel 的状态存储在 ViewModelStore 中,生命周期比 Composable 更长,重组、配置更改、导航往返都不会丢失。
延伸思考:rememberSaveable 可以跨配置更改保留状态,但它只适合简单可序列化的数据。ViewModel 没有这个限制,可以持有任意复杂的状态,包括列表、复杂对象、协程作用域。
习题 2:为什么用单一状态对象
题目:用一个 data class 封装所有屏幕状态,比多个独立的 mutableStateOf 有什么优势?
参考答案:状态变更的原子性——多个相关状态的变化应该被视为一个整体,避免 UI 闪烁。可测试性——一个对象可以被完整断言。可追溯性——copy() 产生新对象,便于调试对比。
延伸思考:单一状态对象的代价是每次更新都要 copy() 整个对象,对于字段很多的状态可能有性能开销。但现代 JVM 和 Native 的对象分配都很快,这个开销通常可以忽略。如果确实有性能问题,可以用 @Stable 注解或拆分为多个更小的状态对象。
习题 3:Event Sink 模式的价值
题目:为什么把多个回调收敛为一个 onEvent 参数?
参考答案:签名稳定,新增事件类型不影响函数签名。可追溯,所有事件通过一个入口。可测试,直接构造事件对象。编译期安全,密封接口的 when 要求穷尽分支。
习题 4:状态与事件的区分
题目:为什么导航应该用一次性事件而不是状态?
参考答案:状态是持久的,每次重组都会读取。如果把“导航到主页”作为状态,每次重组都会触发导航,导致导航栈堆叠。事件是瞬态的,被消费一次就消失,适合表示“发生了一件事”的通知。
习题 5:CMP ViewModel 的跨平台限制
题目:为什么在 CMP 的 commonMain 中调用 viewModel() 必须提供初始化器?
参考答案:非 JVM 平台(wasmJs、iOS)不支持类型反射,无法通过类型自动创建 ViewModel 实例。必须提供初始化器(lambda)来显式创建实例。如果忘记提供,会在这些平台运行时崩溃,而 Android 上一切正常,问题很隐蔽。
代码实践
习题 6:定义 ViewModel 状态
题目:为一个“用户详情页”定义一个状态数据类,包含用户对象、加载状态、错误信息。
参考答案:
data class UserDetailState(
val user: User? = null,
val isLoading: Boolean = false,
val error: String? = null,
)
习题 7:实现 ViewModel
题目:实现一个 UserDetailViewModel,接收 UserRepository,暴露 StateFlow<UserDetailState>,提供 loadUser(id: String) 方法。
参考答案:
class UserDetailViewModel(
private val repository: UserRepository,
) : ViewModel() {
private val _state = MutableStateFlow(UserDetailState())
val state: StateFlow<UserDetailState> = _state.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
when (val result = repository.getUser(id)) {
is NetworkResult.Success -> {
_state.update { it.copy(user = result.data, isLoading = false) }
}
is NetworkResult.Error -> {
_state.update { it.copy(error = result.message, isLoading = false) }
}
}
}
}
}
习题 8:在 Composable 中收集状态
题目:实现 UserDetailScreen,从 ViewModel 收集状态,根据加载/错误/成功状态渲染不同 UI。
参考答案:
@Composable
fun UserDetailScreen(
userId: String,
viewModel: UserDetailViewModel = viewModel { UserDetailViewModel(repository) },
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
when {
state.isLoading -> CircularProgressIndicator()
state.error != null -> {
Column {
Text("加载失败: ${state.error}")
Button(onClick = { viewModel.loadUser(userId) }) { Text("重试") }
}
}
state.user != null -> {
Text(state.user!!.name)
}
}
}
习题 9:Event Sink 实现
题目:为 UserDetailViewModel 添加 Event Sink 模式,定义 UserDetailEvent 密封接口,包含 Load 和 Retry 事件。
参考答案:
sealed interface UserDetailEvent {
data class Load(val userId: String) : UserDetailEvent
data object Retry : UserDetailEvent
}
// ViewModel 中
fun onEvent(event: UserDetailEvent) {
when (event) {
is UserDetailEvent.Load -> loadUser(event.userId)
UserDetailEvent.Retry -> {
_state.value.user?.let { loadUser(it.id) }
}
}
}
习题 10:配置 Koin 模块
题目:为 UserDetail 功能配置 Koin 模块,包含 HttpClient、UserRepository、UserDetailViewModel。
参考答案:
val userModule = module {
single { HttpClient { /* 配置 */ } }
single<UserRepository> { UserRepositoryImpl(get()) }
viewModel { UserDetailViewModel(get()) }
}
习题 11:在 Composable 中使用 Koin
题目:修改 UserDetailScreen,使用 koinViewModel() 注入 ViewModel。
参考答案:
@Composable
fun UserDetailScreen(
userId: String,
viewModel: UserDetailViewModel = koinViewModel(),
) {
// 与习题 8 相同,只是 ViewModel 获取方式变了
}
综合设计
习题 12:完整的列表页 ViewModel
题目:实现一个 PostListViewModel,包含帖子列表、搜索查询、加载状态、错误状态。提供搜索和刷新事件。
参考答案:
data class PostListState(
val posts: List<Post> = emptyList(),
val searchQuery: String = "",
val isLoading: Boolean = false,
val error: String? = null,
)
sealed interface PostListEvent {
data class Search(val query: String) : PostListEvent
data object Refresh : PostListEvent
}
class PostListViewModel(
private val repository: PostRepository,
) : ViewModel() {
private val _state = MutableStateFlow(PostListState())
val state: StateFlow<PostListState> = _state.asStateFlow()
fun onEvent(event: PostListEvent) {
when (event) {
is PostListEvent.Search -> {
_state.update { it.copy(searchQuery = event.query) }
loadPosts(event.query)
}
PostListEvent.Refresh -> loadPosts(_state.value.searchQuery)
}
}
private fun loadPosts(query: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val result = if (query.isBlank()) {
repository.getAllPosts()
} else {
repository.searchPosts(query)
}
when (result) {
is NetworkResult.Success -> {
_state.update { it.copy(posts = result.data, isLoading = false) }
}
is NetworkResult.Error -> {
_state.update { it.copy(error = result.message, isLoading = false) }
}
}
}
}
}
习题 13:状态恢复与 SavedStateHandle
题目:在 PostListViewModel 中使用 SavedStateHandle 保存搜索查询,当 ViewModel 重建时恢复。
参考答案:
class PostListViewModel(
private val repository: PostRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val _state = MutableStateFlow(
PostListState(
searchQuery = savedStateHandle["searchQuery"] ?: "",
)
)
val state: StateFlow<PostListState> = _state.asStateFlow()
fun onEvent(event: PostListEvent) {
when (event) {
is PostListEvent.Search -> {
savedStateHandle["searchQuery"] = event.query
_state.update { it.copy(searchQuery = event.query) }
loadPosts(event.query)
}
// ...
}
}
}
SavedStateHandle 在 ViewModel 重建时恢复数据,跨进程死亡也有效。
习题 14:登录流程的 ViewModel
题目:实现登录页的 ViewModel,包含用户名、密码、登录按钮启用状态、加载状态、错误信息。
参考答案:
data class LoginState(
val username: String = "",
val password: String = "",
val isLoading: Boolean = false,
val error: String? = null,
) {
val canSubmit: Boolean
get() = username.isNotBlank() && password.isNotBlank()
}
sealed interface LoginEvent {
data class UsernameChanged(val value: String) : LoginEvent
data class PasswordChanged(val value: String) : LoginEvent
data object Submit : LoginEvent
}
class LoginViewModel(
private val authRepository: AuthRepository,
) : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
fun onEvent(event: LoginEvent) {
when (event) {
is LoginEvent.UsernameChanged -> {
_state.update { it.copy(username = event.value, error = null) }
}
is LoginEvent.PasswordChanged -> {
_state.update { it.copy(password = event.value, error = null) }
}
LoginEvent.Submit -> submit()
}
}
private fun submit() {
val current = _state.value
if (!current.canSubmit) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
when (val result = authRepository.login(current.username, current.password)) {
is NetworkResult.Success -> {
_events.send(LoginUiEvent.NavigateToHome)
}
is NetworkResult.Error -> {
_state.update { it.copy(error = result.message, isLoading = false) }
}
}
}
}
}
习题 15:一次性事件(导航)
题目:登录成功后需要导航到主页,但导航是“一次性事件”,不应该在状态中持久存在。如何实现?
参考答案:
使用 Channel 暴露一次性事件:
sealed interface LoginUiEvent {
data object NavigateToHome : LoginUiEvent
}
class LoginViewModel(
private val authRepository: AuthRepository,
) : ViewModel() {
private val _events = Channel<LoginUiEvent>()
val events: Flow<LoginUiEvent> = _events.receiveAsFlow()
// submit() 中:
// is NetworkResult.Success -> {
// _events.send(LoginUiEvent.NavigateToHome)
// }
}
@Composable
fun LoginScreen(
viewModel: LoginViewModel = koinViewModel(),
onNavigateToHome: () -> Unit,
) {
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
LoginUiEvent.NavigateToHome -> onNavigateToHome()
}
}
}
// ...
}
Channel 是冷流,每个事件只被消费一次。状态是持久的,事件是瞬态的——这个区分是单向数据流架构的关键细节。
延伸思考:为什么不用 SharedFlow?SharedFlow 可以有多个订阅者,且可以配置重放。对于一次性事件,我们希望事件只被消费一次,且不重放。Channel 配合 receiveAsFlow() 正好满足这个需求。如果多个 Composable 需要同时响应同一个事件,才考虑 SharedFlow。
八、本课小结
ViewModel 的价值:状态与 UI 解耦、跨配置更改保持状态、业务逻辑可测试、统一状态修改入口。CMP 中 ViewModel 的跨平台实现需要显式初始化器(非 JVM 平台不支持反射)。
状态模型设计:单一不可变数据类封装屏幕状态,保证原子性和可测试性。StateFlow 作为暴露机制,asStateFlow() 提供只读视图。内部用 _state,外部用 state。
Event Sink 模式:用密封接口收敛交互事件,签名稳定、可追溯、可测试。适合回调数量超过 4-5 个的复杂屏幕。简单屏幕直接用回调即可。
状态与事件的区分:状态是持久的,每次重组都会被读取;事件是瞬态的,被消费一次就消失。导航、Snackbar、动画等应该用事件,加载状态、数据、错误等应该用状态。
Koin 集成:纯 Kotlin 运行时 DI,同一份配置跨平台共享。single 注册单例,viewModel 注册 ViewModel 工厂,get() 自动解析依赖链。依赖链超过 3 个时引入 DI 是合理的时机。
架构分层:UI 层(Composable + ViewModel)、领域层(Repository/UseCase)、数据层(Ktor/数据库)。状态单向流动,UI 只读状态、只发事件。UseCase 只在有真正业务价值时引入,避免贫血 ViewModel。
九、下一课预告
第7课 平台适配与互操作
更多推荐




所有评论(0)