【Jetpack Compose娓娓道来】 第25课:ViewModel就是UI界面的大脑

目录

  • 一、先看一段"没有大脑"的代码
  • 二、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() 干了三件事:

  1. 从当前 ViewModelStoreOwner 获取或创建实例
  2. 配置变更时返回同一个实例
  3. 组件销毁时自动清理

默认的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的三个好处:

  1. 状态是一个整体,不会出现矛盾组合
  2. 新增字段只需改一处
  3. 测试时构造一个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。
  • 状态管理 :StateFlow vs mutableStateOf 二选一;用 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写两个测试:一个测试成功加载,一个测试失败情况。做完之后,你会发现代码清晰了很多,而且旋转屏幕再也不丢状态了。

相关推荐
马立杰1 小时前
PHP 未授权 RCE 应急响应:CVE-2026-109618 CVE-2026-109619 排查与加固实战
android·开发语言·php
NamoAmitabha1281 小时前
安装“编译器环境”(Android Studio)--deepseek--Cocos --Creator3.8.8
android·ide·android studio
聚美智数2 小时前
文档转换 API‑文档转 PDF‑PDF 转文档‑异步文档处理‑文件格式迁移‑转换结果回调推送 API 接口介绍
android·java·pdf
booboosui3 小时前
Android 上的无人回合:只放开 2 类工具,宁可空手
android
贾伟康4 小时前
【健康菜谱助手|04】HarmonyOS ArkTS 一周膳食计划实战:管理日期、餐次与菜谱替换的联动
软件测试·移动开发·harmonyos·arkts·arkui·1024程序员节
贾伟康4 小时前
【健康菜谱助手|02】HarmonyOS ArkTS 烹饪步骤实战:把分步操作、完成状态和返回恢复做稳
软件测试·移动开发·harmonyos·arkts·arkui
事圆则缓5 小时前
Android 性能排查实战:怎么看内存抖动、CPU 占用和 FPS
android
mmsx5 小时前
Android 三类「看着无关」的故障:大事务、UI 阻塞与 ViewModel 作用域
android·ui
换元不配限5 小时前
Android 休眠与唤醒完全指南:从手机到 IoT 设备的实战解析
android·物联网·智能手机·alarmmanager·wakelock·workmanager·休眠和唤醒