目录
- 一、先看一个"状态打架"的现场
- 二、UiState:状态的"唯一真相"
- [2.1 从散装到打包](#2.1 从散装到打包)
- [2.2 data class 还是 sealed class](#2.2 data class 还是 sealed class)
- [2.3 空状态的正确表达](#2.3 空状态的正确表达)
- [2.4 加载状态的细分](#2.4 加载状态的细分)
- 三、UI事件建模:从"回调满天飞"到"明确的消息"
- [3.1 两种事件建模方式](#3.1 两种事件建模方式)
- [3.2 怎么选](#3.2 怎么选)
- [3.3 一次性事件 vs 持续状态](#3.3 一次性事件 vs 持续状态)
- 四、状态容器:ViewModel之外的选择
- [4.1 什么是状态容器](#4.1 什么是状态容器)
- [4.2 用remember + 普通类做状态容器](#4.2 用remember + 普通类做状态容器)
- [4.3 状态容器与ViewModel的边界](#4.3 状态容器与ViewModel的边界)
- 五、状态建模的"三不原则"
- [5.1 不存储能派生的状态](#5.1 不存储能派生的状态)
- [5.2 不用多个布尔值表示互斥状态](#5.2 不用多个布尔值表示互斥状态)
- [5.3 不在UI层做状态判断的"猜谜"](#5.3 不在UI层做状态判断的“猜谜”)
- 六、常见陷阱速查
- 七、综合实战:用户列表的完整状态建模
- [7.1 UiState](#7.1 UiState)
- [7.2 Effect](#7.2 Effect)
- [7.3 Event](#7.3 Event)
- [7.4 ViewModel](#7.4 ViewModel)
- [7.5 UI层](#7.5 UI层)
- 八、状态建模的思维模型
- 九、小结与下一课预告
一、先看一个"状态打架"的现场
第30到33课,我们把架构的零件组装好了:ViewModel是大脑,Repository是管家,UseCase是食谱,Hilt是装配线。现在大脑里存着状态,但状态长什么样?
很多人的代码是这样的:
kotlin
class UserListViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
var users by mutableStateOf<List<User>>(emptyList())
private set
var isLoading by mutableStateOf(false)
private set
var isRefreshing by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set
var isEmpty by mutableStateOf(false)
private set
var selectedUser by mutableStateOf<User?>(null)
private set
}
六个独立的状态,各自为政。然后UI层开始写这样的判断:
kotlin
when {
isLoading -> 显示加载中
error != null -> 显示错误
isEmpty -> 显示空状态
users.isNotEmpty() -> 显示列表
else -> 显示什么??
}
问题来了:如果 isLoading=true 同时 error!=null 同时 users 有数据,UI该显示什么? 代码没告诉你,编译器也没告诉你。你只能靠"约定"和"祈祷"。有一天某个协程忘了把 isLoading 设回 false,页面就永远卡在转圈上。
更隐蔽的问题:isEmpty 和 users.isEmpty() 是两份数据,它们可能不一致。 你在一处更新了 users,忘了更新 isEmpty,UI就会显示"空状态"但列表里明明有数据。
这就是"散装状态"的代价:状态之间可以有矛盾组合,而编译器无法帮你检查。
这一课,我们就来解决这个问题:把散装状态打包成"可穷举的契约",把UI事件建模成"明确的消息"。
下面是本课要解决的问题与解决思路总览:
#mermaid-svg-EOpnXwaW9xLr8nPu{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-EOpnXwaW9xLr8nPu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EOpnXwaW9xLr8nPu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EOpnXwaW9xLr8nPu .error-icon{fill:#552222;}#mermaid-svg-EOpnXwaW9xLr8nPu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EOpnXwaW9xLr8nPu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EOpnXwaW9xLr8nPu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EOpnXwaW9xLr8nPu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EOpnXwaW9xLr8nPu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EOpnXwaW9xLr8nPu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EOpnXwaW9xLr8nPu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EOpnXwaW9xLr8nPu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EOpnXwaW9xLr8nPu .marker.cross{stroke:#333333;}#mermaid-svg-EOpnXwaW9xLr8nPu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EOpnXwaW9xLr8nPu p{margin:0;}#mermaid-svg-EOpnXwaW9xLr8nPu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EOpnXwaW9xLr8nPu .cluster-label text{fill:#333;}#mermaid-svg-EOpnXwaW9xLr8nPu .cluster-label span{color:#333;}#mermaid-svg-EOpnXwaW9xLr8nPu .cluster-label span p{background-color:transparent;}#mermaid-svg-EOpnXwaW9xLr8nPu .label text,#mermaid-svg-EOpnXwaW9xLr8nPu span{fill:#333;color:#333;}#mermaid-svg-EOpnXwaW9xLr8nPu .node rect,#mermaid-svg-EOpnXwaW9xLr8nPu .node circle,#mermaid-svg-EOpnXwaW9xLr8nPu .node ellipse,#mermaid-svg-EOpnXwaW9xLr8nPu .node polygon,#mermaid-svg-EOpnXwaW9xLr8nPu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EOpnXwaW9xLr8nPu .rough-node .label text,#mermaid-svg-EOpnXwaW9xLr8nPu .node .label text,#mermaid-svg-EOpnXwaW9xLr8nPu .image-shape .label,#mermaid-svg-EOpnXwaW9xLr8nPu .icon-shape .label{text-anchor:middle;}#mermaid-svg-EOpnXwaW9xLr8nPu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EOpnXwaW9xLr8nPu .rough-node .label,#mermaid-svg-EOpnXwaW9xLr8nPu .node .label,#mermaid-svg-EOpnXwaW9xLr8nPu .image-shape .label,#mermaid-svg-EOpnXwaW9xLr8nPu .icon-shape .label{text-align:center;}#mermaid-svg-EOpnXwaW9xLr8nPu .node.clickable{cursor:pointer;}#mermaid-svg-EOpnXwaW9xLr8nPu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EOpnXwaW9xLr8nPu .arrowheadPath{fill:#333333;}#mermaid-svg-EOpnXwaW9xLr8nPu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EOpnXwaW9xLr8nPu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EOpnXwaW9xLr8nPu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EOpnXwaW9xLr8nPu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EOpnXwaW9xLr8nPu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EOpnXwaW9xLr8nPu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EOpnXwaW9xLr8nPu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EOpnXwaW9xLr8nPu .cluster text{fill:#333;}#mermaid-svg-EOpnXwaW9xLr8nPu .cluster span{color:#333;}#mermaid-svg-EOpnXwaW9xLr8nPu 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-EOpnXwaW9xLr8nPu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EOpnXwaW9xLr8nPu rect.text{fill:none;stroke-width:0;}#mermaid-svg-EOpnXwaW9xLr8nPu .icon-shape,#mermaid-svg-EOpnXwaW9xLr8nPu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EOpnXwaW9xLr8nPu .icon-shape p,#mermaid-svg-EOpnXwaW9xLr8nPu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EOpnXwaW9xLr8nPu .icon-shape .label rect,#mermaid-svg-EOpnXwaW9xLr8nPu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EOpnXwaW9xLr8nPu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EOpnXwaW9xLr8nPu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EOpnXwaW9xLr8nPu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 散装状态:多个独立布尔/字段
状态之间可矛盾
UI层靠约定和祈祷判断
出现诡异Bug:加载中显示空状态等
解决:打包成可穷举的契约
UiState:状态的唯一真相
UI事件:明确的消息
data class 可共存 / sealed class 互斥
能派生的不存储
加载状态用枚举细分
方法回调 / 密封类事件
Effect 一次性事件 / UiState 持续状态
二、UiState:状态的"唯一真相"
2.1 从散装到打包
把上面六个状态打包成一个 data class:
kotlin
data class UserListUiState(
val users: List<User> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: String? = null,
val selectedUser: User? = null
) {
val isEmpty: Boolean get() = users.isEmpty() && !isLoading && error == null
}
注意 isEmpty 变成了计算属性 ,不再是独立存储的状态。它从 users、isLoading、error 派生出来,永远不可能和它们矛盾。这是消除"状态打架"的第一招:能派生的,就不要存储。
再看UI层的判断:
kotlin
when {
uiState.isLoading && uiState.users.isEmpty() -> 显示全屏加载
uiState.error != null && uiState.users.isEmpty() -> 显示全屏错误
uiState.isEmpty -> 显示空状态
else -> 显示列表(可能同时有刷新进度条和错误提示)
}
现在 when 的每个分支都是互斥的 ,因为 isEmpty 的定义保证了它不会和 isLoading、error 同时为真。UI层不再需要"猜",只需要"穷举"。
下面是"从散装到打包"的改造流程:
#mermaid-svg-ITS3qJXyw9kUzMyw{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-ITS3qJXyw9kUzMyw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ITS3qJXyw9kUzMyw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ITS3qJXyw9kUzMyw .error-icon{fill:#552222;}#mermaid-svg-ITS3qJXyw9kUzMyw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ITS3qJXyw9kUzMyw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ITS3qJXyw9kUzMyw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ITS3qJXyw9kUzMyw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ITS3qJXyw9kUzMyw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ITS3qJXyw9kUzMyw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ITS3qJXyw9kUzMyw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ITS3qJXyw9kUzMyw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ITS3qJXyw9kUzMyw .marker.cross{stroke:#333333;}#mermaid-svg-ITS3qJXyw9kUzMyw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ITS3qJXyw9kUzMyw p{margin:0;}#mermaid-svg-ITS3qJXyw9kUzMyw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ITS3qJXyw9kUzMyw .cluster-label text{fill:#333;}#mermaid-svg-ITS3qJXyw9kUzMyw .cluster-label span{color:#333;}#mermaid-svg-ITS3qJXyw9kUzMyw .cluster-label span p{background-color:transparent;}#mermaid-svg-ITS3qJXyw9kUzMyw .label text,#mermaid-svg-ITS3qJXyw9kUzMyw span{fill:#333;color:#333;}#mermaid-svg-ITS3qJXyw9kUzMyw .node rect,#mermaid-svg-ITS3qJXyw9kUzMyw .node circle,#mermaid-svg-ITS3qJXyw9kUzMyw .node ellipse,#mermaid-svg-ITS3qJXyw9kUzMyw .node polygon,#mermaid-svg-ITS3qJXyw9kUzMyw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ITS3qJXyw9kUzMyw .rough-node .label text,#mermaid-svg-ITS3qJXyw9kUzMyw .node .label text,#mermaid-svg-ITS3qJXyw9kUzMyw .image-shape .label,#mermaid-svg-ITS3qJXyw9kUzMyw .icon-shape .label{text-anchor:middle;}#mermaid-svg-ITS3qJXyw9kUzMyw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ITS3qJXyw9kUzMyw .rough-node .label,#mermaid-svg-ITS3qJXyw9kUzMyw .node .label,#mermaid-svg-ITS3qJXyw9kUzMyw .image-shape .label,#mermaid-svg-ITS3qJXyw9kUzMyw .icon-shape .label{text-align:center;}#mermaid-svg-ITS3qJXyw9kUzMyw .node.clickable{cursor:pointer;}#mermaid-svg-ITS3qJXyw9kUzMyw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ITS3qJXyw9kUzMyw .arrowheadPath{fill:#333333;}#mermaid-svg-ITS3qJXyw9kUzMyw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ITS3qJXyw9kUzMyw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ITS3qJXyw9kUzMyw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ITS3qJXyw9kUzMyw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ITS3qJXyw9kUzMyw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ITS3qJXyw9kUzMyw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ITS3qJXyw9kUzMyw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ITS3qJXyw9kUzMyw .cluster text{fill:#333;}#mermaid-svg-ITS3qJXyw9kUzMyw .cluster span{color:#333;}#mermaid-svg-ITS3qJXyw9kUzMyw 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-ITS3qJXyw9kUzMyw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ITS3qJXyw9kUzMyw rect.text{fill:none;stroke-width:0;}#mermaid-svg-ITS3qJXyw9kUzMyw .icon-shape,#mermaid-svg-ITS3qJXyw9kUzMyw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ITS3qJXyw9kUzMyw .icon-shape p,#mermaid-svg-ITS3qJXyw9kUzMyw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ITS3qJXyw9kUzMyw .icon-shape .label rect,#mermaid-svg-ITS3qJXyw9kUzMyw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ITS3qJXyw9kUzMyw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ITS3qJXyw9kUzMyw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ITS3qJXyw9kUzMyw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 六个独立状态
打包成 data class
isEmpty 改为派生属性
UI层 when 分支互斥
不再需要猜,只需穷举
2.2 data class 还是 sealed class
这是UiState建模的第一个分岔路口。
data class 适合"可以叠加"的状态:加载中可以有旧数据,刷新中可以有错误提示,列表可以同时有数据和"正在加载更多"。这些状态不是互斥的,而是可以共存的。
sealed class 适合"互斥"的状态:一次登录操作,要么成功,要么失败,要么加载中,不可能同时存在。这种场景用密封类,编译器会强制你处理每一种情况。
kotlin
// 互斥场景:登录
sealed interface LoginUiState {
data object Idle : LoginUiState
data object Loading : LoginUiState
data class Success(val user: User) : LoginUiState
data class Error(val message: String) : LoginUiState
}
// UI层
when (val state = uiState) {
LoginUiState.Idle -> 显示表单
LoginUiState.Loading -> 显示加载中
is LoginUiState.Success -> 显示成功
is LoginUiState.Error -> 显示错误
// 没有 else!编译器强制穷举
}
关键区别 :sealed class 的 when 不需要 else 分支 ,因为编译器知道所有可能的子类型。如果你新增了一个状态,所有没处理它的 when 都会编译报错------这是编译器在帮你找Bug。
data class 的 when 通常需要 else,因为布尔条件的组合是无穷的。你只能靠"设计上保证互斥"来避免矛盾。
选择标准:
- 状态之间可以共存 →
data class+ 派生属性 - 状态之间互斥 →
sealed class - 大多数列表页用
data class,因为"加载中"和"有旧数据"可以共存。 - 大多数表单提交、登录、支付用
sealed class,因为结果互斥。
下面是 data class 与 sealed class 的选择决策流程:
#mermaid-svg-kuSDDGt1ixvhyEJn{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-kuSDDGt1ixvhyEJn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kuSDDGt1ixvhyEJn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kuSDDGt1ixvhyEJn .error-icon{fill:#552222;}#mermaid-svg-kuSDDGt1ixvhyEJn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kuSDDGt1ixvhyEJn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kuSDDGt1ixvhyEJn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kuSDDGt1ixvhyEJn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kuSDDGt1ixvhyEJn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kuSDDGt1ixvhyEJn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kuSDDGt1ixvhyEJn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kuSDDGt1ixvhyEJn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kuSDDGt1ixvhyEJn .marker.cross{stroke:#333333;}#mermaid-svg-kuSDDGt1ixvhyEJn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kuSDDGt1ixvhyEJn p{margin:0;}#mermaid-svg-kuSDDGt1ixvhyEJn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kuSDDGt1ixvhyEJn .cluster-label text{fill:#333;}#mermaid-svg-kuSDDGt1ixvhyEJn .cluster-label span{color:#333;}#mermaid-svg-kuSDDGt1ixvhyEJn .cluster-label span p{background-color:transparent;}#mermaid-svg-kuSDDGt1ixvhyEJn .label text,#mermaid-svg-kuSDDGt1ixvhyEJn span{fill:#333;color:#333;}#mermaid-svg-kuSDDGt1ixvhyEJn .node rect,#mermaid-svg-kuSDDGt1ixvhyEJn .node circle,#mermaid-svg-kuSDDGt1ixvhyEJn .node ellipse,#mermaid-svg-kuSDDGt1ixvhyEJn .node polygon,#mermaid-svg-kuSDDGt1ixvhyEJn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kuSDDGt1ixvhyEJn .rough-node .label text,#mermaid-svg-kuSDDGt1ixvhyEJn .node .label text,#mermaid-svg-kuSDDGt1ixvhyEJn .image-shape .label,#mermaid-svg-kuSDDGt1ixvhyEJn .icon-shape .label{text-anchor:middle;}#mermaid-svg-kuSDDGt1ixvhyEJn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kuSDDGt1ixvhyEJn .rough-node .label,#mermaid-svg-kuSDDGt1ixvhyEJn .node .label,#mermaid-svg-kuSDDGt1ixvhyEJn .image-shape .label,#mermaid-svg-kuSDDGt1ixvhyEJn .icon-shape .label{text-align:center;}#mermaid-svg-kuSDDGt1ixvhyEJn .node.clickable{cursor:pointer;}#mermaid-svg-kuSDDGt1ixvhyEJn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kuSDDGt1ixvhyEJn .arrowheadPath{fill:#333333;}#mermaid-svg-kuSDDGt1ixvhyEJn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kuSDDGt1ixvhyEJn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kuSDDGt1ixvhyEJn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kuSDDGt1ixvhyEJn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kuSDDGt1ixvhyEJn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kuSDDGt1ixvhyEJn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kuSDDGt1ixvhyEJn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kuSDDGt1ixvhyEJn .cluster text{fill:#333;}#mermaid-svg-kuSDDGt1ixvhyEJn .cluster span{color:#333;}#mermaid-svg-kuSDDGt1ixvhyEJn 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-kuSDDGt1ixvhyEJn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kuSDDGt1ixvhyEJn rect.text{fill:none;stroke-width:0;}#mermaid-svg-kuSDDGt1ixvhyEJn .icon-shape,#mermaid-svg-kuSDDGt1ixvhyEJn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kuSDDGt1ixvhyEJn .icon-shape p,#mermaid-svg-kuSDDGt1ixvhyEJn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kuSDDGt1ixvhyEJn .icon-shape .label rect,#mermaid-svg-kuSDDGt1ixvhyEJn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kuSDDGt1ixvhyEJn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kuSDDGt1ixvhyEJn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kuSDDGt1ixvhyEJn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 能
不能
状态之间能否共存?
data class + 派生属性
sealed class 强制穷举
列表页:加载中与旧数据可共存
登录/表单提交/支付:结果互斥
2.3 空状态的正确表达
空状态是最容易建模错的地方。很多人会存一个 isEmpty 布尔值,然后忘了更新。
正确做法:空状态是派生的,不是存储的。
kotlin
data class UserListUiState(
val users: List<User> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
) {
// 只有"加载完成 + 没错误 + 列表为空"才是真正的空状态
val isEmpty: Boolean
get() = users.isEmpty() && !isLoading && error == null
}
为什么不能存 isEmpty :如果存了,它和 users、isLoading、error 是四份独立数据,任何一处更新遗漏都会导致不一致。派生属性永远从真相计算,不可能出错。
2.4 加载状态的细分
"加载中"其实有三种不同的场景,UI表现完全不同:
| 场景 | 条件 | UI表现 |
|---|---|---|
| 首次加载 | isLoading=true && users.isEmpty() |
全屏转圈 |
| 下拉刷新 | isRefreshing=true && users.isNotEmpty() |
顶部进度条,列表还在 |
| 加载更多 | isLoadingMore=true && users.isNotEmpty() |
底部转圈,列表还在 |
如果用同一个 isLoading 表示三种场景,UI层就分不清该显示全屏转圈还是顶部进度条。结果就是:用户下拉刷新时,整个列表消失,变成一个大转圈------体验极差。
正确做法:用三个独立的布尔值,或者用一个枚举。
kotlin
data class UserListUiState(
val users: List<User> = emptyList(),
val loadState: LoadState = LoadState.Idle,
val error: String? = null
) {
val isEmpty: Boolean
get() = users.isEmpty() && loadState == LoadState.Idle && error == null
}
enum class LoadState {
Idle, // 空闲
FirstLoading, // 首次加载
Refreshing, // 下拉刷新
LoadingMore // 加载更多
}
用枚举的好处 :四种状态互斥,不可能同时"首次加载"又"下拉刷新"。UI层用 when 穷举,每个分支的UI表现清晰明确。
下面是加载状态的细分与UI表现对应关系:
#mermaid-svg-emmCfGJgXfdeMQMR{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-emmCfGJgXfdeMQMR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-emmCfGJgXfdeMQMR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-emmCfGJgXfdeMQMR .error-icon{fill:#552222;}#mermaid-svg-emmCfGJgXfdeMQMR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-emmCfGJgXfdeMQMR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-emmCfGJgXfdeMQMR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-emmCfGJgXfdeMQMR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-emmCfGJgXfdeMQMR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-emmCfGJgXfdeMQMR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-emmCfGJgXfdeMQMR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-emmCfGJgXfdeMQMR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-emmCfGJgXfdeMQMR .marker.cross{stroke:#333333;}#mermaid-svg-emmCfGJgXfdeMQMR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-emmCfGJgXfdeMQMR p{margin:0;}#mermaid-svg-emmCfGJgXfdeMQMR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-emmCfGJgXfdeMQMR .cluster-label text{fill:#333;}#mermaid-svg-emmCfGJgXfdeMQMR .cluster-label span{color:#333;}#mermaid-svg-emmCfGJgXfdeMQMR .cluster-label span p{background-color:transparent;}#mermaid-svg-emmCfGJgXfdeMQMR .label text,#mermaid-svg-emmCfGJgXfdeMQMR span{fill:#333;color:#333;}#mermaid-svg-emmCfGJgXfdeMQMR .node rect,#mermaid-svg-emmCfGJgXfdeMQMR .node circle,#mermaid-svg-emmCfGJgXfdeMQMR .node ellipse,#mermaid-svg-emmCfGJgXfdeMQMR .node polygon,#mermaid-svg-emmCfGJgXfdeMQMR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-emmCfGJgXfdeMQMR .rough-node .label text,#mermaid-svg-emmCfGJgXfdeMQMR .node .label text,#mermaid-svg-emmCfGJgXfdeMQMR .image-shape .label,#mermaid-svg-emmCfGJgXfdeMQMR .icon-shape .label{text-anchor:middle;}#mermaid-svg-emmCfGJgXfdeMQMR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-emmCfGJgXfdeMQMR .rough-node .label,#mermaid-svg-emmCfGJgXfdeMQMR .node .label,#mermaid-svg-emmCfGJgXfdeMQMR .image-shape .label,#mermaid-svg-emmCfGJgXfdeMQMR .icon-shape .label{text-align:center;}#mermaid-svg-emmCfGJgXfdeMQMR .node.clickable{cursor:pointer;}#mermaid-svg-emmCfGJgXfdeMQMR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-emmCfGJgXfdeMQMR .arrowheadPath{fill:#333333;}#mermaid-svg-emmCfGJgXfdeMQMR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-emmCfGJgXfdeMQMR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-emmCfGJgXfdeMQMR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-emmCfGJgXfdeMQMR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-emmCfGJgXfdeMQMR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-emmCfGJgXfdeMQMR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-emmCfGJgXfdeMQMR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-emmCfGJgXfdeMQMR .cluster text{fill:#333;}#mermaid-svg-emmCfGJgXfdeMQMR .cluster span{color:#333;}#mermaid-svg-emmCfGJgXfdeMQMR 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-emmCfGJgXfdeMQMR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-emmCfGJgXfdeMQMR rect.text{fill:none;stroke-width:0;}#mermaid-svg-emmCfGJgXfdeMQMR .icon-shape,#mermaid-svg-emmCfGJgXfdeMQMR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-emmCfGJgXfdeMQMR .icon-shape p,#mermaid-svg-emmCfGJgXfdeMQMR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-emmCfGJgXfdeMQMR .icon-shape .label rect,#mermaid-svg-emmCfGJgXfdeMQMR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-emmCfGJgXfdeMQMR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-emmCfGJgXfdeMQMR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-emmCfGJgXfdeMQMR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} LoadState 枚举
Idle:显示列表
FirstLoading:全屏转圈
Refreshing:顶部进度条
LoadingMore:底部转圈
users 为空
users 非空
三、UI事件建模:从"回调满天飞"到"明确的消息"
3.1 两种事件建模方式
ViewModel接收UI事件,有两种主流方式。
方式一:方法回调(onXxx)
kotlin
class UserListViewModel : ViewModel() {
fun onRefresh() { }
fun onUserClick(id: Long) { }
fun onDelete(id: Long) { }
fun onSearchQueryChange(query: String) { }
}
方式二:密封类事件(UiEvent)
kotlin
sealed interface UserListEvent {
data object Refresh : UserListEvent
data class UserClick(val id: Long) : UserListEvent
data class Delete(val id: Long) : UserListEvent
data class SearchQueryChange(val query: String) : UserListEvent
}
class UserListViewModel : ViewModel() {
fun onEvent(event: UserListEvent) {
when (event) {
UserListEvent.Refresh -> refresh()
is UserListEvent.UserClick -> navigateToDetail(event.id)
is UserListEvent.Delete -> delete(event.id)
is UserListEvent.SearchQueryChange -> updateQuery(event.query)
}
}
}
3.2 怎么选
| 维度 | 方法回调 | 密封类事件 |
|---|---|---|
| 简洁性 | 简单直接 | 多一层抽象 |
| 可穷举性 | 方法之间无关联 | when 强制穷举所有事件 |
| 可测试性 | 直接调方法 | 构造事件对象调用 onEvent |
| 可追踪性 | 调用分散 | 所有事件集中在一个密封类 |
| 适合场景 | 事件少、简单页面 | 事件多、复杂页面、需要日志/埋点 |
建议:
- 事件少于5个、逻辑简单 → 方法回调。不要为了"架构感"硬套密封类。
- 事件多、需要统一埋点、需要记录事件流 → 密封类事件。所有事件经过
onEvent一个入口,方便加日志、加防抖、加权限检查。
一个实用的中间方案:方法回调 + 内部统一处理。
kotlin
class UserListViewModel : ViewModel() {
fun onRefresh() = handleEvent(UserListEvent.Refresh)
fun onUserClick(id: Long) = handleEvent(UserListEvent.UserClick(id))
fun onDelete(id: Long) = handleEvent(UserListEvent.Delete(id))
private fun handleEvent(event: UserListEvent) {
// 统一日志、埋点、防抖
when (event) { ... }
}
}
对外暴露简单的方法,内部走统一的事件处理。兼顾了简洁性和可追踪性。
3.3 一次性事件 vs 持续状态
第30课讲过:导航、Snackbar这类事件只应该发生一次,不能用 StateFlow,要用 Channel。
但这里有一个更细的建模问题:一次性事件的"类型"怎么设计?
kotlin
// 不推荐:用字符串表示事件
_channel.send("跳转到详情页")
// 推荐:用密封类
sealed interface UserListEffect {
data class NavigateToDetail(val id: Long) : UserListEffect
data class ShowSnackbar(val message: String) : UserListEffect
data object NavigateBack : UserListEffect
}
命名建议 :一次性事件叫 Effect(副作用),持续状态叫 UiState。这样一眼就能区分"这是状态还是事件"。
kotlin
class UserListViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UserListUiState())
val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()
private val _effects = Channel<UserListEffect>()
val effects = _effects.receiveAsFlow()
}
UI层:
kotlin
// 持续状态:用collectAsStateWithLifecycle
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 一次性事件:用LaunchedEffect收集
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is UserListEffect.NavigateToDetail -> navController.navigate(...)
is UserListEffect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message)
UserListEffect.NavigateBack -> navController.popBackStack()
}
}
}
关键区分:
- UiState:UI"现在长什么样"。持续存在,重组时重新读取。
- Effect:UI"应该做什么"。瞬时发生,只消费一次。
下面是 UiState、Effect、Event 三者的关系与数据流向:
#mermaid-svg-lUcUtKqSPVdywzLg{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-lUcUtKqSPVdywzLg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lUcUtKqSPVdywzLg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lUcUtKqSPVdywzLg .error-icon{fill:#552222;}#mermaid-svg-lUcUtKqSPVdywzLg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lUcUtKqSPVdywzLg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lUcUtKqSPVdywzLg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lUcUtKqSPVdywzLg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lUcUtKqSPVdywzLg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lUcUtKqSPVdywzLg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lUcUtKqSPVdywzLg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lUcUtKqSPVdywzLg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lUcUtKqSPVdywzLg .marker.cross{stroke:#333333;}#mermaid-svg-lUcUtKqSPVdywzLg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lUcUtKqSPVdywzLg p{margin:0;}#mermaid-svg-lUcUtKqSPVdywzLg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lUcUtKqSPVdywzLg .cluster-label text{fill:#333;}#mermaid-svg-lUcUtKqSPVdywzLg .cluster-label span{color:#333;}#mermaid-svg-lUcUtKqSPVdywzLg .cluster-label span p{background-color:transparent;}#mermaid-svg-lUcUtKqSPVdywzLg .label text,#mermaid-svg-lUcUtKqSPVdywzLg span{fill:#333;color:#333;}#mermaid-svg-lUcUtKqSPVdywzLg .node rect,#mermaid-svg-lUcUtKqSPVdywzLg .node circle,#mermaid-svg-lUcUtKqSPVdywzLg .node ellipse,#mermaid-svg-lUcUtKqSPVdywzLg .node polygon,#mermaid-svg-lUcUtKqSPVdywzLg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lUcUtKqSPVdywzLg .rough-node .label text,#mermaid-svg-lUcUtKqSPVdywzLg .node .label text,#mermaid-svg-lUcUtKqSPVdywzLg .image-shape .label,#mermaid-svg-lUcUtKqSPVdywzLg .icon-shape .label{text-anchor:middle;}#mermaid-svg-lUcUtKqSPVdywzLg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lUcUtKqSPVdywzLg .rough-node .label,#mermaid-svg-lUcUtKqSPVdywzLg .node .label,#mermaid-svg-lUcUtKqSPVdywzLg .image-shape .label,#mermaid-svg-lUcUtKqSPVdywzLg .icon-shape .label{text-align:center;}#mermaid-svg-lUcUtKqSPVdywzLg .node.clickable{cursor:pointer;}#mermaid-svg-lUcUtKqSPVdywzLg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lUcUtKqSPVdywzLg .arrowheadPath{fill:#333333;}#mermaid-svg-lUcUtKqSPVdywzLg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lUcUtKqSPVdywzLg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lUcUtKqSPVdywzLg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lUcUtKqSPVdywzLg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lUcUtKqSPVdywzLg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lUcUtKqSPVdywzLg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lUcUtKqSPVdywzLg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lUcUtKqSPVdywzLg .cluster text{fill:#333;}#mermaid-svg-lUcUtKqSPVdywzLg .cluster span{color:#333;}#mermaid-svg-lUcUtKqSPVdywzLg 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-lUcUtKqSPVdywzLg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lUcUtKqSPVdywzLg rect.text{fill:none;stroke-width:0;}#mermaid-svg-lUcUtKqSPVdywzLg .icon-shape,#mermaid-svg-lUcUtKqSPVdywzLg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lUcUtKqSPVdywzLg .icon-shape p,#mermaid-svg-lUcUtKqSPVdywzLg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lUcUtKqSPVdywzLg .icon-shape .label rect,#mermaid-svg-lUcUtKqSPVdywzLg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lUcUtKqSPVdywzLg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lUcUtKqSPVdywzLg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lUcUtKqSPVdywzLg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ViewModel
UI层
Event 用户做了什么
更新
collectAsStateWithLifecycle
发送
LaunchedEffect 收集
用户操作
渲染状态
Event 入口 onEvent
UiState 持续状态
Effect 一次性事件
四、状态容器:ViewModel之外的选择
4.1 什么是状态容器
ViewModel是状态容器的一种,但不是唯一的一种。状态容器(State Holder)是任何持有状态、处理事件的普通类。
有些状态不需要ViewModel那么长的生命周期。比如一个搜索框的展开/收起、一个表单的校验状态、一个列表的滚动位置。这些状态只在一个页面内有效,页面销毁就该清理。
4.2 用remember + 普通类做状态容器
kotlin
class SearchBarState(
private val scope: CoroutineScope
) {
var query by mutableStateOf("")
private set
var isExpanded by mutableStateOf(false)
private set
var suggestions by mutableStateOf<List<String>>(emptyList())
private set
fun onQueryChange(newQuery: String) {
query = newQuery
scope.launch {
suggestions = search(newQuery)
}
}
fun toggleExpanded() {
isExpanded = !isExpanded
}
}
@Composable
fun rememberSearchBarState(): SearchBarState {
val scope = rememberCoroutineScope()
return remember { SearchBarState(scope) }
}
使用:
kotlin
@Composable
fun SearchBar() {
val state = rememberSearchBarState()
OutlinedTextField(
value = state.query,
onValueChange = state::onQueryChange
)
if (state.isExpanded) {
LazyColumn {
items(state.suggestions) { suggestion ->
Text(suggestion)
}
}
}
}
什么时候用这种状态容器而不是ViewModel:
- 状态只在单个可组合函数内使用,不需要跨页面共享。
- 不需要在配置变更后保留(或者用
rememberSaveable就够)。 - 不需要依赖注入。
- 状态逻辑比较重,放在可组合函数里太乱,但又不值得开一个ViewModel。
核心价值 :把"状态+逻辑"从可组合函数里抽出来,让可组合函数只负责"画"。 这和第30课把逻辑从UI抽到ViewModel是同一个思路,只是粒度更小、生命周期更短。
4.3 状态容器与ViewModel的边界
| 维度 | 状态容器(remember) | ViewModel |
|---|---|---|
| 生命周期 | 跟随组合 | 比组合长,配置变更存活 |
| 作用域 | 单个可组合函数 | 页面/导航图 |
| 依赖注入 | 不需要 | 需要Hilt |
| 测试 | 可以直接new | 需要Hilt测试或手动构造 |
| 适合场景 | UI局部状态、临时交互 | 页面级状态、业务逻辑 |
判断标准 :如果状态丢失了用户会"不爽"(比如输入了一半的表单),用ViewModel或 rememberSaveable。如果状态丢了无所谓(比如搜索框展开状态),用普通状态容器。
五、状态建模的"三不原则"
5.1 不存储能派生的状态
kotlin
// 错误:isEmpty和users是两份数据,可能不一致
var users by mutableStateOf<List<User>>(emptyList())
var isEmpty by mutableStateOf(true)
// 正确:派生
val isEmpty: Boolean get() = users.isEmpty()
能派生的状态,一律不存储。 每多存一份,就多一个"忘记同步"的机会。
5.2 不用多个布尔值表示互斥状态
kotlin
// 错误:可能出现isLoading=true同时isSuccess=true
var isLoading by mutableStateOf(false)
var isSuccess by mutableStateOf(false)
var isError by mutableStateOf(false)
// 正确:用密封类或枚举
sealed interface LoadState {
data object Loading : LoadState
data class Success(val data: List<User>) : LoadState
data class Error(val message: String) : LoadState
}
互斥的状态,用密封类或枚举。 编译器会帮你保证"只有一个为真"。
5.3 不在UI层做状态判断的"猜谜"
kotlin
// 错误:UI层猜状态
if (users.isNotEmpty() && !isLoading) {
// 这是成功?还是刷新中?还是加载更多?
}
// 正确:状态层明确表达
when (uiState.loadState) {
LoadState.FirstLoading -> 全屏加载
LoadState.Refreshing -> 顶部进度条
LoadState.LoadingMore -> 底部进度条
LoadState.Idle -> 显示列表
}
状态层负责"把状态说清楚",UI层负责"把状态画出来"。 UI层不应该猜。
六、常见陷阱速查
| 陷阱 | 真实场景 | 后果 | 解法 |
|---|---|---|---|
| 存派生状态 | isEmpty 和 users 分开存 |
状态不一致 | 用 get() 派生 |
| 多个布尔表示互斥状态 | isLoading、isSuccess、isError 三个布尔 |
出现矛盾组合 | 用密封类或枚举 |
一个 isLoading 表示所有加载 |
首次加载和下拉刷新共用一个布尔 | 下拉刷新时列表消失 | 拆成 LoadState 枚举 |
一次性事件用 StateFlow |
导航事件用 StateFlow<Long?> |
重组时重复导航 | 用 Channel 转 Flow |
| 事件命名混乱 | 有的叫 onXxx,有的叫 Event |
难追踪、难维护 | 统一用 UiEvent 或统一用方法回调 |
UiState 用 var 暴露 |
var uiState by mutableStateOf(...) |
外部可随意修改 | private set 或 asStateFlow() |
| 空状态判断遗漏加载中 | users.isEmpty() 就显示空状态 |
加载中显示"暂无数据" | isEmpty 排除 isLoading 和 error |
状态容器持有 CoroutineScope 不取消 |
remember 里创建 scope 但没管理 |
协程泄漏 | 用 rememberCoroutineScope() |
七、综合实战:用户列表的完整状态建模
7.1 UiState
kotlin
data class UserListUiState(
val users: List<User> = emptyList(),
val loadState: LoadState = LoadState.Idle,
val error: String? = null
) {
val isEmpty: Boolean
get() = users.isEmpty() &&
loadState == LoadState.Idle &&
error == null
}
enum class LoadState {
Idle,
FirstLoading,
Refreshing,
LoadingMore
}
7.2 Effect
kotlin
sealed interface UserListEffect {
data class NavigateToDetail(val id: Long) : UserListEffect
data class ShowSnackbar(val message: String) : UserListEffect
}
7.3 Event
kotlin
sealed interface UserListEvent {
data object Refresh : UserListEvent
data object LoadMore : UserListEvent
data class UserClick(val id: Long) : UserListEvent
data class Delete(val id: Long) : UserListEvent
data object Retry : UserListEvent
}
7.4 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 _effects = Channel<UserListEffect>()
val effects = _effects.receiveAsFlow()
init {
loadFirstPage()
}
fun onEvent(event: UserListEvent) {
when (event) {
UserListEvent.Refresh -> refresh()
UserListEvent.LoadMore -> loadMore()
is UserListEvent.UserClick -> navigateToDetail(event.id)
is UserListEvent.Delete -> delete(event.id)
UserListEvent.Retry -> loadFirstPage()
}
}
private fun loadFirstPage() {
viewModelScope.launch {
_uiState.update {
it.copy(loadState = LoadState.FirstLoading, error = null)
}
repository.refreshUsers()
.onSuccess {
_uiState.update { it.copy(loadState = LoadState.Idle) }
}
.onFailure { e ->
_uiState.update {
it.copy(loadState = LoadState.Idle, error = e.toMessage())
}
}
}
}
private fun refresh() {
viewModelScope.launch {
_uiState.update {
it.copy(loadState = LoadState.Refreshing, error = null)
}
repository.refreshUsers()
.onSuccess {
_uiState.update { it.copy(loadState = LoadState.Idle) }
}
.onFailure { e ->
_uiState.update {
it.copy(loadState = LoadState.Idle, error = e.toMessage())
}
_effects.send(
UserListEffect.ShowSnackbar(e.toMessage())
)
}
}
}
private fun loadMore() {
if (_uiState.value.loadState != LoadState.Idle) return
viewModelScope.launch {
_uiState.update { it.copy(loadState = LoadState.LoadingMore) }
repository.loadMore()
.onSuccess {
_uiState.update { it.copy(loadState = LoadState.Idle) }
}
.onFailure { e ->
_uiState.update { it.copy(loadState = LoadState.Idle) }
_effects.send(UserListEffect.ShowSnackbar(e.toMessage()))
}
}
}
private fun navigateToDetail(id: Long) {
viewModelScope.launch {
_effects.send(UserListEffect.NavigateToDetail(id))
}
}
private fun delete(id: Long) {
viewModelScope.launch {
repository.deleteUser(id)
.onSuccess {
_uiState.update { state ->
state.copy(users = state.users.filter { it.id != id })
}
_effects.send(UserListEffect.ShowSnackbar("已删除"))
}
.onFailure { e ->
_effects.send(UserListEffect.ShowSnackbar(e.toMessage()))
}
}
}
}
7.5 UI层
kotlin
@Composable
fun UserListRoute(
onNavigateToDetail: (Long) -> Unit,
viewModel: UserListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is UserListEffect.NavigateToDetail ->
onNavigateToDetail(effect.id)
is UserListEffect.ShowSnackbar ->
snackbarHostState.showSnackbar(effect.message)
}
}
}
UserListScreen(
uiState = uiState,
snackbarHostState = snackbarHostState,
onEvent = viewModel::onEvent
)
}
@Composable
fun UserListScreen(
uiState: UserListUiState,
snackbarHostState: SnackbarHostState,
onEvent: (UserListEvent) -> Unit
) {
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
when {
uiState.loadState == LoadState.FirstLoading &&
uiState.users.isEmpty() -> {
FullScreenLoading(modifier = Modifier.padding(padding))
}
uiState.error != null && uiState.users.isEmpty() -> {
FullScreenError(
message = uiState.error,
onRetry = { onEvent(UserListEvent.Retry) },
modifier = Modifier.padding(padding)
)
}
uiState.isEmpty -> {
FullScreenEmpty(modifier = Modifier.padding(padding))
}
else -> {
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (uiState.loadState == LoadState.Refreshing) {
item { LinearProgressIndicator(Modifier.fillMaxWidth()) }
}
items(uiState.users, key = { it.id }) { user ->
UserItem(
user = user,
onClick = { onEvent(UserListEvent.UserClick(user.id)) },
onDelete = { onEvent(UserListEvent.Delete(user.id)) }
)
}
if (uiState.loadState == LoadState.LoadingMore) {
item {
Box(
Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(Modifier.size(24.dp))
}
}
}
}
}
}
}
}
设计要点回看:
为什么用 LoadState 枚举而不是多个布尔 :四种加载状态互斥,用枚举后UI层的 when 可以穷举,不会出现"首次加载和刷新同时为真"的矛盾。
为什么 isEmpty 是派生属性 :它排除了 loadState 和 error 的情况。加载中不会显示空状态,出错时也不会显示空状态。UI层不需要自己判断这些条件。
为什么事件用密封类 :所有事件集中在一个 sealed interface,onEvent 的 when 强制穷举所有事件。新增一个事件,编译器会提醒所有没处理的地方。
为什么 Effect 和 UiState 分开 :导航和Snackbar只应该发生一次。用 Channel 保证只消费一次。UiState 是持续状态,重组时重新读取。
八、状态建模的思维模型
第一层:UiState 是一个契约。 它定义了UI"可能处于哪些状态"。好的UiState让UI层可以穷举所有情况,不需要 else,不需要猜。
第二层:能派生的不存储。 isEmpty 从 users 派生,canSubmit 从表单字段派生。每多存一份,就多一个不一致的机会。
第三层:互斥的用密封类或枚举。 加载中/成功/失败是互斥的,用 LoadState 枚举。首次加载/刷新/加载更多是互斥的,用 LoadState 枚举。不要用多个布尔。
第四层:区分状态和事件。 UiState 是"现在长什么样",持续存在。Effect 是"应该做什么",只发生一次。Event 是"用户做了什么",从UI流向ViewModel。
第五层:状态容器按生命周期选择。 页面级状态用ViewModel,局部UI状态用 remember + 普通类。不要为了"统一"把所有状态都塞进ViewModel。
贯穿始终的原则:让编译器帮你找Bug。 密封类强制穷举,派生属性消除矛盾,类型化事件统一入口。好的状态建模,让"不可能的状态"在类型层面就无法表达。
九、小结与下一课预告
这一课我们把"散装状态"变成了"可穷举的契约"。关键点回顾:
- UiState 是状态的唯一真相 :用
data class或sealed class打包状态,消除矛盾组合。 - data class vs sealed class :可共存的状态用
data class+ 派生属性,互斥的状态用sealed class强制穷举。 - 能派生的不存储 :
isEmpty、canSubmit这类状态从真相计算,不独立存储。 - 加载状态要细分 :首次加载、下拉刷新、加载更多用
LoadState枚举区分,不要共用一个isLoading。 - UI事件建模:方法回调适合简单场景,密封类事件适合复杂场景和统一埋点。
- 状态与事件分离:UiState 是持续状态,Effect 是一次性事件,Event 是用户操作。三者不要混用。
- 状态容器按生命周期选 :页面级用ViewModel,局部用
remember + 普通类。 - 常见陷阱 :存派生状态、多个布尔表示互斥状态、一个
isLoading表示所有加载、一次性事件用StateFlow、空状态判断遗漏加载中。
状态建模是Compose架构的"最后一公里"。 ViewModel有了,Hilt注入了,但如果UiState设计得不好,UI层还是会陷入"猜状态"的泥潭。好的UiState,让UI层的 when 变成一份清晰的"状态说明书"。
下一课,我们会讲Compose中的单向数据流(UDF)完整落地 。状态从ViewModel流向UI,事件从UI流回ViewModel------这个环怎么闭合?collectAsStateWithLifecycle 和 LaunchedEffect 怎么配合?状态恢复怎么做?配置变更、进程死亡、导航返回时状态怎么保持?这是把前面所有"零件"串成一个"闭环"的关键一课。
课后练习建议:打开你的一个ViewModel,把里面的独立状态打包成一个 data class,把能派生的属性改成 get()。然后把加载状态从 isLoading 布尔改成 LoadState 枚举。最后检查UI层,把 when 的每个分支改成互斥的。改完之后,你会发现UI层的逻辑清晰了很多,而且那些"加载中显示空状态"的诡异Bug再也不会出现了。