Android 学习笔记 D05|把列表逻辑搬进 ViewModel,真正建立单向数据流
摘要:ViewModel 的价值不只是旋转后少创建一次对象。通过仓库接口、统一界面状态、只读 StateFlow 与显式事件,本篇把文章列表从界面内的临时练习整理成可以继续接网络和数据库的结构。
标签:ViewModel、StateFlow、单向数据流、Repository、Android架构
为什么现在需要整理职责
列表拥有全文数据、搜索词、收藏和统计后,Composable 很容易同时负责显示、筛选、修改和初始化。此时增加一个需求,就得在界面多个分支之间找变量。D05 的目标是建立可追踪的状态更新链,暂不加入真实网络,也不引入依赖注入框架。
《第一行代码》第 13 章的 Jetpack 内容可以作为 ViewModel 的基础阅读;StateFlow 和 Compose 的生命周期感知收集是本课程的现代扩展。学习时要比较职责,不应把书中的具体观察方式原封不动混入新代码。
先定义谁拥有事实
本阶段由 ViewModel 拥有屏幕上的完整文章集合和搜索词,界面只消费状态并报告操作。Repository 负责提供文章数据,它的接口描述调用者需要什么,而不暴露假数据如何创建。以后换成远端数据源时,界面不需要知道具体 HTTP 工具。
收藏今天仍是内存练习,退出进程后可以丢失;数据库课才建立长期事实来源。提前把这种边界写清楚,能避免把"旋转不丢"宣传成"收藏已持久化"。
统一状态也不是把所有变量机械装进一个大对象。若同时保存完整列表、筛选列表和收藏数,就必须维护三个值的同步。本例只保存基础事实,筛选结果与数量按需计算;计算昂贵时再基于测量选择缓存方式。
一个同步假数据版本
以下为局部业务示例,需要 lifecycle-viewmodel 与 kotlinx-coroutines-core。Repository 当前只读取少量内存数据,因此可同步调用;不要把阻塞网络或数据库查询直接替换到 initialArticles 中。示例未在本次写稿中编译。
kotlin
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class FeedArticle(val id: Long, val title: String, val bookmarked: Boolean = false)
data class FeedUiState(val items: List<FeedArticle> = emptyList(), val query: String = "") {
val visibleItems: List<FeedArticle>
get() = items.filter { it.title.contains(query.trim(), ignoreCase = true) }
}
interface ArticleRepository {
fun initialArticles(): List<FeedArticle>
}
class FakeArticleRepository : ArticleRepository {
override fun initialArticles() = listOf(
FeedArticle(1, "Kotlin 类型"), FeedArticle(2, "Compose 状态")
)
}
class FeedViewModel(repository: ArticleRepository) : ViewModel() {
private val mutableState = MutableStateFlow(
FeedUiState(items = repository.initialArticles())
)
val uiState = mutableState.asStateFlow()
fun changeQuery(query: String) {
mutableState.update { it.copy(query = query) }
}
fun toggleBookmark(id: Long) {
mutableState.update { state ->
state.copy(items = state.items.map { article ->
if (article.id == id) article.copy(bookmarked = !article.bookmarked)
else article
})
}
}
}
对外暴露只读流,让 UI 无法随意写入半完成状态。update 的函数需要保持无副作用,避免在其中写日志计数、发请求或修改外部对象;并发情况下该计算可能被重新求值。StateFlow 持有最新值,并使用相等性合并更新,它不是保证每个中间值都送达的事件队列。StateFlow API
通过正确作用域获取对象
不能在界面函数体里直接 FeedViewModel(repository),然后期望系统自动替它保留生命周期。应使用 ViewModel 获取 API,并由 Factory 提供构造参数。以下接口放在同一 Compose 工程,额外需要 lifecycle-viewmodel-compose 和 lifecycle-runtime-compose。
kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
@Composable
fun FeedRoute(repository: ArticleRepository) {
val factory = remember(repository) {
viewModelFactory { initializer { FeedViewModel(repository) } }
}
val model: FeedViewModel = viewModel(factory = factory)
val state by model.uiState.collectAsStateWithLifecycle()
Column {
OutlinedTextField(value = state.query, onValueChange = model::changeQuery)
Text("匹配 ${state.visibleItems.size} 篇")
Text("收藏 ${state.items.count { it.bookmarked }} 篇")
}
}
生产页面继续把 state.visibleItems 交给文章列表,把点击交给 model.toggleBookmark。上面只显示输入和统计,是为了突出状态连接,仓库应由稳定的应用容器或上层入口提供。Factory 负责创建,不意味着每次 Factory 对象出现都会替换已存在的 ViewModel。带依赖的 ViewModel 创建方式
collectAsStateWithLifecycle 让收集与生命周期协调。它不会替你决定数据加载策略,更不会自动阻止所有重复请求。若每次进入组合都另行调用刷新,仍可能重复加载。对象作用域、工作启动时机和收集生命周期必须分别分析。
为下周加载状态留出明确语义
接入慢请求后可以增加加载和错误字段,但先写出合法组合:首次加载时没有内容;刷新时已有旧内容;刷新失败时旧内容仍然保留,并展示错误提示。一个 Boolean 可以表达某次加载是否进行中,却不能独自说明是哪种操作。
今天无需提前设计十几种状态类型,先用纸写出这些场景,到异步和分页课再扩充。不要为了"架构完整"造出当前从不出现的状态,也不要把错误全部压成空列表,否则后续无法区分真的没有内容和请求失败。
故障实验:追踪对象和初始化次数
在 Fake 的 initialArticles 里临时增加日志,在 ViewModel 初始化处记录对象标识。进入页面、输入关键词、旋转、切后台再返回,分别记录次数。使用同一作用域正确获取时,普通重组不应重新构造 ViewModel;配置变化通常保留同一个屏幕 ViewModel。
然后故意把构造器直接放进 Composable,观察次数变化,再恢复。若次数仍异常,检查是否进入了不同导航条目、传入了不同 key 或不同 owner。不能仅凭"使用了 ViewModel 类"就认定作用域正确。ViewModel 概览
三道原创面试问答
1. 为什么 Repository 要作为构造参数? 它把依赖写在接口上,测试可替换受控实现,ViewModel 无需自己创建所有底层对象。追问:必须使用 Hilt 吗?不必,手动 Factory 也能清楚地完成当前规模的创建工作。
2. ViewModel 为什么不应长期持有 Activity 或 View? 它可能跨配置变化存活,持有旧界面会让旧实例无法及时释放,并造成错误访问。追问:需要显示提示怎么办?让状态描述提示,交给当前界面呈现。
3. 生命周期感知收集是否等于请求只执行一次? 不是,它约束的是收集行为;上游流、共享策略和显式刷新仍决定工作何时执行。追问:如何证明重复请求修好了?记录请求入口次数和操作序列,用同一条件前后对照。
以上均为课程自拟题。验收时,从点击收藏开始,口述回调、ViewModel、状态更新、界面收集的完整路径;把 Fake 换成另一组内存数据后 UI 不应改结构;能说明当前收藏还没有跨进程持久化。做到这些,再进入异步请求会更容易定位问题。