Android 学习笔记 D03|列表排序后收藏为什么不能跟着位置跑
摘要:从一张卡片扩展到可滚动列表时,必须同时处理业务身份、组合身份与状态归属。本文用排序、插入和筛选实验说明稳定 key 的意义,以及它为什么不能替代正确的数据更新逻辑。
标签:Jetpack Compose、LazyColumn、状态提升、列表、Android
从能显示到能正确更新
文章列表有一个很容易漏测的场景:收藏第三篇文章,然后在列表顶部插入一篇新文章。如果收藏按钮跟着第三个位置留下,而没有跟着原文章移动,说明程序把"位置"误当成了"身份"。初始截图完全正常,操作之后才会暴露问题。
本篇对应 D03。可以结合《第一行代码》第 4 章学习列表思路,其中 4.5 是 ListView,4.6 是 RecyclerView;本文的 LazyColumn 是现代 Compose 扩展。传统控件和 Compose 的具体 API 不同,但"条目是谁、内容是什么、状态由谁拥有"这三个问题都需要回答。
为三种信息分清责任
业务身份使用文章 ID,标题改变后仍然可以是同一篇文章。当前位置只描述排序结果,筛选、插入和删除都会改变它。收藏状态属于文章业务事实,应由列表状态拥有者统一修改;卡片自己的临时展开状态才可以留在卡片内部。
如果标题区要显示收藏总数,那么卡片与统计都需要同一份收藏事实。让每张卡片 remember 一个独立 Boolean,再另外维护总数,等于把同一个事实复制到多个地方。某个分支少更新一次就会出现按钮已收藏、计数仍为零的问题。
状态提升的直接做法是卡片接收 Article 和 onBookmark 回调,不自己保存收藏。上层提供当前值,下层报告用户动作,上层更新后再传下去。这样以后状态搬到 ViewModel 时,卡片接口仍然可以保持稳定。状态提升文档
实现一个可以改变顺序的列表
以下代码用于已有 Compose Material 3 模板。依赖 runtime、foundation 和 Material 3;模型、列表和卡片全部在此给出,但不包含 Activity 与 Gradle,未在本次写稿中编译。示例暂用 remember,不承诺重建后保存收藏。
kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
data class ListArticle(val id: Long, val title: String, val bookmarked: Boolean = false)
@Composable
fun ArticleList(modifier: Modifier = Modifier) {
val state = remember {
mutableStateOf(List(20) { ListArticle(it + 1L, "文章 ${it + 1}") })
}
val onlySaved = remember { mutableStateOf(false) }
val all = state.value
val visible = if (onlySaved.value) all.filter { it.bookmarked } else all
LazyColumn(modifier) {
item(key = "controls") {
Text("全部 ${all.size} 篇,收藏 ${all.count { it.bookmarked }} 篇")
Button(onClick = { state.value = state.value.reversed() }) { Text("反转顺序") }
Button(onClick = { onlySaved.value = !onlySaved.value }) {
Text(if (onlySaved.value) "显示全部" else "只看收藏")
}
}
items(visible, key = { it.id }) { article ->
ArticleRow(article, onBookmark = {
state.value = state.value.map {
if (it.id == article.id) it.copy(bookmarked = !it.bookmarked) else it
}
})
}
if (visible.isEmpty()) item(key = "empty") { Text("当前没有符合条件的文章") }
}
}
@Composable
fun ArticleRow(article: ListArticle, onBookmark: () -> Unit) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text("${article.id} · ${article.title}")
Button(onClick = onBookmark) { Text(if (article.bookmarked) "取消收藏" else "收藏") }
}
}
这里统计的是完整集合中的收藏数,不是当前搜索或筛选结果的收藏数。统计口径需要写进文案或产品约定,否则即使代码没有 bug,用户仍会对数字含义产生误解。
LazyColumn 按懒布局机制组织项目,适合长列表;普通 Column 遍历全部内容会创建和布局全部子项。不要因此声称 LazyColumn 的成本与数据量完全无关,数据转换、图片、排序和业务状态计算仍然可能耗时。懒列表文档
稳定 key 的能力和边界
key 应在当前列表范围内唯一,并在同一文章移动后保持不变。Long 类型的 ID 也便于 Android 保存相关条目状态。随机数每次变化,无法表达稳定身份;下标在排序后改变,无法表达业务身份;标题又可能重复或者被修改。
不过,正确 key 并不能救活错误的更新代码。如果点击收藏时仍按过滤结果中的下标去改完整集合,就可能修改另一篇文章。示例始终按 ID 找到目标,这解决业务映射;key 则帮助组合系统跟踪列表项,两者需要同时正确。
如果后端返回重复 ID,应先检查数据清洗与合并策略。临时把 key 改成"ID 加随机数"只能隐藏输入冲突,不能说明两条记录究竟代表一篇文章还是两篇文章。
用三步实验区分两类错误
第一步,收藏 ID 为 3 的文章并反转顺序,预期收藏仍对应 ID 3,总数不变。第二步,在首部插入新的唯一 ID,重复检查。第三步,只看收藏并取消该文章,预期它从可见列表退出,但切回全部时仍能找到。
如果想专门验证组合身份,可以临时在卡片中加入 remember 的"展开详情"状态,展开一个可见项目后改变排序。去掉 key,再和有稳定 key 的版本对照。使用业务模型中的收藏来测试这一点并不充分,因为模型已经按 ID 保存收藏,即使 key 缺失,按钮仍可能看起来正确。
排查时同时记录 ID、下标、收藏值与展开值。收藏错位优先查业务更新目标;展开错位优先查组合身份与状态寿命。长标题把布局撑坏属于另一类显示问题,应另用大字体和窄窗口复现,不要混在一次实验里修改。
三道原创面试问答
1. 稳定 key 能保证收藏不丢吗? 不能,收藏存在哪里、如何恢复由业务状态方案决定。追问:为什么还要 key?它帮助 Compose 在条目移动时保持组合身份,尤其涉及条目局部状态时有意义。
2. 为什么不把 MutableList 原地改完就结束? 普通可变集合内部变化不会自动成为 Compose 可观察状态变化,本例通过新列表赋值表达更新。追问:可观察集合可以用吗?可以,但要了解它观察哪些修改,以及元素本身是否可观察,不能一概而论。
3. "状态向下、事件向上"会不会让卡片没有能力? 卡片仍然负责显示、交互和可访问性,只是不决定业务事实的最终更新。追问:这种设计对预览有什么帮助?可以直接传入已收藏或未收藏的数据,不必启动真实仓库。
以上为课程自拟题,可对应题库的传统 UI 与 Compose UI 主题继续比较。
今天的验收
关闭示例后独立完成"只看收藏",再完成新增、删除、排序三种身份测试。能解释统计范围,能说明 key 没有替业务层保存什么,能区分收藏状态和临时展开状态。记录操作与预期后再运行,只有实际观察到的结果才写入学习日志。