一个列表页通常至少有三种状态:正在加载、拿到内容、请求失败。如果这些信息分散在多个临时变量中,页面很容易出现加载动画不消失、旧错误覆盖新结果等问题。把变量收进 data class 只是第一步,更重要的是规定谁可以转换状态,以及并发请求发生时哪个结果有权提交。
先把状态转移写成产品规则
以笔记列表为例:首次进入没有内容,显示整页加载;已有内容时刷新,保留旧列表,只显示刷新指示;刷新失败保留旧内容并给出可恢复错误;请求成功但返回空列表,显示空态,而不是继续显示加载。
因此“列表为空”不能独自区分尚未加载与加载完成。可加入 hasLoaded,或用初始、加载、内容、失败等明确结构表达。若需要保留旧内容,单纯用互斥的 Loading、Error 类型又可能丢掉已有数据,模型必须服务于展示规则。
1 | data class NoteItem(val id: String, val title: String) |
示例假设 Kotlin 协程、Compose、Lifecycle 与 ViewModel 已配置,Repository 和 ViewModel 工厂由应用提供。发布状态后不要继续修改同一个普通可变列表;Compose 不会自动观察所有普通集合的内部修改。Compose 状态说明解释了可观察状态与集合的边界。
“取消旧请求”还不够,提交点也要校验
新的刷新通常取消前一次任务,但取消是合作机制。某些 SDK 的桥接、回调包装或已经完成的网络调用,可能使旧结果在取消后仍到达。如果旧任务无条件赋值,界面就会倒退到旧数据。
下面让每次请求获得序号,在真正更新状态前再次验证。示例适用于常见 Lifecycle 2.6 及以上 API,所有公开操作限定在主线程;代码未在完整 Android 工程中编译。
1 | import androidx.annotation.MainThread |
序号和状态提交都在主线程,检查与赋值之间没有挂起点,才能形成容易理解的顺序。@MainThread 是调用契约,不是自动切线程;从后台回调触发时应先切回主线程。若多个线程允许同时调用 refresh,就需要另外定义同步机制,不能只给 requestId 加原子自增便认为整段转换都原子化。
代码只把 IOException 转成用户可见的预期失败。HTTP 认证、业务校验和解析错误应在 Repository 层映射到明确的应用错误模型,不应把所有 Throwable 都吞成“网络不好”。不符合契约的程序错误需要被记录和修复。
同时避免在每个旧任务的 finally 中无条件 loading=false:旧请求结束时,新请求可能还在加载。最终状态必须受同一个请求身份约束。
StateFlow 保存当前值,不保存完整事件历史
StateFlow 会保留当前状态,并以相等性合并更新;慢收集者可能只看到较新的值,而跳过中间状态。Kotlin 的 StateFlow API说明了这一语义。
这对界面通常合理:用户需要知道“现在是否加载”,不一定需要逐帧播放所有加载切换。但如果把两次相同 Toast 文本直接当成 StateFlow 值,第二次可能不会形成新变化;如果把支付完成等重要业务动作当作只发一次的瞬时事件,在页面停止收集时又可能丢失。
可以把待确认消息建模为带 ID 的状态,UI 展示后回传 messageShown(id),仅清除同一个 ID,避免旧消息的回调误清除新消息。关键业务结果应保存在领域数据里,由界面根据结果展示;“显示了提示”不应成为业务提交是否成功的唯一证据。Android 的UI 事件处理建议也强调把需要恢复的结果落实到状态。
生命周期收集不等于停止生产
页面连接方式仍然保持简单:
1 | import androidx.compose.runtime.getValue |
需要 lifecycle-runtime-compose 依赖,版本应与项目 Lifecycle 兼容。NotesScreen 只接收状态和回调,预览与测试便可直接注入首次加载、空态和失败状态,不必创建网络服务。
停止收集并不自动取消上面 viewModelScope 中正在执行的刷新。若产品希望退到后台继续完成一次请求,这可能正是所需行为;若传感器流只在界面可见时运行,则应把生产者与订阅生命周期连接,例如经过明确配置的共享策略。选择应基于资源和产品需求,而不是把任何 Flow 都套成同一种模板。
ViewModel、局部状态与进程恢复
输入框是否展开、临时动画进度通常留在局部 Compose 状态;需要在配置变化后保留的页面业务状态可由合适作用域的 ViewModel 持有。注意 ViewModel 的作用域:属于导航目的地与属于整个 Activity,销毁时机可能不同。
ViewModel 不等于持久化存储。系统回收进程后,筛选条件、查询词或选中记录 ID 可以通过 SavedStateHandle 等机制恢复,笔记正文与待上传数据则应进入持久层。不要往 saved state 塞庞大完整列表,然后误以为获得了数据库级可靠性。
重建时先恢复查询条件,再重新读取本地数据源或发起请求。是否展示旧缓存、是否自动刷新,以及错误是否需要保留,都是应写进状态转换的规则。
并发状态更新要区分替换与合并
StateFlow 的 update 适合由旧状态计算新状态,但转换函数应保持纯净。不要在其中发送请求、生成必须唯一执行的业务操作或写数据库,因为并发更新时转换可能重试。先完成外部工作,再把结果转成一个可重复计算的状态转换,边界会更清楚。
也不要认为所有状态都应该整体替换。请求成功时构造新的 NotesUiState,在当前只有列表相关字段时很简洁;若以后加了用户正在输入的搜索词、选择项或未确认消息,整体替换可能把这些互不相关的状态清空。可以拆分状态拥有者,或在提交时只更新属于该请求的字段。
对搜索页,还应明确请求身份是否包含筛选条件。用户先搜索 A,再切分类 B,旧请求即使返回相同列表长度,也不属于当前查询。把查询参数快照与请求序号一起保存,日志和测试才能解释“这一份结果为什么被接受”。只按返回时间排序,无法还原用户操作的因果关系。
加载失败后是否自动重试同样影响状态机。若每次重组看到 error 就启动刷新,页面可能进入没有上限的循环。重试应来自明确用户动作、受控策略或数据层恢复信号;界面只展示当前状态,不把绘制过程变成调度器。重复点击可以选择取消前次、忽略重复或排队,但必须由产品规则决定,而不是由某个协程 API 偶然决定。
用可控时序验证最后一次操作获胜
假 Repository 应允许分别控制每次调用的返回时机:启动 A,再启动 B;先让 B 成功,最后让 A 返回,断言界面仍显示 B。再交换为 A 失败,确认旧错误不能覆盖 B。
分别测试首次空结果、已有内容刷新失败、连续点击刷新、加载时退到后台、旋转以及进程恢复。对消息状态测试两次相同文本不同 ID,确认旧消费回调不会移除新消息。
协程测试可以用 kotlinx-coroutines-test 的测试调度器和可控 Deferred,而不是依赖真实 sleep。本文未执行这些测试;它们给出的不是“状态类存在就正确”的结论,而是一组能直接暴露竞态的验收条件。