Android 基础补强 B07|RecyclerView:复用的 View 为什么会带着上一条的状态
摘要:通过收藏标记串行和标题更新不显示两个问题,区分 ViewHolder 复用、完整绑定、条目身份、内容比较及不可变列表提交。
标签:Android、RecyclerView、DiffUtil、ListAdapter、第一行代码
本文对应《第一行代码》第 3 版第 4.6 节,并补强 28 天课程 D17。RecyclerView 不只是“能滚动的数组”:数据、条目 View、位置以及业务 ID 是不同概念。下面围绕同一份文章数据写传统列表,示例未在你的工程编译,布局与依赖需配套补齐。
1. 复用复用的是容器,不是文章身份
文章滚出屏幕后,它使用的 ViewHolder 可能被拿来展示另一篇文章。这样避免为所有记录长期创建一整套控件,但旧控件上的文字、可见性、选中状态也会暂时保留。绑定新数据时,如果只处理“已收藏时显示图标”,却没处理“未收藏时隐藏图标”,第二篇就可能继承第一篇的视觉状态。
因此完整绑定必须让当前 View 的显示由当前模型决定。标题、作者、图标、可见性、背景、启用状态和点击行为,都需要明确赋值。图片请求也要关联当前条目并处理占位与取消,不能让上一张图的异步结果覆盖新条目。复用机制由 RecyclerView 组织,正确绑定是 Adapter 的职责。RecyclerView 官方指南
布局管理器决定条目如何排列,Adapter 决定如何创建与绑定,ViewHolder 保存条目引用。把这些角色分开之后,切成网格主要改变布局安排,不应该迫使网络请求或收藏逻辑全部重写。
2. 同一篇文章,也可能需要更新
DiffUtil 的身份比较回答“这是不是同一条业务记录”,通常使用文章 ID;内容比较回答“显示相关的数据是否相同”。同一个 ID 的标题或收藏状态变化时,身份相同、内容不同,应该重新绑定。如果两个比较都只比较 ID,条目就会看起来不更新。
以下为简化的 ListAdapter,假设启用 ViewBinding,item_article.xml生成ItemArticleBinding,具有title、author、bookmarkIcon。依赖 RecyclerView 与 AndroidX Core 的isVisible扩展,省略导包和宿主配置。
dataclassArticleRow(valid:Long,valtitle:String,valauthor:String,valbookmarked:Boolean)classArticleAdapter(privatevalonClick:(Long)->Unit):ListAdapter<ArticleRow,ArticleAdapter.Holder>(DIFF){classHolder(privatevalbinding:ItemArticleBinding):RecyclerView.ViewHolder(binding.root){funbind(row:ArticleRow,onClick:(Long)->Unit){binding.title.text=row.title binding.author.text=row.author binding.bookmarkIcon.isVisible=row.bookmarked binding.root.isSelected=row.bookmarked binding.root.setOnClickListener{onClick(row.id)}}}overridefunonCreateViewHolder(parent:ViewGroup,viewType:Int):Holder{valbinding=ItemArticleBinding.inflate(LayoutInflater.from(parent.context),parent,false)returnHolder(binding)}overridefunonBindViewHolder(holder:Holder,position:Int){holder.bind(getItem(position),onClick)}companionobject{valDIFF=object:DiffUtil.ItemCallback<ArticleRow>(){overridefunareItemsTheSame(old:ArticleRow,new:ArticleRow)=old.id==new.idoverridefunareContentsTheSame(old:ArticleRow,new:ArticleRow)=old==new}}}这里把点击转换为业务 ID,避免长期捕获绑定时的位置。位置可能因插入、删除而变化,身份则代表目标文章。如果业务需要点击瞬间的最新数据,可在回调后按 ID 从当前状态查询;如果读取bindingAdapterPosition,必须处理NO_POSITION,不能未经检查直接当数组下标。
old == new依赖本例数据类字段恰好覆盖显示内容。若模型还包含不影响 UI 的内部字段,可以定义更精确的内容比较;若把展示字段藏在未参与比较的可变对象里,则需要重新设计,不然比较结果可能失真。DiffUtil API
3. 提交新列表,也要保持旧快照不变
ListAdapter 会计算列表差异并分发更新。参与比较的旧列表和元素应保持不变,更新收藏时使用map与copy生成新快照,再提交。只在同一个 MutableList 内把字段改掉,再提交原对象,可能让差异计算失去“修改前”的证据。ListAdapter API
示例更新可以写成adapter.submitList(rows.map { if (it.id == target) it.copy(bookmarked = true) else it })。真正业务应先更新 ViewModel 或 Repository,由状态观察把新列表交给 Adapter,而不是 Adapter 自己保存另一套永久收藏事实。
差异更新与 View 复用解决不同成本。前者描述数据变化,后者减少条目对象反复创建。notifyDataSetChanged()可以要求全面刷新,却不能修复错误身份、原地修改或绑定遗漏,也不应作为所有问题的默认补丁。
4. 两个故障实验和一个顺序检查
先准备二十条文章,只收藏第一条。故意让绑定只在真分支显示收藏图标,再向下滚动。预期复用后可能出现不该显示的标记。恢复无条件赋值后重复滚动,预期每条显示都与模型一致。记录条目 ID 与 ViewHolder 标识,可以观察相同容器如何服务不同文章。
再让同 ID 文章改变标题,先把内容比较错误地写成只比较 ID,观察新标题不及时展示;恢复完整内容比较后提交新快照,预期只更新应变化的条目。最后在列表首部插入一条,再点击原第三篇,核对回调 ID 仍对应那篇文章,而不是插入后的第三个位置。
以上为实验设计与预期,不是已运行报告。若某次现象没有出现,应通过可控滚动和日志确认是否实际发生复用,不能据一次截图宣布机制不存在。
5. 原创面试问答与追问
问一:复用为什么会导致状态串到别的条目?新绑定没有覆盖旧 View 的全部相关属性。追问:销毁所有 View 能解决吗?可能掩盖症状,但失去复用收益,正确做法是完整绑定。
问二:身份相同是否意味着内容相同?不意味着,同 ID 的标题和收藏状态都可能变化。追问:比较内容时漏了收藏字段会怎样?收藏变化可能无法触发应有更新。
问三:为什么不能把 position 当长期身份?插入、删除、排序会改变它。追问:点击要传什么?通常传业务 ID,再按需要读取最新模型;读取当前位置时处理无效位置。
上述为原创自测,可配合面试鸭 RecyclerView 主题复述,未照搬题库答案。
6. 专题验收
能解释创建、绑定、复用、差异计算各发生了什么;完成快速滚动、同 ID 更新、首部插入后的点击检查;更新数据时不修改旧快照。书本第 4.6 节提供列表起点,本篇补上维护真实列表最常见的状态与身份问题,再与 D17 的生命周期收集连接起来。