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

资讯详情

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

Android列表复用混乱全解析:从机制到实战修复

Android列表复用混乱全解析:从机制到实战修复

做安卓的兄弟,多多少少都被列表复用坑过。ListView的convertView、RecyclerView的ViewHolder,这套机制帮我们省了海量内存,让列表轻松跑几千条数据不卡。但复用的另一面,就是各种“数据串门”——图片张冠李戴、CheckBox明明没点却显示选中、输入框里的字跑到了上一行……这就是圈里常说的复用混乱问题,也是初级工程师面试几乎必问、线上Bug高频出没的重灾区。今天我不打算讲那些泛泛的“复用机制原理”,而是把多年实战里遇到的真实场景、根因分析、修复代码和排查手段,系统性地梳理一遍,希望能帮正在被列表Bug折磨的朋友省下几个通宵。

1. 先从机制说起:为什么列表控件要复用View

1.1 ListView时代:convertView与ViewHolder的由来

在ListView的年代,Adapter的getView方法是整个列表性能的核心。屏幕能显示的item数量其实很少,假如一屏能展示10个item,ListView不会把1000条数据全部创建成View——它只创建大概10个左右,当item滚出屏幕时不会被直接销毁,而是被回收进一个池子里,滚入屏幕的新item直接拿池子里的旧View来用,这个参数就是getView里的convertView。

这个设计的初衷非常务实:View的创建和inflate是重活,一个LinearLayout加几个TextView,inflate一次可能要几百微秒,滚动时连续inflate几十次,肉眼就能看到掉帧卡顿。复用convertView,相当于把inflate次数压到最低,滚动时只需要重新绑定数据,性能立刻上了一个档次。

但问题也随之而来:很多新手在getView里拿到convertView之后,只在convertView == null时做inflate,然后直接绑定当前position的数据。这里有一个致命遗漏——假如item里有一个ImageView,你给它setImageResource(R.mipmap.default_img),当这个View从第0个position滚回第100个position时,ImageView的内容还是上一个position的图片。如果没有重新设置,或者设置逻辑被if-else挡住了,那么位置和数据就会错位。这就是复用混乱最原始的面貌。

ViewHolder在这里扮演的角色,是为了避免频繁调用findViewById。每个item里那几个固定的控件,在复用的时候本来就可以保存引用。用静态内部类也好、用局部变量也好,关键是把findViewById的次数压下来,同时code review的时候更容易看清楚每个控件是否都绑了数据。

提示:ListView时代有一个经典判断——“convertView == null时才inflate,否则直接用”。这句话本身没有错,但很多Bug的根源是只记住了这句话,却忘了“绑定数据”这件事必须每次都执行。

1.2 RecyclerView的缓存体系:从scrap到RecycledViewPool

RecyclerView把复用机制做得更彻底、也更复杂。它不再有convertView这种直白的参数,而是由Recycler统一管理View。ViewHolder的创建、绑定、回收分别对应onCreateViewHolder、onBindViewHolder和onViewRecycled三个回调。

Recycler内部有几层缓存,简单说:

  • mAttachedScrap:当前还在屏幕上的ViewHolder,短暂离开也先留着,主要用于刷新时的局部复用。
  • mCachedViews:刚刚滚出屏幕的ViewHolder,按position缓存,默认容量2,可以直接复用,不需要重新走绑定逻辑。
  • RecycledViewPool:缓存ViewHolder的池子,按ViewType分类,默认每个type缓存5个,复用时需要重新走onBindViewHolder绑定数据。

这里有一个非常重要的点:mCachedViews里的ViewHolder是“刚刚滚出屏幕、还没经过回收”的状态,它绑定的是旧position的数据。如果用户快速滑回去,直接复用的可能还是旧数据,而onBindViewHolder可能压根不会触发——这恰恰是很多“返回时数据不对”的诡异Bug的来源。很多开发者在RecyclerView里遇到复用混乱,第一反应是onBindViewHolder没写全,实际上忽略了mCachedViews直接复用导致的“不调用onBindViewHolder”问题。

理解这层缓存结构,对定位问题非常关键。很多看似“数据错误”的Bug,其实是“View没有经过重新绑定”。解决方案是:在Adapter的onBindViewHolder里保证“无论这个ViewHolder来自哪一层缓存,都要把当前item的数据完整地刷一遍”,同时在需要稳定标识的场景下重写getItemId、调用setHasStableIds(true),帮助Recycler更准确地判断item身份。

2. 五种高发场景:复用混乱到底乱在哪

2.1 图片错乱:异步加载与异步取消的博弈

图片错乱是复用混乱里最经典、最容易复现的场景。常见写法是:

@Override public void onBindViewHolder(ViewHolder holder, int position) { ImageView iv = holder.iv; String url = dataList.get(position).getUrl(); // 假设手写异步加载,没有取消机制 loadImage(iv, url); }

如果是纯手写的异步加载,没有取消机制,问题几乎是必然的:网络图片回来的时候,ImageView可能已经滚到了别的位置。本来应该显示第0个位置的图片,但第0个View已经滚出屏幕,被复用到了第30个位置,加载线程把图直接set到了当前复用的ImageView上——于是第30条数据显示的是第0条数据的图片。这就是典型的异步回调没和position绑定、回调前也没有取消或校验导致的错乱。

真实的项目里还有更隐蔽的情况:使用图片加载库时没有区分placeholder,或者同一个url在快速滚动时频繁触发加载,上一个任务还没完成,下一个任务又开始了,回调顺序错位。后面第3章我具体说怎么用库来规避这些问题。

2.2 CheckBox状态错乱:数据驱动的缺失

CheckBox、Switch是复用混乱Bug的第二大来源。电商购物车、多选列表、设置项列表里,用户勾了一个CheckBox,滚动几下,发现另一个item的CheckBox也亮了。

根因很清晰:CheckBox的状态是留在View上的,复用之后,View继续沿用上一个item的“勾选”状态,而onBindViewHolder里的绑定逻辑如果只写了“从数据里读状态再设置”,也就是holder.checkBox.setChecked(item.isChecked()),理论上每次复用都会重新覆盖状态,应该没问题。为什么还会错?

原因在于:很多人会在CheckBox的OnCheckedChangeListener里直接去改position对应的数据,但没有区分“用户操作”和“代码赋值”两种情况。当你在onBindViewHolder里调用setChecked(true)时,这个set操作本身就会触发监听器,监听器反过来又改了其他数据。加上复用,状态就彻底乱套了。

更细一层:监听器里如果用闭包捕获了position,捕获的是旧的原始int值,复用时ViewHolder的数据还没绑定,先执行了listener,就会拿旧position往数据源里写值。我在实际项目里见过不止一次这样的写法:

holder.checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> { dataList.get(holder.getAdapterPosition()).setChecked(isChecked); });

这段代码看起来没问题。但如果onBindViewHolder里先setChecked恢复状态,setChecked触发的回调会先执行,此时holder.getAdapterPosition()可能已经不是旧position了,于是旧数据被改了,新数据的状态还不一定对。这个Bug当时查了很久,最后用“先恢复状态再设置监听”的标准模式解决,具体做法第3章讲。

2.3 EditText输入串行:键盘刚打完字就没了

EditText的问题比较隐蔽,因为输入依赖系统键盘,状态不像CheckBox那么直观。典型场景是:表单列表,第一个EditText输入了文字,滚动到底下一个item,再滚回来,发现第一个EditText里的内容不见了,或者内容跑到了另一个EditText里。

这里有两层原因。第一层:EditText的文本内容本身就是View状态,复用后如果onBindViewHolder里没有重新setText,旧文本就会残留在View上。第二层:就算重新setText了,EditText内部还有一个TextWatcher,setText会触发TextWatcher,而TextWatcher里很多人会去更新数据源,但setText的时机如果早于dataList按position取值,或者TextWatcher里的逻辑不严谨,就会把旧内容写进新position的数据。

还有一个组合场景更让人头疼:EditText设置了TextWatcher之后,用户输入时不断回调,如果在回调里又notifyDataSetChanged,会导致EditText的焦点和输入状态被重置,用户正打的字符丢失、光标跳动。这一类虽然不完全是“复用混乱”,但同样发生在列表item上,很容易被当成同一个问题来查。

2.4 多种ItemType复用冲突:一个池子里的身份混战

RecyclerView的RecycledViewPool默认按ViewType区分缓存,只要正确区分ViewType,理论上不会混用。但实际开发里有两个坑:

第一种:ViewType返回了未定义的值。比如在getItemViewType里用position % 2返回type,或者只在部分路径下返回了正确的type,其他路径默认返回0,导致不同类型的item混进了同一个缓存池。之后创建View时,可能一个图片Item复用到了文本Item的位置,布局结构直接错乱,严重的时候直接ClassCastException。

第二种:自定义RecycledViewPool时,setMaxRecycledViews设置的值不合理,某些type的缓存数量不够,视图被反复创建、回收、再创建,虽然不会出现“类型错乱”,但会造成滚动卡顿和CPU额外消耗,体验上也算“性能型错乱”。

多类型列表的核心在于getItemViewType必须返回稳定、一一对应的type。通常的判定方式是拿position对应的数据对象做instanceof或类型字段比较,需要特别注意不能用纯position来判断类型,数据变化后position会漂移,type判断很容易踩错。

2.5 数据更新后不刷新:position与数据错位

最后这个场景跟复用机制本身没有直接关系,但经常被当成“复用混乱”来排查:数据源更新了,但没有调用正确的notify方法,或者调用了但位置计算错误。比如删除了第3条数据,调用notifyDataSetChanged,理论上一切正常,但如果你在ViewHolder里缓存了旧position,或者曾经用setTag存储过旧position,复用之后拿旧position去数据源取值,取出来的自然就是错位的。

这种情况的观感和复用混乱一模一样:某一行的数据和上一行一样,刷新后错乱的位置随机漂移。排查时要格外注意,先问一句“数据源是不是最新的?notify有没有调用?ViewHolder里有没有缓存position?”,很多冤枉“复用机制”的Bug,其实只是更新数据后忘了刷新。

3. 一套能落地的解题方案

3.1 数据绑定必须全量覆盖

无论ListView还是RecyclerView,有一条铁律:绑定数据时,所有UI控件都要有对应的赋值代码,哪怕它在当前item里不需要变化,也要显式给一个默认值。

举个例子。一个item里有标题、描述和状态标签,其中某些item的状态标签是隐藏的(GONE),有些是显示的(VISIBLE)。如果只写“当有状态时显示”,而没有写else分支去隐藏,那么状态标签被复用到下一个item时,就会残留上一个item的显示状态。测试同事就会报Bug:“明明这条数据没有状态,怎么还显示着状态?”

全量覆盖的正确写法是:

if (item.isShowStatus()) { holder.tvStatus.setVisibility(View.VISIBLE); holder.tvStatus.setText(item.getStatusText()); } else { holder.tvStatus.setVisibility(View.GONE); }

这个else分支在复用机制下非常重要。而且不仅仅是GONE/VISIBLE的问题,任何属性都要贯彻这个思路:背景颜色、选中态、文案、图标、alpha值、Tag,每次onBindViewHolder都重新赋值,不许漏项。

3.2 状态恢复要先恢复、后监听

CheckBox问题的标准模式,我给出一个亲测有效的做法:先在onBindViewHolder里用数据设置状态,设置完成后再附着监听器,或者用一个标志位区分程序赋值和用户操作。

holder.checkBox.setOnCheckedChangeListener(null); holder.checkBox.setChecked(item.isChecked()); holder.checkBox.setOnCheckedChangeListener(this.checkListener);

第一行先移除监听器,第二行setChecked,因为监听器是null,不会产生副作用。第三行再把监听器加回来。这个“三步法”简单直接,能解决大部分CheckBox复用问题。

如果还要更严谨,用一个boolean标志位控制回调逻辑。绑定状态期间把标志位置为false,绑定完成后再置为true,监听器里判断标志位再决定是否更新数据源。

真实项目里我更推荐“数据驱动UI”的思路:所有UI状态都只从数据源读取,点击事件里只修改数据源,然后刷新列表,不直接操作View。这样写看起来绕了一点,但在复用机制下是最稳的,也方便后续做状态恢复和埋点。

3.3 图片加载交给库,但要用对姿势

现在绝大部分项目都用Glide、Coil这类图片加载库。库内部已经处理了大量复用问题,但要确保用得对。

以Glide为例:

Glide.with(iv) .load(url) .placeholder(defaultImg) .error(errorImg) .into(iv);

这样写在大多数情况下是不会错乱的,因为Glide在into时会根据View的引用关系和请求key来判断,新加载会取消旧的加载。但有两个要点需要注意:

第一,不要在ViewHolder里缓存一个GlideRequest对象然后反复复用。如果你把RequestOptions或者RequestBuilder存在ViewHolder字段里,下一次load时还在用旧的,就可能把上一次的占位图、裁剪选项带过来。

第二,强烈不建议手写异步加载再通过给ImageView设置Tag、加载完对比Tag来做防错乱。这种经典方案虽然能解决同步问题,但手写方案要考虑内存缓存、磁盘缓存、线程池、生命周期,工作量很大。既然有成熟库,把精力留给业务才是正事。

3.4 DiffUtil和ListAdapter:让数据刷新稳下来

DiffUtil解决的是一批和“数据更新后不刷新、position漂移”相关的Bug。它能计算新旧数据的差异,让RecyclerView只做最小范围的刷新,正确触发move、change等动作。

使用时先实现DiffUtil.Callback:

class StatusDiffCallback extends DiffUtil.Callback { @Override public boolean areItemsTheSame(int oldPosition, int newPosition) { return oldList.get(oldPosition).getId() == newList.get(newPosition).getId(); } @Override public boolean areContentsTheSame(int oldPosition, int newPosition) { return oldList.get(oldPosition).equals(newList.get(newPosition)); } }

然后:

DiffUtil.DiffResult result = DiffUtil.calculateDiff(callback); result.dispatchUpdatesTo(adapter);

注意,DiffUtil的calculateDiff是在当前线程同步执行的。数据量大时容易卡顿,所以建议用ListAdapter,也就是内置了AsyncListDiffer的基础Adapter。用submitList提交新列表,它会在后台计算差异,再自动帮你在正确时机调用notify系列方法。这个方案能一口气干掉大部分和刷新相关的列表错乱Bug。

3.5 给EditText一个防串数据模板

EditText的处理要稍微讲究一点。我的模板是这样:

第一,EditText的文本内容不要直接存在ViewHolder里,每次从dataList当前位置读取,onBindViewHolder里只做一次setText赋值,不做多余操作。

第二,TextWatcher的解绑和绑定要成对。最稳的方式是:在赋值前先removeTextChangeListener,赋值完成后重新add。注意remove时需要同一个watcher实例,所以watcher要定义成成员变量或ViewHolder字段,不要定义在onBindViewHolder的局部变量里。

第三,TextWatcher回调中不要直接改dataList再notifyDataSetChanged。如果一定要实时更新数据源,只更新dataList里的数据对象,不触发列表刷新——因为刷新会导致EditText重新绑定,正在输入的内容被重置,体验极差。

之前遇到一个线上反馈:“输入框每输入两个字符,内容就被清空一次”,最后定位就是输入触发TextWatcher,TextWatcher里同步改了数据源,另一个逻辑立刻notifyDataSetChanged,item被整体重建,EditText内容就没了。这种Bug虽然跟复用无直接关系,但出现在列表item上,排查方向很容易被带偏。

4. 排查实录:快速定位复用混乱问题

4.1 复现的三种思路

排查复用混乱,最难的不是修,而是稳定复现。我的经验是先做三个动作:

第一个是快速上下滑动。往复快速滑动列表5到8次,只要存在复用问题,大概率会暴露出来。屏幕上如果有一项数据错乱,基本没跑。

第二个是滚出屏幕再滚回。单独针对某一个item,让它的View彻底滚出屏幕后再滚回来,观察数据是否保持原样。这种方式能验证mCachedViews和RecycledViewPool两条不同的复用路径。

第三个是固定场景复现。如果前两种都复现不出来,就按用户的操作路径来,比如先勾选一个CheckBox再滑动,再检查另一个位置的CheckBox。大多数场景这样就能稳定触发。

我之前遇到过一个订单列表,用户反馈“下拉刷新后,某一行显示的是另一行的商品图”。一开始我们以为就是图片加载错乱,给Glide配置了skipMemoryCache也没有用。后来我在onBindViewHolder里加了position打印,快速滑动模拟,终于在日志里发现onBindViewHolder的调用次数远少于屏幕item数量——这说明部分item根本没有经过重新绑定,直接从缓存里拿了出来。顺着这个线索,最终定位到是mCachedViews直接复用导致“旧View未被重新绑定”,而数据已经变了。

4.2 日志、Layout Inspector、Debug三种工具

排查复用混乱,我常用的工具链是三个:

第一个是日志。在onCreateViewHolder和onBindViewHolder的入口打印position和ViewHolder的hashCode。如果看到同一个ViewHolder在很短时间内对应多个不同position,说明它在正常复用;如果发现某个position完全没有走onBindViewHolder,就要警惕缓存直接命中的问题。

第二个是Layout Inspector。Android Studio自带的Layout Inspector可以查看当前界面上真实的View层级和属性。如果某个TextView显示的文本来自上一个position,你可以直接在Inspector里看到它的text属性和层级关系,顺藤摸瓜找到是谁在最后一次setText。这个工具对定位“隐藏/显示状态错乱”特别有效,visibility属性在Inspector里一目了然。

第三个是Debug断点。断点打在onBindViewHolder里,每次复用时暂停,查看holder里各控件当前的属性。断点方式比较慢,但在复杂场景下最准确。尤其是那种“只在特定数据组合下才出现”的Bug,通过断点来回调整数据构造,效率反而很高。

4.3 一个真实的Bug修复全流程

再讲一个完整的排查案例。复选框列表,滚动后状态错乱,用户勾选了A,结果B也变成了选中态。

第一步,先复现。快速滑动,稳定复现。第二步,检查onBindViewHolder,绑定逻辑本身没问题,setChecked用的也是当前item的数据。第三步,在setChecked前后打日志,发现setChecked执行之前,holder里CheckBox的状态竟然已经是false,而item的isChecked是true。这说明问题出在绑定之前的某个环节,View状态残留了。第四步,检查OnCheckedChangeListener,发现问题就出在这里:监听器在Activity里初始化,被多个holder复用了,但监听器里闭包捕获的position不是动态获取,而是onBindViewHolder时传入的旧int值,导致每次回调都用旧position去更新数据源。

修复方案是:监听器里不再用外部传入的position,改成动态获取holder.getBindingAdapterPosition(),同时把“先恢复状态、再附加监听”的顺序调整到位。修复后连续快速滑动几百次,没有再出现错乱。

5. 几个日常开发中容易混淆的坑

最后补充几个和复用混乱一起出现的高频问题,排查的时候一并检查能省不少时间。

第一个是item的根布局尽量别用wrap_content去撑满屏幕,也不要依赖复杂的嵌套权重计算。部分“复用混乱”的观感问题其实来自布局测量不稳定,导致渲染顺序错位,虽然严格来说不是复用机制本身的问题,但表现非常相似。

第二个是RecyclerView的Adapter注册了DataObserver之后,更新数据源时不能只改list不调notify,否则列表一直显示旧数据,用户操作又是新数据,两边不一致,看起来就像数据错位了。

第三个是自定义View作为item根布局时,重写onDetachedFromWindow或onViewRecycled要格外小心,不能回收一个即将被复用的View里的资源。很多漫画、表情加载App在快速滑动时崩溃,就是item滚出屏幕后图片资源被回收,但这个ViewHolder没过多久又被复用,资源却早就没了。

我个人的体会是,复用混乱问题的本质,是“View层有生命周期,数据层有生命周期,两者没有同步”。只要在写代码时始终记得:UI状态一律由数据驱动,每次onBindViewHolder都全量刷新,监听器动态获取position并管理好注册与注销,那么绝大多数列表复用Bug都可以从根上避免。

最后分享一个小技巧:每次写完Adapter,自己花三分钟做一遍“复用体检”——快速滑两屏、勾选一个选项再滑走、输入一段文字再滚回,这三招能挡住至少七成线上复用Bug。列表这种天天都在写的组件,多养一下好习惯,以后少熬几个夜。

返回列表