本课目标:理解 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课 平台适配与互操作

Logo

一站式 AI 云服务平台

更多推荐