拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ListView与RecyclerView复用机制:从原理到实战解决列表错乱

ListView与RecyclerView复用机制:从原理到实战解决列表错乱

做安卓开发的人,基本都和 ListView 与 RecyclerView 打过照面。这两个控件一老一新,本质上都在解决同一类问题:屏幕空间有限,数据条数却很多,不能为每一条数据都单独创建一份 View。于是系统引入了“复用”机制,说白了就是把滑出屏幕的 View 借给滑入屏幕的新数据再用一次。机制本身设计得当,列表会非常丝滑;可只要用法不正确,就会触发让无数人血压升高的“复用混乱”:条目显示错行、EditText 里的内容乱穿、图片闪成别的数据、滑动后再滑回来整个列表的状态完全串档。这篇文章我就围绕 ListView 和 RecyclerView 在复用上的底层逻辑、常见坑位以及一套能直接抄作业的解决方案,把这个问题彻底讲透。

适合谁来读?刚接触列表控件的安卓初学者可以看缓存机制和基础写法;已经写了几版 Adapter,但总觉得代码里充满“黑魔法”的开发者,可以重点看第二、三章的案例和绑定思路;被线上反馈“列表内容错乱”折磨过的朋友,可以直接跳到第四章的排查速查表。内容以实际工程视角展开,代码以 Kotlin 为主,思路同样适用于 Java 版本。

1. 复用到底在复用什么:先看 ListView 与 RecyclerView 的缓存模型

1.1 ListView 时代的“五个箱子”与 ViewHolder 的必要性

ListView 是安卓早期的列表控件,它的核心在 AbsListView 内部的 RecyclerBin 机制:系统维护了五个缓存区间,包括 ActiveView、ScrapView 等。大家不必死记这些名字,只需要抓住一个关键:当列表滚动时,滑出屏幕的条目标签会被拆下来,放进一个“待回收”的池子里;滑进来的新条目如果池子里恰好有类型匹配的旧 View,就直接拿来用,而不是重新 inflate。这样做节省了 XML 解析和 View 创建的耗时,所以 getView() 里的 convertView 参数才会那么重要。

在 ListView 时代,官方就不断强调“你要在 Adapter 里自己实现 ViewHolder”。原因是如果没有 ViewHolder,复用之后每次 getView 都要 findViewById,哪怕系统已经帮你省掉了 inflate 的开销,findViewById 仍然是一个从 View 树中按 id 搜索的过程,量大之后依然会有累计卡顿。ViewHolder 本质上是把“View 对象”和“数据填充动作”绑定成一个可重复使用的工作单元,通过 setTag 挂在 convertView 上,下次直接拿走。

但问题恰恰出在这里:很多开发者用了 ViewHolder,却只把它当成一个装子 View 的容器。他们认为复用只是性能问题,并没有意识到复用之后 View 的内部状态也是“继承”的。你没有清理 CheckBox 的选中状态,它就会把上一任数据的 checked 状态带到下一任数据上;你没有重置 EditText 的文本,滑回来时就会看到别的行的输入内容。这些都是 ListView 时代就存在的经典问题,只是当时大家往往把它归结为“你没写对”。

1.2 RecyclerView 的“回收站”与 ViewHolder 的分工

RecyclerView 是作为 ListView 的继任者出现的,它的复用模型更清晰,也更容易被想当然地理解。整个回收复用过程中最核心的对象是 ViewHolder,每个 ViewHolder 内部持有一棵 Item 对应的 View 树,并且能根据 viewType 去 RecycledViewPool 里找到可以复用的旧对象。系统还有 mCachedViews 这一层短期缓存,保证刚滑出屏幕的相邻条目能够原封不动地快速回填,避免重复绑定。

很多刚接触 RecyclerView 的开发者会误以为“ViewHolder 绑定一次,之后就什么都不用管了”。这种想法非常危险。RecyclerView 确实把创建 ViewHolder(onCreateViewHolder)和填充数据(onBindViewHolder)拆开了,但那只是为了减少创建成本,并不代表 onBindViewHolder 不会被反复调用。只要列表项滑出屏幕又滑回来,或者调用了 notifyDataSetChanged,系统就有可能会把缓存的 ViewHolder 重新交到 onBindViewHolder 手上。你需要重新为它填充当前 position 对应的数据。

恰恰因为如此,RecyclerView 的复用混乱和 ListView 在表现上略有不同:ListView 更多是 convertView 复用带来的旧状态残留,RecyclerView 则还要叠加 viewType 选错、ViewHolder 被预取(prefetch)、DiffUtil 更新位置错乱等新问题。比如你在 onBindViewHolder 里写了一个异步网络请求,请求回来后直接更新 itemView 里的子 View,但你并不知道这个 ViewHolder 此刻是否还被绑在同一个 position 上。一旦它被复用到另一个 position,回填的图片、文字就会串到别人的条目里。这种异步错位,是 RecyclerView 时代最典型的复用混乱来源。

1.3 为什么缓存设计本身有时也会引发“假性错乱”

还需要理清一点:不是所有看起来“错乱”的问题都一定是你写错了 onBindViewHolder。RecyclerView 的预取机制会提前创建和绑定即将进入屏幕的条目,如果在 onBind 方法里做了比较重的 IO 或布局操作,也许滚动时会出现几帧的视觉错位,像是上一张图片还没换完就被滑走了。另外,viewType 划分不准确时,比如两种布局的 View 高度差距很大,系统从 RecycledViewPool 里复用了一个不同布局的 ViewHolder,即使你重新绑定数据,也可能出现测量高度不对、显示上下留白的问题,看起来就像数据位置串了。这些问题单独拎出来都不是“逻辑判断”写错,但会整体表现为“列表显示混乱”,我在第四章会梳理对应的排查方向。

2. 复用混乱问题为什么会精准踩中绝大多数人

2.1 状态残留:只填新数据,不擦旧数据

状态残留是复用混乱里出现频率最高的一类。大家在写 Adapter 时最常见的行为是:在 onBindViewHolder 里把需要的数据 set 到各个子 View 上,比如 imageView.setUrl、textView.setText、checkBox.setChecked。但如果某个子 View 在当前数据模型里没有对应值,有人就会习惯性地选择不去动它,只管写有值的情况,比如只在 isShow 为 true 时设置某行可见,为 false 时却忘了设成 gone,或者只在 title 非空时 setText,不在为空时设置空串。

当 View 被复用时,旧数据留下的“痕迹”会原封不动地留在 View 树里。这就好比酒店连续把同一个房间卖给两拨客人,却没有打扫上一任客人留下的毛巾和牙刷,新客人一进门自然满眼都是别人的痕迹。解决办法听起来很简单,就是“不复用则已,复用就必须全量重置”。所有可能会随数据变化而变化的属性,都要在每次 onBind 时给一个明确的默认值,哪怕那个值只是空字符串、false、View.GONE,都要主动写一次。不要依赖上一个 ViewHolder 残留的内容。

2.2 异步错位:回调时位置早已换了主人

状态残留肉眼可见,异步错位则更难定位。一个典型场景是这样的:你在 onBindViewHolder 里启动了某个异步任务,比如从网络下载图片、从数据库查询备注信息,然后在回调里拿到数据后,直接找到 itemView.findViewById(...) 把它填充进去。这个请求可能很快,也可能很慢,但安卓系统的 ViewHolder 根本不会等你。列表继续滚动,这个 ViewHolder 可能已经被回收并重新绑定给了另一个 position 的数据。那个位置的数据拿到响应后,也调用了同样的填充逻辑,于是两张图片或者两条文本就在同一个 ViewHolder 的树上互相覆盖。

异步线程和 UI 线程的执行顺序是不确定的。图片请求恰好快的那一次,显示也许是对的;请求慢的那一次,错位就出现了。所以这类问题有非常典型的“偶发性”。要根治,不能靠“把请求调快一点”这种玄学,必须给异步任务和 ViewHolder 建立明确的对应关系:要么在回调时判一下图片所属的数据 id 与当前绑定的 id 是否一致,要么使用支持自动取消旧请求的图片加载框架(如 Glide、Coil),再配合每次绑定前清空子 View 内容。这一点我会在第三章展开。

2.3 监听器与嵌套列表:最隐蔽的一类乱源

除了数据和 View 状态,监听器也是复用错乱的隐藏来源。假如你在 onBindViewHolder 里给 Button 设置了一个 OnClickListener,而这个 listener 内部访问了某个外部局部变量,比如 position 或 dataList[position],那这个写法的风险很高:第一个 ViewHolder 被克隆时,listener 里捕获的变量很可能还是旧的位置。第一代 RecyclerView 开发还流行过给 itemView.setTag(position) 来实现点击跳转,一旦复用后没有更新 tag,点击事件就会跳到错误的条目,甚至越界崩溃。

嵌套列表的问题同样隐蔽。当 Item 内部再嵌套一个 RecyclerView,子列表会有自己的回收池。如果外层列表复用 Item,而内层列表的数据源没有重置,常常出现的情况是:外层第 5 条滑动后变成第 15 条,但内层子列表仍然显示着第 5 条对应的子项。很多人以为是内层 Adapter 写错了,实际上是因为外层复用时把“旧内层列表持有的历史状态”带了过来,却没有根据新数据重新 set 内层 Adapter 的数据。

3. 一套能直接抄作业的稳定方案

3.1 先把每一条 Item 变成一个“自带重置逻辑”的独立组件

要解决复用混乱,我最想强调的思路并不是“在出问题时打补丁”,而是从写法上让每一条 Item 拥有强制重置状态的能力。大原则是:ViewHolder 不要只是一个数据容器,它应该像一个状态机,每次被绑定的时候,都先执行 clearState(),再执行 fill(data)。

以 RecyclerView 为例,可以这样组织代码:

class UserAdapter : RecyclerView.Adapter<UserAdapter.VH>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { return VH(LayoutInflater.from(parent.context).inflate(R.layout.item_user, parent, false)) } override fun onBindViewHolder(holder: VH, position: Int) { val user = list[position] // 第一步:重置所有可变状态 holder.name.text = "" holder.avatar.setImageDrawable(null) holder.follow.isChecked = false holder.desc.visibility = View.GONE // 第二步:填充新数据 holder.name.text = user.name holder.desc.text = user.desc holder.desc.visibility = if (user.desc.isEmpty()) View.GONE else View.VISIBLE // 异步加载时把当前用户 id 绑到 view 上,用于请求回来后的比较 holder.avatar.tag = user.id loadAvatar(user.id, holder.avatar) } }

千万不要小看第一步。你把不可见控件设为 GONE、文本清空、图片置空这些看似多余的操作,才真正斩断旧数据残留的传递链。我见过很多修复“复用混乱”的代码,都是只在出问题的那个控件上加一个 if 判断,结果修好一个,另一个又冒出来。彻底的方案是给每次绑定一个完整默认态。

3.2 文本输入类 Item 的防乱串实战

列表里放 EditText 是复用问题的高发区,因为 EditText 不仅有文本内容,还有光标位置、焦点状态这些额外变量。你需要做到三点:

第一,onBindViewHolder 里 setText 之前不要依赖“上一次文本为空所以不设置”这类逻辑,直接每次强制写一次数据源里的内容,并且只在内容确实变化时才调用,避免触发 TextWatcher 的二次回调。第二,不要把数据直接存到 View 内部当作唯一来源,用户输入内容要实时回写到数据模型里,否则滑走再滑回来,输入内容就会清空或者乱串。第三,在 onBind 时给 EditText 设置一个 TextWatcher,但这个 watcher 最好复用同一个实例,并在绑定前移除、绑定后重新添加,否则复用后 listener 叠加会导致点击一次触发多次回调。

一段参考写法:

class NoteAdapter(private val notes: MutableList<Note>) : RecyclerView.Adapter<NoteAdapter.VH>() { override fun onBindViewHolder(holder: VH, position: Int) { val note = notes[position] holder.editText.removeTextChangedListener(holder.listener) holder.editText.setText(note.content) holder.editText.setTag(R.id.note_id, note.id) holder.editText.addTextChangedListener(holder.listener) } }

其中 TextWatcher 在 ViewHolder 创建时初始化,回调里根据 holder.editText.getTag() 获得当前条目 id,再把输入内容回写到 notes 集合对应项。注意设置顺序:先 remove,再 setText,最后 add。如果你先 add 再 setText,TextWatcher 会在 setText 期间被调用,导致数据被不清不楚地改写。另外,焦点和键盘弹出会让列表滚动时编辑状态异常,建议在 RecyclerView 滚动时统一 clearFocus 并收起键盘,以免让用户产生列表“自己跳动”的错乱感。

3.3 图片加载与异步任务的统一入口

异步任务回填是复用混乱的另一个重灾区。现代项目里我强烈建议直接使用成熟的图片加载库,不要在 Adapter 里手动写一套 AsyncTask + Bitmap 的逻辑。Glide、Coil 这类库都内置了“绑定到 View 生命周期”的能力:同一个 ImageView 在绑定新数据时,新请求会取消旧的加载;加载完成后如果发现 View 已经不在原位置,也会拒绝回填。

即便使用这类库,也不能完全省略状态重置。reason 是 View 复用后,ImageView 里可能还留着上一张图,直到新图加载完毕才被替换,中间会有一个肉眼可见的“旧图短暂停留”。如果想要更干净,可以在 onBindViewHolder 里先设置占位图或直接清空 drawable。同时,用图片 URL 或数据 id 作为 tag,在需要手动处理回调的场景中检查 tag 是否一致,是自己写异步任务时的底线。

如果你因为业务特殊,不能用图片库,需要自己维护请求,一定要在绑定新数据时标记当前 position,并在回调时增加一个“当前 ViewHolder 是否还被原数据持有”的判断。在 view 被 detach 或 re-bind 时置一个生成版本号,来确保回调不施加到错误位置。

3.4 数据更新用 DiffUtil,少用整套刷新

很多看似“复用混乱”的问题,其实来自开发者习惯性地调用 notifyDataSetChanged()。这个方法的副作用是全部 ViewHolder 都要被重新绑定,即使你的数据只有其中一行变了。如果某一行存在异步加载或者输入框焦点,全量刷新很容易导致焦点丢失、闪烁、输入内容被重设,视觉上就像列表乱了。

更稳妥的做法是使用 ListAdapter + DiffUtil,让系统根据新旧列表的差异精准通知增删改移。比拼注意点:DiffUtil 的 areItemsTheSame 最好基于稳定 id,例如用户 id、订单号;areContentsTheSame 则比较每条数据字段是否变化。只有当内容真的变化时才触发 onBindViewHolder。你会发现高频业务下,这个方案既省电,也治愈大量“乱跳”问题。

注意:即使使用了 DiffUtil,View 的复用缓存机制仍然存在。DiffUtil 只是减少不必要的 onBind 调用,并不会改变一个 ViewHolder 在滚动回收后的复用路径。所以不要在使用了 DiffUtil 之后就不做 3.1 小节里的状态重置,两者解决的问题是两个层面:前者解决“何时需要更新”,后者解决“更新时如何保证 View 状态完全受控”。

4. 我在项目里踩过的复用坑(问题排查实录)

4.1 实录:图片错乱,加载框架都没救回来

有次线上反馈说列表图片时不时变成别人的头图。我看代码用的是 Glide,理论上它应该能处理 View 复用时的请求取消,但图片仍然错乱了。排查过程分三步:先看是不是同一个 ImageView 复用到另一行时,Glide 的占位图覆盖逻辑没有生效;再看是不是 glide 的“.override()”导致的缓存 key 问题;最后把图片对应的 id 打到 log 里,发现错乱的图片都是数据列表里后来发生过“按时间倒序排序”的行。

真相是:服务器在翻页时返回了新顺序,列表更新后 position 变了,但使用 Glide 时,请求 Key 里的参数没有包含版本号或业务 id,导致旧缓存命中后放到了新位置。简单说,问题不在 Glide 的请求取消,而在缓存 key 的设计。后来我们把所有异步请求都应该带业务 id 编码到 key 里,再配合绑定前清空 imageView,问题直接消失。这个经历说明复用混乱的排查不能只看 View 本身,还要考虑数据层和缓存层是否会串数据。

4.2 实录:EditText 内容来回跳,焦点和输入互相打架

另一个印象深刻的问题是列表中有输入框,用户在第一行输入“测试”,往下滑再回来,第一行内容变成了第二行的文字,而且光标位置很诡异。看着像 EditText 被复用后文本没写好,但我在代码里明明每次绑定都写了 setText。后来才发现,问题出在我把 TextWatcher 写在 onBindViewHolder 里,每次都 new 一个实例,然后 add 上去,导致同一个 EditText 在复用后存在两个 listener。旧 listener 里保存的 position 还是上一次的,用户一输入,旧位置的数据被实时改写,写回去的内容又覆盖了当前 setText 的内容。

修复之后我养成了两个习惯:第一,所有 Watcher、Listener 都放到 ViewHolder 创建时初始化,不要让他在复用时被叠加监听;第二,收到 TextWatcher 的 afterTextChanged 回调后,用 holder.itemView 上的 tag 拿到当前条目的业务 id,而不是拿 position 去操作 list,因为 position 在列表移动或排序后会变化。顺着这个思路,文本串台再也没出现。

4.3 实录:RecyclerView 高度忽高忽低,滚动像抽搐

还有一种“复用混乱”表型在 Item 高度不固定时特别明显:列表滚动时,Item 高度忽高忽低,甚至出现大面积留白。复盘之后发现每个 Item 的布局里有一个“详情描述”区域,数据为空时不显示,有数据时显示并撑高 Item。由于复用的 ViewHolder 首次创建时描述的 visibility 是确定的,滚动复用后如果 onBind 里没有重置 visibility,描述区就会沿用上一行的状态,造成高度计算和实际不一致。

RecyclerView 的测量是逐层进行的,itemView 的高度在绑定数据时就会被测量,setVisibility 修改后还要请求重新 layout。所以除了在绑定数据时设置 visibility,还需要在数据变化导致高度可能变化时调用 notifyItemChanged 并传递 payload,或者干脆固定 Item 高度,或者使用 setHasStableIds + 高度复用策略。直接粗暴地全部刷新可能让滚动变得卡顿,稳妥做法是先用固定最小高度,再配合 payload 局部更新。

4.4 复用混乱排查速查表

为了节省大家排查时间,我把常见症状和对应方向整理成一张小表:

症状表现最可能的根因快速验证方法
文字/图片串到别的条目异步回填时未校验归属;未清空旧数据给回填数据加 id,打印当前 bind position 与回调 id
CheckBox/选中态错乱绑定数据时没有主动重置选中状态滑出屏幕再滑回,观察状态变化
EditText 内容跳跃/无法输入TextWatcher 叠加;数据没有实时回写在 afterTextChanged 里打印 holder id 与 position
Item 高度忽高忽低visibility 未重置;RecyclerView 复用到了旧测量结果固定描述区高度或强制重新 setVisibility 后再滚动
首次正常,二次错乱View 状态残留检查 onBindViewHolder 是否覆盖所有可变属性
点击事件跳到错误条目listener 捕获了旧 position改用 adapterPosition + getBindingAdapterPosition,避免外部变量

5. 复用混乱排查工具箱与最后几点建议

5.1 开发期靠这些工具快速定位问题

排查复用混乱时,没有必要全靠眼睛去猜。可以打开 Android Studio 自带的 Layout Inspector,查看当前界面上每个 ViewHolder 对应的 position 和绑定数据。这个方法对“某个 View 的 visibility 异常”、“文本残留”特别直观。也可以临时在 onBindViewHolder 里给 itemView 的 tag 写上 position 和业务 id,然后在应用中滚动几屏,导出视图层级再对比。

RecyclerView 还提供了调试接口,你可以为 RecyclerView 设置 RecyclerListener,在 View 被回收时打日志,确认一条 View 是否真的被回收复用。更简单的方式是在 Adapter 的 onBindViewHolder 里打日志,观察同一 holder 被 repeatly bind 的位置变化。如果发现某一个 holder 一会儿 bind position 3,一会儿 bind position 9,说明缓存路径正常,问题大概率在数据绑定逻辑;如果发现 bind 次数远多于可见条目数,就要怀疑是否是 notifyDataSetChanged 在频繁触发。

5.2 我一直保留的几个小习惯

第一,核心列表数据尽量使用稳定 id,不用十进制可变行号做唯一标识。第二,项目中定义 Item 状态采用显式枚举或数据模型状态,不要把状态隐藏在 View 内部。第三,写新的 Adapter 时顺手封装一个 bindContent 方法,强制先重置后赋值,这样 Review 代码时也能一眼看到哪些 View 没有处理。第四,给列表条目设置 payload 更新时,尽量让 UI 结构稳定,不要频繁切换 View 的显示类型,否则复用池中会产生大量不同 viewType,缓存率降低,复用混乱的概率也会增加。

复用混乱并不是什么高深理论,它本质上是“View 生命周期比数据生命周期长”造成的冲突。只要理解缓存原理,养成每次绑定都全量重置 View 状态的习惯,再用稳定 id 串联异步回调和列表数据,绝大多数错乱都可以迎刃而解。若再碰到类似问题,建议先问三个问题:这条 View 上一次绑定的数据是什么?这次绑定有没有覆盖上次的所有可变属性?异步任务回来时,这个 View 是否还属于同一个数据源?顺着这三个问题查下去,通常很快就能定位到病因。

返回列表