Android ViewModel 与 StateFlow 界面状态
Android ViewModel 与 StateFlow 界面状态
摘要:界面状态分散在 Activity、Composable 和网络回调中时,容易出现旋转丢状态、重复请求和页面数据不一致。本文以 ViewModel、StateFlow 和单向数据流为主线,搭建一个可测试、能感知生命周期的 Android 页面状态模型。
一、把界面看作状态的呈现
一个列表页面通常有加载中、加载成功、空结果和失败等状态。若分别用多个布尔值表示,可能出现 isLoading 与 hasError 同时为真的组合。用一个状态类型表达页面当前所处阶段,可以避免无效组合:
data class Article(
val id: Long,
val title: String
)
sealed interface ArticleUiState {
data object Loading : ArticleUiState
data class Content(val articles: List<Article>) : ArticleUiState
data object Empty : ArticleUiState
data class Error(val message: String) : ArticleUiState
}
页面只需根据状态绘制内容,不必自行推断数据是否完整。
二、分离数据来源与界面逻辑
Repository 负责协调数据来源,ViewModel 负责把数据变化转换为界面状态。ViewModel 不应持有 Activity、View 或 Composable 的引用:
interface ArticleRepository {
suspend fun loadArticles(): List<Article>
}
class ArticleViewModel(
private val repository: ArticleRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<ArticleUiState>(ArticleUiState.Loading)
val uiState: StateFlow<ArticleUiState> = _uiState.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
_uiState.value = ArticleUiState.Loading
_uiState.value = try {
val items = repository.loadArticles()
if (items.isEmpty()) ArticleUiState.Empty
else ArticleUiState.Content(items)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Exception) {
ArticleUiState.Error(error.message ?: "加载失败")
}
}
}
}
取消异常要继续向上传播,否则协程取消可能被误显示成普通错误。viewModelScope 会随 ViewModel 清理而取消任务。
三、让 Compose 按生命周期收集状态
使用生命周期感知的收集函数,可避免页面处于后台时仍持续收集界面数据:
@Composable
fun ArticleRoute(viewModel: ArticleViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ArticleScreen(state = state, onRefresh = viewModel::refresh)
}
@Composable
fun ArticleScreen(
state: ArticleUiState,
onRefresh: () -> Unit
) {
when (state) {
ArticleUiState.Loading -> CircularProgressIndicator()
ArticleUiState.Empty -> Text("还没有内容")
is ArticleUiState.Error -> ErrorPanel(state.message, onRefresh)
is ArticleUiState.Content -> ArticleList(state.articles)
}
}
ArticleScreen 只接受输入并绘制界面,便于预览和单元测试。状态收集与 ViewModel 的连接放在 Route 层,职责更清楚。
四、区分持久状态与短暂事件
页面标题、已加载列表和筛选条件适合作为可重放的状态。一次性提示或跳转属于事件;如果把它们塞进普通 StateFlow,页面重建后可能再次消费旧事件。可以让界面动作携带回调,由界面处理完后再通知 ViewModel 更新状态;需要队列语义时,再使用明确规定缓冲和重放策略的事件流。
事件设计至少要回答:新订阅者是否收到旧事件?暂时没有订阅者时事件是否保留?同一事件可否消费多次?把这些语义写清楚,比只选一个流类型更重要。
五、处理筛选和请求竞争
用户快速切换筛选条件时,前一个请求可能晚于后一个返回。对由输入驱动的查询,可将条件建模为 Flow 并使用 flatMapLatest 取消过期任务:
private val query = MutableStateFlow("")
val results: StateFlow<SearchUiState> = query
.debounce(300)
.map(String::trim)
.distinctUntilChanged()
.flatMapLatest { value ->
flow {
emit(SearchUiState.Loading)
emit(SearchUiState.Content(repository.search(value)))
}.catch { error ->
emit(SearchUiState.Error(error.message ?: "搜索失败"))
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchUiState.Loading)
实际项目中仍需把取消异常按协程规则处理,并在 Repository 中定义网络失败与空结果的语义。
六、测试状态转换
为 ViewModel 注入 Repository 接口,可用假的实现控制返回值和异常。测试重点是状态顺序:初始加载、数据到达后的内容状态,以及失败后是否能再次刷新。协程测试应使用可控调度器,避免依赖真实等待时间。
七、常见问题
- 不要在 Composable 每次重组时直接发起网络请求;请求应由 ViewModel 的明确动作驱动。
- 不要把可推导的数据重复存进多个 StateFlow,避免同步规则变复杂。
StateFlow总会有当前值;状态初值要表达真实的页面阶段。- 不要吞掉
CancellationException,它是结构化并发的一部分。 - 列表项使用稳定 ID,避免数据变化后界面错配。
八、总结
ViewModel 与 StateFlow 可以把页面变成清晰的状态映射:Repository 提供数据,ViewModel 管理异步状态,Compose 负责展示并上报用户动作。明确状态模型、生命周期收集规则和事件语义后,页面重建、刷新和测试都会更可控。
- 点赞
- 收藏
- 关注作者
评论(0)