【Jetpack Compose娓娓道来】第29课:状态容器与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再也不会出现了。

相关推荐
五彩小白2 小时前
喵呜打卡项目总结
android·团队开发
古法安卓4 小时前
Android-GPU压测工具
android·java·android studio
xcLeigh6 小时前
KingbaseES数据库教程:初识数据库,从安装到跑通第一条SQL
android·数据库·sql·安装·数据库教程·kes
TimeFine9 小时前
AI 编码工作流的设计(二)
android·ai编程
2401_881828329 小时前
MySQL操作命令
android·mysql·adb
用户607097591490610 小时前
AOSP 14 OTA 升级指导(三):三条升级链路——Java API、自研 HAL 与 TR069
android
前端小D11 小时前
React Native Android 项目搭建
android·react native·react.js
恋猫de小郭12 小时前
感人,Flutter 居然有了一个超实用提案,期待落地
android·前端·flutter
CS_Zero13 小时前
安卓开发:Linux的android工具命令
android·linux·运维