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 确实把创建 ViewHolderonCreateViewHolder和填充数据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.AdapterUserAdapter.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: MutableListNote) : RecyclerView.AdapterNoteAdapter.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 再 setTextTextWatcher 会在 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。你会发现高频业务下这个方案既省电也治愈大量“乱跳”问题。注意即使使用了 DiffUtilView 的复用缓存机制仍然存在。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 与回调 idCheckBox/选中态错乱绑定数据时没有主动重置选中状态滑出屏幕再滑回观察状态变化EditText 内容跳跃/无法输入TextWatcher 叠加数据没有实时回写在 afterTextChanged 里打印 holder id 与 positionItem 高度忽高忽低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 是否还属于同一个数据源顺着这三个问题查下去通常很快就能定位到病因。
返回列表