目录
- 一、先看一段"没有大脑"的代码
- 二、ViewModel到底是什么:一个精确的比喻
- [2.1 大脑与身体的比喻](#2.1 大脑与身体的比喻)
- [2.2 ViewModel的三条命脉](#2.2 ViewModel的三条命脉)
- [2.3 生命周期:大脑比身体活得久](#2.3 生命周期:大脑比身体活得久)
- 三、在Compose中使用ViewModel:三种场景
- [3.1 场景一:最简单的页面级ViewModel](#3.1 场景一:最简单的页面级ViewModel)
- [3.2 场景二:导航页面级ViewModel(每个页面独立)](#3.2 场景二:导航页面级ViewModel(每个页面独立))
- [3.3 场景三:需要参数的ViewModel](#3.3 场景三:需要参数的ViewModel)
- [四、状态管理:StateFlow vs mutableStateOf,到底选哪个](#四、状态管理:StateFlow vs mutableStateOf,到底选哪个)
- [4.1 两种方式的核心区别](#4.1 两种方式的核心区别)
- [4.2 一张表说清楚](#4.2 一张表说清楚)
- [4.3 我的建议](#4.3 我的建议)
- [4.4 UiState:把状态打包成一个整体](#4.4 UiState:把状态打包成一个整体)
- 五、事件处理:普通事件和一次性事件
- [5.1 普通事件:方法回调](#5.1 普通事件:方法回调)
- [5.2 一次性事件:为什么不能用StateFlow](#5.2 一次性事件:为什么不能用StateFlow)
- [5.3 正确做法:Channel转Flow](#5.3 正确做法:Channel转Flow)
- 六、ViewModel的测试:为什么好测试,怎么测
- [6.1 ViewModel是纯Kotlin,测试零成本](#6.1 ViewModel是纯Kotlin,测试零成本)
- [6.2 测试一次性事件](#6.2 测试一次性事件)
- [6.3 测试的核心思路](#6.3 测试的核心思路)
- 七、最佳实践:ViewModel的"该做"与"不该做"
- [7.1 该做的五件事](#7.1 该做的五件事)
- [7.2 不该做的五件事](#7.2 不该做的五件事)
- [八、常见陷阱:真实场景 + 后果 + 解法](#八、常见陷阱:真实场景 + 后果 + 解法)
- 九、综合实战:一个完整的用户列表ViewModel
- [9.1 UiState](#9.1 UiState)
- [9.2 ViewModel](#9.2 ViewModel)
- [9.3 UI层](#9.3 UI层)
- [9.4 设计要点回看](#9.4 设计要点回看)
- 十、ViewModel的思维模型
- 十一、小结与下一课预告
一、先看一段"没有大脑"的代码
这是一个用户列表页面,网络请求、状态管理、错误处理全写在可组合函数里:
kotlin
@Composable
fun UserListScreen() {
var users by remember { mutableStateOf<List<User>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
isLoading = true
try {
users = fetchUsers()
} catch (e: Exception) {
error = e.message
} finally {
isLoading = false
}
}
when {
isLoading -> CircularProgressIndicator()
error != null -> Text("错误:$error")
else -> LazyColumn { items(users) { UserItem(it) } }
}
}
这段代码能跑,但它有一个致命缺陷:屏幕一旋转,所有状态归零,数据重新加载。
用户正在看列表,手机转了个方向,页面白屏,转圈,再等三秒。用户心里骂了一句。
为什么会这样?因为 remember 的状态绑定在组合上。屏幕旋转导致Activity重建,组合销毁,remember 全丢。rememberSaveable 能救一部分,但一整个列表的序列化成本高,而且网络请求的逻辑无处安放。
根本问题不是状态保存,而是这段代码缺一个"大脑"。 它把"想"(业务逻辑)和"看"(UI展示)混在了一起。UI既要知道怎么画,又要知道数据从哪来、怎么加载、出错了怎么办。
ViewModel就是来当这个"大脑"的。它负责想和记,UI只负责看和点。
下面是这段"没有大脑"的代码的问题示意:
#mermaid-svg-ajOsGBcfvwWq8mdU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ajOsGBcfvwWq8mdU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ajOsGBcfvwWq8mdU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ajOsGBcfvwWq8mdU .error-icon{fill:#552222;}#mermaid-svg-ajOsGBcfvwWq8mdU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ajOsGBcfvwWq8mdU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ajOsGBcfvwWq8mdU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ajOsGBcfvwWq8mdU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ajOsGBcfvwWq8mdU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ajOsGBcfvwWq8mdU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ajOsGBcfvwWq8mdU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ajOsGBcfvwWq8mdU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ajOsGBcfvwWq8mdU .marker.cross{stroke:#333333;}#mermaid-svg-ajOsGBcfvwWq8mdU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ajOsGBcfvwWq8mdU p{margin:0;}#mermaid-svg-ajOsGBcfvwWq8mdU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ajOsGBcfvwWq8mdU .cluster-label text{fill:#333;}#mermaid-svg-ajOsGBcfvwWq8mdU .cluster-label span{color:#333;}#mermaid-svg-ajOsGBcfvwWq8mdU .cluster-label span p{background-color:transparent;}#mermaid-svg-ajOsGBcfvwWq8mdU .label text,#mermaid-svg-ajOsGBcfvwWq8mdU span{fill:#333;color:#333;}#mermaid-svg-ajOsGBcfvwWq8mdU .node rect,#mermaid-svg-ajOsGBcfvwWq8mdU .node circle,#mermaid-svg-ajOsGBcfvwWq8mdU .node ellipse,#mermaid-svg-ajOsGBcfvwWq8mdU .node polygon,#mermaid-svg-ajOsGBcfvwWq8mdU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ajOsGBcfvwWq8mdU .rough-node .label text,#mermaid-svg-ajOsGBcfvwWq8mdU .node .label text,#mermaid-svg-ajOsGBcfvwWq8mdU .image-shape .label,#mermaid-svg-ajOsGBcfvwWq8mdU .icon-shape .label{text-anchor:middle;}#mermaid-svg-ajOsGBcfvwWq8mdU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ajOsGBcfvwWq8mdU .rough-node .label,#mermaid-svg-ajOsGBcfvwWq8mdU .node .label,#mermaid-svg-ajOsGBcfvwWq8mdU .image-shape .label,#mermaid-svg-ajOsGBcfvwWq8mdU .icon-shape .label{text-align:center;}#mermaid-svg-ajOsGBcfvwWq8mdU .node.clickable{cursor:pointer;}#mermaid-svg-ajOsGBcfvwWq8mdU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ajOsGBcfvwWq8mdU .arrowheadPath{fill:#333333;}#mermaid-svg-ajOsGBcfvwWq8mdU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ajOsGBcfvwWq8mdU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ajOsGBcfvwWq8mdU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ajOsGBcfvwWq8mdU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ajOsGBcfvwWq8mdU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ajOsGBcfvwWq8mdU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ajOsGBcfvwWq8mdU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ajOsGBcfvwWq8mdU .cluster text{fill:#333;}#mermaid-svg-ajOsGBcfvwWq8mdU .cluster span{color:#333;}#mermaid-svg-ajOsGBcfvwWq8mdU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ajOsGBcfvwWq8mdU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ajOsGBcfvwWq8mdU rect.text{fill:none;stroke-width:0;}#mermaid-svg-ajOsGBcfvwWq8mdU .icon-shape,#mermaid-svg-ajOsGBcfvwWq8mdU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ajOsGBcfvwWq8mdU .icon-shape p,#mermaid-svg-ajOsGBcfvwWq8mdU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ajOsGBcfvwWq8mdU .icon-shape .label rect,#mermaid-svg-ajOsGBcfvwWq8mdU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ajOsGBcfvwWq8mdU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ajOsGBcfvwWq8mdU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ajOsGBcfvwWq8mdU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} UI 可组合函数
remember 状态
屏幕旋转
Activity 重建
组合销毁
remember 状态全部丢失
数据重新加载
白屏 + 转圈 + 等待
二、ViewModel到底是什么:一个精确的比喻
2.1 大脑与身体的比喻
把UI界面想象成一个人的身体 ,ViewModel就是大脑。
身体(UI) :负责感知和表达。眼睛看到按钮,手指点击按钮,嘴巴说出文字。但它不思考。你碰它一下,它把信号传给大脑;大脑说"抬手",它才抬手。
大脑(ViewModel) :负责记忆和决策。它记得用户列表长什么样(状态),知道点击用户要跳转详情(逻辑),知道网络失败了要提示(错误处理)。它不直接接触外界,但指挥身体行动。
关键点:身体可以换(屏幕旋转、配置变更、甚至换一个设备),但大脑还是那个大脑。只要大脑在,记忆和决策能力就在。
2.2 ViewModel的三条命脉
用一句话概括ViewModel的职责:持有状态、处理事件、调用数据层。
| 职责 | 做什么 | 不做什么 |
|---|---|---|
| 持有状态 | 保存UiState,暴露给UI观察 | 不持有View引用,不持有Context |
| 处理事件 | 接收UI的onXxx回调,更新状态 | 不直接操作UI,不弹Toast,不导航 |
| 调用数据层 | 调用Repository/UseCase获取数据 | 不做数据转换(Repository做),不拼SQL |
为什么"不持有Context":ViewModel的生命周期比Activity长。如果ViewModel持有Activity的Context,Activity销毁了但ViewModel还活着,Context就泄漏了。这是Android内存泄漏的头号原因之一。
为什么"不直接操作UI":ViewModel不知道UI长什么样。它只告诉UI"状态变成这样了",UI自己决定怎么展示。这样ViewModel可以独立测试,不需要启动UI。
2.3 生命周期:大脑比身体活得久
Activity创建 → ViewModel创建(大脑诞生)
↓
屏幕旋转 → Activity重建 → ViewModel存活(大脑还在,身体换了)
↓
用户按返回键 → Activity销毁 → ViewModel.onCleared()(大脑死亡)
关键理解 :配置变更(旋转、切换语言、分屏)时,ViewModel不会被销毁。只有Activity真正销毁时,ViewModel才被清理。
这就是为什么ViewModel能解决"旋转丢状态"的问题------大脑没死,记忆还在。
下面是 ViewModel 生命周期的完整示意:
#mermaid-svg-9BMj2E2cluu6iABH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9BMj2E2cluu6iABH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9BMj2E2cluu6iABH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9BMj2E2cluu6iABH .error-icon{fill:#552222;}#mermaid-svg-9BMj2E2cluu6iABH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9BMj2E2cluu6iABH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9BMj2E2cluu6iABH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9BMj2E2cluu6iABH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9BMj2E2cluu6iABH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9BMj2E2cluu6iABH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9BMj2E2cluu6iABH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9BMj2E2cluu6iABH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9BMj2E2cluu6iABH .marker.cross{stroke:#333333;}#mermaid-svg-9BMj2E2cluu6iABH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9BMj2E2cluu6iABH p{margin:0;}#mermaid-svg-9BMj2E2cluu6iABH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9BMj2E2cluu6iABH .cluster-label text{fill:#333;}#mermaid-svg-9BMj2E2cluu6iABH .cluster-label span{color:#333;}#mermaid-svg-9BMj2E2cluu6iABH .cluster-label span p{background-color:transparent;}#mermaid-svg-9BMj2E2cluu6iABH .label text,#mermaid-svg-9BMj2E2cluu6iABH span{fill:#333;color:#333;}#mermaid-svg-9BMj2E2cluu6iABH .node rect,#mermaid-svg-9BMj2E2cluu6iABH .node circle,#mermaid-svg-9BMj2E2cluu6iABH .node ellipse,#mermaid-svg-9BMj2E2cluu6iABH .node polygon,#mermaid-svg-9BMj2E2cluu6iABH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9BMj2E2cluu6iABH .rough-node .label text,#mermaid-svg-9BMj2E2cluu6iABH .node .label text,#mermaid-svg-9BMj2E2cluu6iABH .image-shape .label,#mermaid-svg-9BMj2E2cluu6iABH .icon-shape .label{text-anchor:middle;}#mermaid-svg-9BMj2E2cluu6iABH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9BMj2E2cluu6iABH .rough-node .label,#mermaid-svg-9BMj2E2cluu6iABH .node .label,#mermaid-svg-9BMj2E2cluu6iABH .image-shape .label,#mermaid-svg-9BMj2E2cluu6iABH .icon-shape .label{text-align:center;}#mermaid-svg-9BMj2E2cluu6iABH .node.clickable{cursor:pointer;}#mermaid-svg-9BMj2E2cluu6iABH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9BMj2E2cluu6iABH .arrowheadPath{fill:#333333;}#mermaid-svg-9BMj2E2cluu6iABH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9BMj2E2cluu6iABH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9BMj2E2cluu6iABH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9BMj2E2cluu6iABH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9BMj2E2cluu6iABH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9BMj2E2cluu6iABH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9BMj2E2cluu6iABH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9BMj2E2cluu6iABH .cluster text{fill:#333;}#mermaid-svg-9BMj2E2cluu6iABH .cluster span{color:#333;}#mermaid-svg-9BMj2E2cluu6iABH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9BMj2E2cluu6iABH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9BMj2E2cluu6iABH rect.text{fill:none;stroke-width:0;}#mermaid-svg-9BMj2E2cluu6iABH .icon-shape,#mermaid-svg-9BMj2E2cluu6iABH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9BMj2E2cluu6iABH .icon-shape p,#mermaid-svg-9BMj2E2cluu6iABH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9BMj2E2cluu6iABH .icon-shape .label rect,#mermaid-svg-9BMj2E2cluu6iABH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9BMj2E2cluu6iABH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9BMj2E2cluu6iABH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9BMj2E2cluu6iABH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Activity 创建
ViewModel 创建(大脑诞生)
屏幕旋转
Activity 重建
ViewModel 存活(大脑还在,身体换了)
用户按返回键
Activity 销毁
ViewModel.onCleared()(大脑死亡)
下面是"大脑与身体"的协作关系图:
#mermaid-svg-kmYRjmWiXd1nsRRe{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kmYRjmWiXd1nsRRe .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kmYRjmWiXd1nsRRe .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kmYRjmWiXd1nsRRe .error-icon{fill:#552222;}#mermaid-svg-kmYRjmWiXd1nsRRe .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kmYRjmWiXd1nsRRe .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kmYRjmWiXd1nsRRe .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kmYRjmWiXd1nsRRe .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kmYRjmWiXd1nsRRe .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kmYRjmWiXd1nsRRe .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kmYRjmWiXd1nsRRe .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kmYRjmWiXd1nsRRe .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kmYRjmWiXd1nsRRe .marker.cross{stroke:#333333;}#mermaid-svg-kmYRjmWiXd1nsRRe svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kmYRjmWiXd1nsRRe p{margin:0;}#mermaid-svg-kmYRjmWiXd1nsRRe .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kmYRjmWiXd1nsRRe .cluster-label text{fill:#333;}#mermaid-svg-kmYRjmWiXd1nsRRe .cluster-label span{color:#333;}#mermaid-svg-kmYRjmWiXd1nsRRe .cluster-label span p{background-color:transparent;}#mermaid-svg-kmYRjmWiXd1nsRRe .label text,#mermaid-svg-kmYRjmWiXd1nsRRe span{fill:#333;color:#333;}#mermaid-svg-kmYRjmWiXd1nsRRe .node rect,#mermaid-svg-kmYRjmWiXd1nsRRe .node circle,#mermaid-svg-kmYRjmWiXd1nsRRe .node ellipse,#mermaid-svg-kmYRjmWiXd1nsRRe .node polygon,#mermaid-svg-kmYRjmWiXd1nsRRe .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kmYRjmWiXd1nsRRe .rough-node .label text,#mermaid-svg-kmYRjmWiXd1nsRRe .node .label text,#mermaid-svg-kmYRjmWiXd1nsRRe .image-shape .label,#mermaid-svg-kmYRjmWiXd1nsRRe .icon-shape .label{text-anchor:middle;}#mermaid-svg-kmYRjmWiXd1nsRRe .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kmYRjmWiXd1nsRRe .rough-node .label,#mermaid-svg-kmYRjmWiXd1nsRRe .node .label,#mermaid-svg-kmYRjmWiXd1nsRRe .image-shape .label,#mermaid-svg-kmYRjmWiXd1nsRRe .icon-shape .label{text-align:center;}#mermaid-svg-kmYRjmWiXd1nsRRe .node.clickable{cursor:pointer;}#mermaid-svg-kmYRjmWiXd1nsRRe .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kmYRjmWiXd1nsRRe .arrowheadPath{fill:#333333;}#mermaid-svg-kmYRjmWiXd1nsRRe .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kmYRjmWiXd1nsRRe .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kmYRjmWiXd1nsRRe .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kmYRjmWiXd1nsRRe .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kmYRjmWiXd1nsRRe .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kmYRjmWiXd1nsRRe .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kmYRjmWiXd1nsRRe .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kmYRjmWiXd1nsRRe .cluster text{fill:#333;}#mermaid-svg-kmYRjmWiXd1nsRRe .cluster span{color:#333;}#mermaid-svg-kmYRjmWiXd1nsRRe div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kmYRjmWiXd1nsRRe .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kmYRjmWiXd1nsRRe rect.text{fill:none;stroke-width:0;}#mermaid-svg-kmYRjmWiXd1nsRRe .icon-shape,#mermaid-svg-kmYRjmWiXd1nsRRe .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kmYRjmWiXd1nsRRe .icon-shape p,#mermaid-svg-kmYRjmWiXd1nsRRe .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kmYRjmWiXd1nsRRe .icon-shape .label rect,#mermaid-svg-kmYRjmWiXd1nsRRe .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kmYRjmWiXd1nsRRe .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kmYRjmWiXd1nsRRe .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kmYRjmWiXd1nsRRe :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 大脑(ViewModel)
身体(UI)
眼睛:看到按钮
手指:点击按钮
嘴巴:说出文字
记忆:用户列表状态
决策:点击跳转详情
判断:网络失败提示
三、在Compose中使用ViewModel:三种场景
3.1 场景一:最简单的页面级ViewModel
kotlin
class CounterViewModel : ViewModel() {
var count by mutableStateOf(0)
private set
fun increment() { count++ }
}
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
Text("计数:${viewModel.count}")
Button(onClick = viewModel::increment) { Text("加一") }
}
viewModel() 干了三件事:
- 从当前
ViewModelStoreOwner获取或创建实例 - 配置变更时返回同一个实例
- 组件销毁时自动清理
默认的Owner是谁 :在Compose里,最近的 ViewModelStoreOwner 通常是Activity。所以这个ViewModel的生命周期绑定到Activity------Activity在,ViewModel就在。
3.2 场景二:导航页面级ViewModel(每个页面独立)
这是导航中最常见的场景。每个页面有自己的ViewModel,页面弹出时ViewModel销毁。
kotlin
composable<DetailRoute> { backStackEntry ->
val viewModel: DetailViewModel = viewModel(
viewModelStoreOwner = backStackEntry // 关键!
)
DetailScreen(viewModel = viewModel)
}
不加 backStackEntry 会怎样:ViewModel会绑定到Activity。用户从详情页返回列表,详情页的ViewModel还在内存里。进进出出十次,就有十个DetailViewModel。内存泄漏。
加了 backStackEntry 之后:ViewModel的生命周期绑定到导航返回栈条目。页面在栈里,ViewModel在;页面被弹出,ViewModel清理。干净利落。
3.3 场景三:需要参数的ViewModel
如果ViewModel需要参数(比如文章ID),有两种方式。
方式一:工厂(传统写法)
kotlin
class DetailViewModel(private val articleId: Long) : ViewModel()
class DetailViewModelFactory(private val articleId: Long) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return DetailViewModel(articleId) as T
}
}
@Composable
fun DetailScreen(articleId: Long) {
val viewModel: DetailViewModel = viewModel(
factory = DetailViewModelFactory(articleId)
)
}
方式二:SavedStateHandle(推荐)
kotlin
class DetailViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
private val articleId: Long = savedStateHandle["articleId"] ?: 0L
}
配合类型安全导航,参数自动注入,连工厂都不用写:
kotlin
class DetailViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
private val route = savedStateHandle.toRoute<DetailRoute>()
private val articleId = route.articleId
}
@Composable
fun DetailScreen() {
val viewModel: DetailViewModel = viewModel() // 参数自动注入
}
为什么推荐SavedStateHandle:它不仅能拿导航参数,还能在进程被杀死后恢复状态。工厂只能传参数,不能恢复状态。
四、状态管理:StateFlow vs mutableStateOf,到底选哪个
4.1 两种方式的核心区别
这是Compose开发者最常纠结的问题。先看代码。
mutableStateOf版本:
kotlin
class CounterViewModel : ViewModel() {
var count by mutableStateOf(0)
private set
fun increment() { count++ }
}
// UI
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
Text("计数:${viewModel.count}") // 直接读,零成本
}
StateFlow版本:
kotlin
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() { _count.value++ }
}
// UI
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val count by viewModel.count.collectAsStateWithLifecycle()
Text("计数:$count")
}
4.2 一张表说清楚
| 维度 | mutableStateOf | StateFlow |
|---|---|---|
| UI读取成本 | 直接读,零成本 | 需要collect,有微小开销 |
| 非Compose环境复用 | 不方便(依赖Compose运行时) | 方便(纯Kotlin协程) |
| Flow操作符 | 不支持 | 支持(map/filter/combine/debounce) |
| 测试 | 直接读值 | 需要收集Flow |
| 生命周期感知 | 自动(Compose管理) | 需要collectAsStateWithLifecycle |
| 团队习惯 | 纯Compose项目常用 | 传统Android项目常用 |
4.3 我的建议
纯Compose项目、状态简单、不需要Flow操作符 :用 mutableStateOf。代码最少,心智负担最低。
需要与Flow操作符配合、与非Compose代码共享、状态流复杂 :用 StateFlow。
最重要的一条 :不要在同一个ViewModel里混用两种方式。 一份状态,要么全用mutableStateOf,要么全用StateFlow。混用会导致状态不一致,调试时非常痛苦。
4.4 UiState:把状态打包成一个整体
无论用哪种方式,都推荐用一个 data class 统一状态:
kotlin
// 不推荐:多个独立状态,容易出现矛盾
var users by mutableStateOf<List<User>>(emptyList())
var isLoading by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
// 可能出现:isLoading=true 但 users 有数据,UI到底显示什么?
// 推荐:统一UiState
data class UserListUiState(
val users: List<User> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val isRefreshing: Boolean = false
)
UiState的三个好处:
- 状态是一个整体,不会出现矛盾组合
- 新增字段只需改一处
- 测试时构造一个UiState就能覆盖所有场景,UI用
when分支穷举所有可能
下面是两种状态管理方式的选型决策图:
#mermaid-svg-UTkbx9v4SZF8rEtq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UTkbx9v4SZF8rEtq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UTkbx9v4SZF8rEtq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UTkbx9v4SZF8rEtq .error-icon{fill:#552222;}#mermaid-svg-UTkbx9v4SZF8rEtq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UTkbx9v4SZF8rEtq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UTkbx9v4SZF8rEtq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UTkbx9v4SZF8rEtq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UTkbx9v4SZF8rEtq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UTkbx9v4SZF8rEtq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UTkbx9v4SZF8rEtq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UTkbx9v4SZF8rEtq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UTkbx9v4SZF8rEtq .marker.cross{stroke:#333333;}#mermaid-svg-UTkbx9v4SZF8rEtq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UTkbx9v4SZF8rEtq p{margin:0;}#mermaid-svg-UTkbx9v4SZF8rEtq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UTkbx9v4SZF8rEtq .cluster-label text{fill:#333;}#mermaid-svg-UTkbx9v4SZF8rEtq .cluster-label span{color:#333;}#mermaid-svg-UTkbx9v4SZF8rEtq .cluster-label span p{background-color:transparent;}#mermaid-svg-UTkbx9v4SZF8rEtq .label text,#mermaid-svg-UTkbx9v4SZF8rEtq span{fill:#333;color:#333;}#mermaid-svg-UTkbx9v4SZF8rEtq .node rect,#mermaid-svg-UTkbx9v4SZF8rEtq .node circle,#mermaid-svg-UTkbx9v4SZF8rEtq .node ellipse,#mermaid-svg-UTkbx9v4SZF8rEtq .node polygon,#mermaid-svg-UTkbx9v4SZF8rEtq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UTkbx9v4SZF8rEtq .rough-node .label text,#mermaid-svg-UTkbx9v4SZF8rEtq .node .label text,#mermaid-svg-UTkbx9v4SZF8rEtq .image-shape .label,#mermaid-svg-UTkbx9v4SZF8rEtq .icon-shape .label{text-anchor:middle;}#mermaid-svg-UTkbx9v4SZF8rEtq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UTkbx9v4SZF8rEtq .rough-node .label,#mermaid-svg-UTkbx9v4SZF8rEtq .node .label,#mermaid-svg-UTkbx9v4SZF8rEtq .image-shape .label,#mermaid-svg-UTkbx9v4SZF8rEtq .icon-shape .label{text-align:center;}#mermaid-svg-UTkbx9v4SZF8rEtq .node.clickable{cursor:pointer;}#mermaid-svg-UTkbx9v4SZF8rEtq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UTkbx9v4SZF8rEtq .arrowheadPath{fill:#333333;}#mermaid-svg-UTkbx9v4SZF8rEtq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UTkbx9v4SZF8rEtq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UTkbx9v4SZF8rEtq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UTkbx9v4SZF8rEtq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UTkbx9v4SZF8rEtq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UTkbx9v4SZF8rEtq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UTkbx9v4SZF8rEtq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UTkbx9v4SZF8rEtq .cluster text{fill:#333;}#mermaid-svg-UTkbx9v4SZF8rEtq .cluster span{color:#333;}#mermaid-svg-UTkbx9v4SZF8rEtq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UTkbx9v4SZF8rEtq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UTkbx9v4SZF8rEtq rect.text{fill:none;stroke-width:0;}#mermaid-svg-UTkbx9v4SZF8rEtq .icon-shape,#mermaid-svg-UTkbx9v4SZF8rEtq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UTkbx9v4SZF8rEtq .icon-shape p,#mermaid-svg-UTkbx9v4SZF8rEtq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UTkbx9v4SZF8rEtq .icon-shape .label rect,#mermaid-svg-UTkbx9v4SZF8rEtq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UTkbx9v4SZF8rEtq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UTkbx9v4SZF8rEtq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UTkbx9v4SZF8rEtq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
否
状态管理选型
需要 Flow 操作符?
StateFlow
需要与非 Compose 代码共享?
状态流复杂?
mutableStateOf
注意:不要混用两种方式
五、事件处理:普通事件和一次性事件
5.1 普通事件:方法回调
按钮点击、下拉刷新、输入变化------这些事件在重组时重复触发也没关系。
kotlin
class UserListViewModel : ViewModel() {
fun onRefresh() { /* 刷新逻辑 */ }
fun onUserClick(id: Long) { /* 点击逻辑 */ }
}
@Composable
fun UserListScreen(viewModel: UserListViewModel = viewModel()) {
Button(onClick = viewModel::onRefresh) { Text("刷新") }
}
5.2 一次性事件:为什么不能用StateFlow
导航、显示Snackbar、弹出对话框------这些事件只应该发生一次。
如果用 StateFlow 表示:
kotlin
// 错误:导航事件用StateFlow
val navigateTo: StateFlow<Long?> = _navigateTo.asStateFlow()
问题在哪?Compose重组时,collectAsStateWithLifecycle() 会重新收集Flow,拿到当前值。如果当前值是 42(上次导航的目标),重组后会再次触发导航。用户发现:点了一次按钮,页面跳了两次。
根本原因:StateFlow表示"状态",状态是持续的;导航是"事件",事件是瞬时的。用状态表示事件,就会重复消费。
5.3 正确做法:Channel转Flow
kotlin
class UserListViewModel : ViewModel() {
private val _events = Channel<UserListEvent>()
val events = _events.receiveAsFlow()
fun onUserClick(id: Long) {
viewModelScope.launch {
_events.send(UserListEvent.NavigateToDetail(id))
}
}
}
sealed class UserListEvent {
data class NavigateToDetail(val id: Long) : UserListEvent()
data class ShowMessage(val message: String) : UserListEvent()
}
UI层收集:
kotlin
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UserListEvent.NavigateToDetail -> navController.navigate(...)
is UserListEvent.ShowMessage -> snackbarHostState.showSnackbar(event.message)
}
}
}
Channel和Flow的区别:Channel是"队列",每个事件只被消费一次;Flow是"流",值可以被重复读取。一次性事件用Channel,持续状态用Flow。
下面是事件处理的完整流程示意:
#mermaid-svg-T0pNvdsftPfEqgCt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-T0pNvdsftPfEqgCt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-T0pNvdsftPfEqgCt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-T0pNvdsftPfEqgCt .error-icon{fill:#552222;}#mermaid-svg-T0pNvdsftPfEqgCt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-T0pNvdsftPfEqgCt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-T0pNvdsftPfEqgCt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-T0pNvdsftPfEqgCt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-T0pNvdsftPfEqgCt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-T0pNvdsftPfEqgCt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-T0pNvdsftPfEqgCt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-T0pNvdsftPfEqgCt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-T0pNvdsftPfEqgCt .marker.cross{stroke:#333333;}#mermaid-svg-T0pNvdsftPfEqgCt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-T0pNvdsftPfEqgCt p{margin:0;}#mermaid-svg-T0pNvdsftPfEqgCt .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-T0pNvdsftPfEqgCt .cluster-label text{fill:#333;}#mermaid-svg-T0pNvdsftPfEqgCt .cluster-label span{color:#333;}#mermaid-svg-T0pNvdsftPfEqgCt .cluster-label span p{background-color:transparent;}#mermaid-svg-T0pNvdsftPfEqgCt .label text,#mermaid-svg-T0pNvdsftPfEqgCt span{fill:#333;color:#333;}#mermaid-svg-T0pNvdsftPfEqgCt .node rect,#mermaid-svg-T0pNvdsftPfEqgCt .node circle,#mermaid-svg-T0pNvdsftPfEqgCt .node ellipse,#mermaid-svg-T0pNvdsftPfEqgCt .node polygon,#mermaid-svg-T0pNvdsftPfEqgCt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-T0pNvdsftPfEqgCt .rough-node .label text,#mermaid-svg-T0pNvdsftPfEqgCt .node .label text,#mermaid-svg-T0pNvdsftPfEqgCt .image-shape .label,#mermaid-svg-T0pNvdsftPfEqgCt .icon-shape .label{text-anchor:middle;}#mermaid-svg-T0pNvdsftPfEqgCt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-T0pNvdsftPfEqgCt .rough-node .label,#mermaid-svg-T0pNvdsftPfEqgCt .node .label,#mermaid-svg-T0pNvdsftPfEqgCt .image-shape .label,#mermaid-svg-T0pNvdsftPfEqgCt .icon-shape .label{text-align:center;}#mermaid-svg-T0pNvdsftPfEqgCt .node.clickable{cursor:pointer;}#mermaid-svg-T0pNvdsftPfEqgCt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-T0pNvdsftPfEqgCt .arrowheadPath{fill:#333333;}#mermaid-svg-T0pNvdsftPfEqgCt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-T0pNvdsftPfEqgCt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-T0pNvdsftPfEqgCt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T0pNvdsftPfEqgCt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-T0pNvdsftPfEqgCt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T0pNvdsftPfEqgCt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-T0pNvdsftPfEqgCt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-T0pNvdsftPfEqgCt .cluster text{fill:#333;}#mermaid-svg-T0pNvdsftPfEqgCt .cluster span{color:#333;}#mermaid-svg-T0pNvdsftPfEqgCt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-T0pNvdsftPfEqgCt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-T0pNvdsftPfEqgCt rect.text{fill:none;stroke-width:0;}#mermaid-svg-T0pNvdsftPfEqgCt .icon-shape,#mermaid-svg-T0pNvdsftPfEqgCt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T0pNvdsftPfEqgCt .icon-shape p,#mermaid-svg-T0pNvdsftPfEqgCt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-T0pNvdsftPfEqgCt .icon-shape .label rect,#mermaid-svg-T0pNvdsftPfEqgCt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T0pNvdsftPfEqgCt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-T0pNvdsftPfEqgCt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-T0pNvdsftPfEqgCt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 普通事件(点击/刷新/输入)
一次性事件(导航/Snackbar/弹窗)
UI 事件
事件类型?
方法回调
Channel 转 Flow
UI 重组时重复触发也没关系
每个事件只被消费一次
避免重复导航/重复弹窗
六、ViewModel的测试:为什么好测试,怎么测
6.1 ViewModel是纯Kotlin,测试零成本
ViewModel不依赖Android框架(除了基类和viewModelScope),可以用JVM单元测试。不需要模拟器,不需要启动UI,毫秒级跑完。
kotlin
class UserListViewModelTest {
@Test
fun `loadUsers updates uiState with data`() = runTest {
val fakeRepository = FakeUserRepository(
users = listOf(User(1, "张三"), User(2, "李四"))
)
val viewModel = UserListViewModel(fakeRepository)
// 等待加载完成
viewModel.uiState.first { !it.isLoading && it.users.isNotEmpty() }
assertEquals(2, viewModel.uiState.value.users.size)
assertNull(viewModel.uiState.value.error)
}
}
6.2 测试一次性事件
kotlin
@Test
fun `onUserClick emits navigate event`() = runTest {
val viewModel = UserListViewModel(fakeRepository)
val events = mutableListOf<UserListEvent>()
val job = launch { viewModel.events.toList(events) }
viewModel.onUserClick(42)
advanceUntilIdle()
assertTrue(events.any { it is UserListEvent.NavigateToDetail })
job.cancel()
}
6.3 测试的核心思路
用假的Repository,不用真的网络。 ViewModel的测试只关心"逻辑对不对",不关心"数据从哪来"。给假的Repository注入不同的返回值(成功、失败、空数据),断言UiState是否正确。
这就是为什么ViewModel要依赖Repository接口而不是具体实现------为了可替换、可测试。
七、最佳实践:ViewModel的"该做"与"不该做"
7.1 该做的五件事
第一,用UiState统一状态。
kotlin
data class UiState(
val users: List<User> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
第二,用viewModelScope管理协程。
kotlin
fun load() {
viewModelScope.launch {
// ViewModel销毁时自动取消
}
}
第三,用collectAsStateWithLifecycle收集Flow。
kotlin
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
后台时暂停收集,节省资源。
第四,用SharingStarted.WhileSubscribed控制上游流。
kotlin
val uiState = repository.getDataFlow()
.map { UiState(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UiState()
)
屏幕旋转时5秒内不重新订阅,避免重复加载。
第五,用Hilt注入依赖。
kotlin
@HiltViewModel
class UserListViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel()
7.2 不该做的五件事
第一,不持有Context。
kotlin
// 错误:内存泄漏
class MyViewModel(private val context: Context) : ViewModel()
用 AndroidViewModel 或注入 Application。
第二,不用GlobalScope。
kotlin
// 错误:协程泄漏
GlobalScope.launch { loadData() }
用 viewModelScope。
第三,不在ViewModel里操作View。
kotlin
// 错误:耦合、难测试
fun load() {
textView.text = "加载中" // ViewModel不应该知道View
}
ViewModel只更新UiState,UI自己决定怎么展示。
第四,不做数据转换。
kotlin
// 不推荐:转换应该在Repository
val domain = dto.map { it.toDomain() }
第五,不暴露可变状态。
kotlin
// 错误:外部可以随意修改
var uiState by mutableStateOf(UiState())
// 正确:只读暴露
private var _uiState by mutableStateOf(UiState())
val uiState: UiState get() = _uiState
八、常见陷阱:真实场景 + 后果 + 解法
| 陷阱 | 真实场景 | 后果 | 解法 |
|---|---|---|---|
| 持有Context | ViewModel里有 private val context: Context |
Activity销毁后Context泄漏 | 用 AndroidViewModel 或注入 Application |
| 用GlobalScope | GlobalScope.launch { loadData() } |
页面关闭后请求还在跑,回调时崩溃 | 用 viewModelScope |
| 一次性事件用StateFlow | 导航事件用 StateFlow<Long?> |
重组时重复导航,页面跳两次 | 用 Channel 转 Flow |
| 多个独立状态 | isLoading 和 users 分开存 |
出现"加载中但有数据"的矛盾 | 用统一UiState |
| 忘记backStackEntry | 导航页面里用 viewModel() |
页面弹出后ViewModel还在,内存泄漏 | viewModelStoreOwner = backStackEntry |
| 用collectAsState | 一直在后台收集 | 后台浪费资源 | 用 collectAsStateWithLifecycle() |
| 状态用var暴露 | var uiState by mutableStateOf(...) |
外部随意修改,状态不可控 | 用 private set 或只读暴露 |
| ViewModel里做耗时操作 | 在主线程读数据库 | UI卡顿 | 用 viewModelScope.launch + Dispatchers.IO |
九、综合实战:一个完整的用户列表ViewModel
把这一课的知识串起来,看一个生产级的例子。
9.1 UiState
kotlin
data class UserListUiState(
val users: List<User> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: String? = null
)
9.2 ViewModel
kotlin
@HiltViewModel
class UserListViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UserListUiState())
val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()
private val _events = Channel<UserListEvent>()
val events = _events.receiveAsFlow()
init {
loadUsers()
}
fun loadUsers() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
repository.getUsers()
.onSuccess { users ->
_uiState.update { it.copy(users = users, isLoading = false) }
}
.onFailure { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
_events.send(UserListEvent.ShowMessage(e.message ?: "加载失败"))
}
}
}
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
repository.getUsers()
.onSuccess { users ->
_uiState.update { it.copy(users = users, isRefreshing = false) }
}
.onFailure { e ->
_uiState.update { it.copy(isRefreshing = false, error = e.message) }
_events.send(UserListEvent.ShowMessage(e.message ?: "刷新失败"))
}
}
}
fun onUserClick(id: Long) {
viewModelScope.launch {
_events.send(UserListEvent.NavigateToDetail(id))
}
}
fun onDelete(id: Long) {
viewModelScope.launch {
repository.deleteUser(id)
.onSuccess {
_uiState.update { state ->
state.copy(users = state.users.filter { it.id != id })
}
_events.send(UserListEvent.ShowMessage("已删除"))
}
.onFailure { e ->
_events.send(UserListEvent.ShowMessage(e.message ?: "删除失败"))
}
}
}
}
sealed class UserListEvent {
data class NavigateToDetail(val id: Long) : UserListEvent()
data class ShowMessage(val message: String) : UserListEvent()
}
9.3 UI层
kotlin
@Composable
fun UserListRoute(
onNavigateToDetail: (Long) -> Unit,
viewModel: UserListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UserListEvent.NavigateToDetail -> onNavigateToDetail(event.id)
is UserListEvent.ShowMessage -> snackbarHostState.showSnackbar(event.message)
}
}
}
UserListScreen(
uiState = uiState,
snackbarHostState = snackbarHostState,
onRefresh = viewModel::refresh,
onUserClick = viewModel::onUserClick,
onDelete = viewModel::onDelete
)
}
9.4 设计要点回看
为什么用StateFlow而不是mutableStateOf :因为需要 update 操作符来原子地更新UiState,避免并发修改。
为什么用Channel处理事件:导航和Snackbar只应该发生一次,StateFlow会重复触发。
为什么init里调用loadUsers:页面创建时自动加载,不需要UI层手动触发。
为什么onDelete成功后更新本地列表:避免重新请求整个列表,只移除被删除的项,用户体验更流畅。
为什么用hiltViewModel:自动注入Repository依赖,不需要手动创建工厂。
十、ViewModel的思维模型
最后,用一张"思维模型"来总结这一课:
第一层:为什么需要ViewModel。 配置变更时状态不丢失,业务逻辑与UI分离,可测试,可复用。
第二层:生命周期。 ViewModel比Activity/组合更长。配置变更时存活,Activity真正销毁时清理。导航页面用 backStackEntry 绑定生命周期。
第三层:状态管理。 用 UiState 统一状态。StateFlow 或 mutableStateOf 二选一,不要混用。StateFlow 适合复杂流,mutableStateOf 适合简单场景。
第四层:事件处理。 普通事件用方法回调,一次性事件用 Channel 转 Flow。导航、Snackbar绝不用StateFlow。
第五层:协程。 用 viewModelScope 启动协程,ViewModel销毁时自动取消。不用GlobalScope。
第六层:依赖注入。 用Hilt的 @HiltViewModel 和 hiltViewModel()。Repository通过构造函数注入,方便替换和测试。
贯穿始终的原则:ViewModel是UI的大脑,不是UI的仆人。 它持有状态、处理逻辑、调用数据层,但不知道UI长什么样。UI只负责展示状态、发送事件。大脑和身体各司其职,代码才能清晰、可测、可维护。
十一、小结与下一课预告
这一课我们彻底搞懂了Compose中的ViewModel。关键点回顾:
- ViewModel是什么:持有UI状态、处理UI事件的类,生命周期比Activity/组合更长。它是UI的大脑。
- 为什么需要:配置变更时状态不丢失,业务逻辑与UI分离,可测试,可复用。
- 在Compose中使用 :
viewModel()获取实例;导航中用backStackEntry作为Owner;带参数用工厂或SavedStateHandle。 - 状态管理 :
StateFlowvsmutableStateOf二选一;用UiState统一状态;不要混用两种方式。 - 事件处理 :普通事件用方法回调;一次性事件用
Channel转Flow,绝不用StateFlow。 - 测试:纯JVM单元测试;用假的Repository;断言UiState;测试一次性事件用Channel收集。
- 最佳实践 :只做三件事(持有状态、处理事件、调用数据层);统一UiState;用
viewModelScope;用collectAsStateWithLifecycle;用SharingStarted.WhileSubscribed;用Hilt注入。 - 常见陷阱:持有Context、用GlobalScope、一次性事件用StateFlow、多个独立状态、忘记backStackEntry、用collectAsState、状态用var暴露、主线程做耗时操作。
ViewModel是Compose架构的核心。 掌握了它,你的代码就从"能跑"变成了"可维护"。UI只负责看和点,大脑负责想和记------这就是ViewModel的价值。
下一课,我们会讲Repository模式与数据层设计。ViewModel负责"想",Repository负责"取"。网络请求、数据库查询、缓存策略、数据映射------这些都在Repository层完成。我们会讲Repository的接口设计、多数据源合并、缓存策略、错误处理、以及如何用Flow构建响应式数据流。这是把Compose应用从"Demo"提升到"生产级"的关键一课。
课后练习建议:找一段你之前写在可组合函数里的业务逻辑,把它抽到一个ViewModel里。定义UiState,用StateFlow或mutableStateOf暴露状态,用viewModelScope管理协程,用Channel处理一次性事件。然后给这个ViewModel写两个测试:一个测试成功加载,一个测试失败情况。做完之后,你会发现代码清晰了很多,而且旋转屏幕再也不丢状态了。