ARTICLE DETAIL

资讯详情

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

Android列表复用混乱详解:原理剖析与根治方案

Android列表复用混乱详解:原理剖析与根治方案 搞安卓列表开发的人应该都遇到过这种灵异现象列表快速滑动的时候某个头像闪烁一下显示成了上一屏某个用户的照片或者明明点的是第3个item的按钮回调里拿到的却是第7个item的数据再或者一个CheckBox自己在那变选中状态跟你手指完全没关系。这些问题听起来各不相同但根子上都指向同一个东西——ListView和RecyclerView的视图复用机制也就是大家常说的复用混乱View Recycling Confusion。这篇文章我只讲一件事复用混乱到底是怎么发生的以及如何在代码层面把它彻底根治。我会先用一次真实的问题排查过程把原理讲透再给出可直接抄作业的写法最后补充一些进阶的检测手段和优化思路适合正在维护列表页、或者准备从ListView迁移到RecyclerView的Android开发同学。1. 从视图回收机制说起为什么高效复用会带来错乱副作用1.1 复用机制的底层逻辑先别急着写代码搞清楚ListView和RecyclerView为什么非要复用你才能真正理解后面所有坑的来源。手机屏幕就那么点大一屏能显示的item数量是有限的比如一个联系人列表可能只有8个item可见。但如果整个列表有2000条数据最笨的做法是把2000个item的View全部创建出来然后一次性摆到屏幕上。这么做有两个致命问题一是内存直接被撑爆2000个复杂item的View树加起来可能几十MB二是创建View本身非常耗时inflate一个布局、初始化一堆子控件在列表快速滑动时会造成肉眼可见的卡顿和丢帧。所以Android的列表控件采用了回收复用策略滚出屏幕的item不销毁而是回收到一个池子里即将滚入屏幕的item直接从池子里拿一个旧的View出来重新绑定新数据再展示。这就是ListView里convertView参数和RecyclerView里ViewHolder的由来。ListView的写法大家应该很熟Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView mInflater.inflate(R.layout.item_contact, parent, false); holder new ViewHolder(convertView); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } // 绑定数据 holder.tvName.setText(mData.get(position).getName()); return convertView; }RecyclerView则是强制你用ViewHolder绑定动作发生在onBindViewHolder里Override public void onBindViewHolder(ViewHolder holder, int position) { Contact contact mData.get(position); holder.tvName.setText(contact.getName()); holder.ivAvatar.setImageResource(contact.getAvatarRes()); }看起来平平无奇对吧问题恰恰就藏在这个拿出来重新绑定里。1.2 复用的本质View对象没有变变的是上面贴的数据我要强调一个关键认知复用发生时View对象本身是完全相同的那个实例。假设屏幕上8个item对应的View对象是V1~V8。你向上滑动V1滚出屏幕被回收到池子屏幕底部要显示第9条数据时系统把V1从池子里取出传给onBindViewHolder让你把第9条数据填进V1。此时V1身上还残留着第1条数据的所有痕迹TextView的文本、ImageView的图片、CheckBox的选中状态、View的tag、背景色、layoutParams全部都在。你必须在这一次onBindViewHolder调用里把所有需要改变的东西全部改成第9条数据对应的值。但只要有一项没改到位你看到的就是第9条数据第1条数据的残留状态这就是复用混乱的本质。我用酒店入住来打个比方复用机制相当于酒店快速退房后再入住的标准流程——客人退房后床单被套理论上要换但实地上一个客人留下的牙刷、水杯、便签纸如果保洁遗漏了下一个客人一进门就会看到上一任客人的私人物品。列表复用就是这个道理ViewHolder是那个房间你在onBindViewHolder里做的绑定操作就是保洁。1.3 为什么高效复用和状态残留是天然矛盾你可能想说那绑定的时候把所有字段都设置一遍不就行了理论上是但实际代码里很难做到。原因有两个。第一个原因是开发者只写了有值的情况。比如有个item需要根据数据是否点赞来显示一个小红心很多人会写成if (contact.isLiked()) { holder.ivLike.setVisibility(View.VISIBLE); }这行代码在isLiked()为true时没问题可如果当前数据是false你什么都没做而上一任数据是true复用过来的View上小红心还是VISIBLE于是错乱了。正确写法必须同时写else分支holder.ivLike.setVisibility(contact.isLiked() ? View.VISIBLE : View.GONE);第二个原因是异步操作和复用在时序上天然冲突。最典型的就是图片加载网络请求是异步的你用第1条数据发起了一个图片请求还没等请求回来View已经被复用到第9条数据了第9条数据又发起了一个新请求。两个请求完成时间不确定谁先回来谁后回来完全看网络最后显示哪张图就变成了玄学。这种异步覆盖问题光靠老老实实在bind里设置一遍是解决不了的。理解了这一层下面再来看具体现象你就知道每个坑的根因在哪里了。2. 典型复用混乱现象的根因拆解图片错位、数据错位、点击错位2.1 图片错位异步加载与复用叠加的经典事故图片错位是列表开发里出现频率最高、也最容易让测试抓狂的bug。场景固定是快速滑动后某个位置的图片短暂显示成其他位置的图片再慢慢变成正确的。根因链路是这样的用户快速滑动屏幕上位置A的item被复用到位置B。位置A发起过一个异步图片加载任务比如加载用户头像这个任务还在进行中。位置B的onBindViewHolder执行给同一个ImageView设置了新URL也发起了一个新加载任务。两个任务并发执行旧任务先完成把图片直接通过imageView.setImageBitmap()设置了上去。在用户眼里位置B这张图片就短暂显示了位置A的头像直到新任务完成图片被覆盖成正确内容。这里有个细节值得展开如果用的是Glide或Picasso这类成熟图片库其实它们内部做了处理——新请求会复用同一个ImageView上的RequestManager旧请求会被取消或者被into()里的Target覆盖所以错乱概率很低。但如果你的项目是自己写线程池Handler/runOnUiThread来更新图片或者用的图片只读取本地文件/DB比如联系人头像库那就非常容易踩中。因为你没有任何机制告诉上一张图片的请求任务已过期。提示自己实现异步加载时m十分推荐的做法是把当前item的唯一标识比如URL、id存到imageView.setTag(key, url)里在更新UI前先检查tag还是不是当前数据不是就直接丢弃。另外图片错位还有一种隐蔽变体你用了View.GONE隐藏了某个ImageView但复用后另一个数据要求它显示如果你只设置了Bitmap没管可见性就会出现图片显示错位空白叠加的问题。2.2 数据错位position的陷阱和只设条件不设默认值问题这个错位不是说数据源弄错了而是view显示的内容和它应该对应的数据不一致。最常见的写法是Override public void onBindViewHolder(ViewHolder holder, int position) { if (position mData.size()) { ItemBean item mData.get(position); holder.tvTitle.setText(item.getTitle()); if (item.hasTag) { holder.tvTag.setVisibility(View.VISIBLE); holder.tvTag.setText(item.getTagText()); } } }这段代码的毛病一眼就能看出来没有else。如果某个item的hasTag为falsetvTag维持上一任数据的状态可能VISIBLE、可能带有上一任的文本。于是列表里会出现一些来历不明的标签。还有一类容易翻车的是用getAdapterPosition()判断位置做特殊显示比如第一个item显示特殊头部if (holder.getAdapterPosition() 0) { holder.itemView.setBackgroundColor(Color.RED); } else { holder.itemView.setBackgroundColor(Color.WHITE); }乍看有else了应该没问题但如果数据源发生了notifyItemMoved或notifyDataSetChangedgetAdapterPosition()可能返回NO_POSITION导致else分支被错误执行。在这个场景下正确的做法是用getBindingAdapterPosition()RecyclerView从21.0.0开始推荐而且在bind里不要过度依赖position来做视觉差异化最好直接体现在数据字段上。2.3 点击错位监听器捕获了旧position点击事件错乱的典型场景非常有意思一个item的点击事件回调里拿到的position是它第一次被绑定时的position而不是当前显示的position。比如ListView时代流行的写法Override public View getView(int position, View convertView, ViewGroup parent) { if (convertView null) { convertView inflate... } final int pos position; convertView.setOnClickListener(v - { Toast.makeText(context, 点击了 pos, Toast.LENGTH_SHORT).show(); }); return convertView; }问题在哪convertView被复用了但它的OnClickListener没有被替换你只是每次都new了一个新的监听器设置上去但事件绑定委托使用的是最后一次设置的关键是如果你在点击监听器里用了final int pos position这个pos是getView执行那一刻的position。当View复用到新位置时如果代码逻辑里没有重新设置监听器或重新捕获position回调拿到的其实是第一次绑定时那个旧pos你会点击第10个item弹出的是第2个item的数据。RecyclerView里类似的坑是在onBindViewHolder中创建了OnClickListener但监听器里用了holder.getAdapterPosition()而不是holder.getBindingAdapterPosition()更隐蔽的是在onCreateViewHolder里给整个itemView设了监听器监听器内部却根据itemView.getTag()取position而tag在bind时忘了更新。提示RecyclerView推荐的做法是在onBindViewHolder里给holder.itemView单独设置tag或设置OnClickListener并且在点击回调里通过holder.getBindingAdapterPosition()获取位置不要缓存position到一个成员变量里。2.4 交互型控件的状态残留CheckBox、EditText、SeekBar这类问题比前几个更隐蔽因为用户会直接参与操作状态残留会和用户自己的操作混在一起。拿EditText举例。列表里的输入框是复用混乱重灾区用户在某个item的EditText里输入了你好。滚动后View被复用onBindViewHolder执行了但你只设置了hint、text为空没有显式setText()。用户看到复用后的输入框还带着你好但数据源里根本没有保存这个值。更头疼的是如果你在onTextChanged里做实时保存比如输入时更新数据源当你因为复用而主动setText()时也会触发onTextChanged就可能把旧数据写进新位置的bean里或者导致光标位置跳动。CheckBox也有类似问题。你以为勾选了一个CheckBox其实复用时上一任数据是选中状态当前数据绑定后没改你看到的选中是继承来的。如果还在onCheckedChanged里直接修改数据源那错的就不仅是UI连数据也脏了。这类问题的根治思路非常统一数据源是唯一可信来源Single Source of Truth。用户交互产生的状态先写回数据源UI永远从数据源读取并还原。后面会给出标准写法。3. 一次完整的问题排查链路从看起来正常到稳稳复现3.1 复现条件整理快速滑动异步加载局部更新先别急着改代码真正的排查第一步是稳定复现。复用混乱这玩意儿有个特点慢速滑动一般不出现快速滑动才现形还不一定每次都能看到。这就要求你先把复现路径固定下来。我自己的经验是三个步骤凑齐成功率几乎100%制造快速滚动用recyclerView.smoothScrollBy(0, 100000)或者手动快速fling让item以最快速度滑出滑入。配合异步加载如果列表里有网络图片可以把图片服务器模拟慢一点比如加个1~2秒延迟让异步返回的时机正好落在View已复用之后。数据源动态更新滑动的过程中调用notifyItemChanged(position)或者adapter.notifyDataSetChanged()让列表局部刷新。这一步很容易放大复用时状态绑定错乱的问题。如果你怀疑某个具体item比如带EditText或CheckBox最稳妥的做法是只保留这一个类型缩小列表规模把列表缩短到3~5个item反复上下滑动。短列表更容易观察到状态残留因为复用的频率高。3.2 通过日志确认ViewHolder实例与position的对应关系一旦能稳定复现接下来要确认到底是不是同一个ViewHolder被复用了。最简单粗暴的方式是在onBindViewHolder入口打印关键信息Override public void onBindViewHolder(ItemViewHolder holder, int position) { Log.d(RecycleDebug, bind pos position , holder System.identityHashCode(holder) , item mData.get(position).getName()); }System.identityHashCode(holder)拿到的是ViewHolder实例的唯一哈希码而不是hashCode()可能被重写后的值。多个position打印出同一个holder哈希码就说明这个holder确实被复用了。有了这组日志你再看哪些position之间出现了状态串台问题就缩小了比如position 0绑定到holder ABC接着position 9绑定到holder ABC然后你发现position 9显示了position 0的图片那基本锁定是position 9绑定数据时没把position 0遗留的图片状态清掉。如果是ListView在getView里打印System.identityHashCode(convertView)也一样。3.3 用Layout Inspector和Hierarchy Viewer做视图级确认日志只能说明同一个holder绑定了不同position但具体是哪个子View残留了状态还需要视图级的手段确认。Android Studio的Layout Inspector可以直接挂载到运行中的列表界面展开当前屏幕的View树。你可以在快速滑动后停止再打开Layout Inspector对比当前屏幕第N个item显示的文本/图片和数据源第N个item应该显示的文本/图片。如果两者不一致那这个View树里就能直接看到残留状态。这个方法对于不知道是哪个子View出问题的场景尤其有效——你能一眼看到错的是ImageView还是TextView。还有个小技巧把RecyclerView的setRecycledViewPool换成自定义RecycledViewPool并重写putRecycledView和getRecycledView在方法里打日志能看到哪些ViewHolder被复用了多少次。这个思路在第五部分会展开。3.4 问题归属判断框架三种错误类型的边界排查到这一步你应该能回答这个bug到底属于哪一类。我习惯把复用混乱分成三类判断清楚之后再下药现象可能根因排查方向图片/文本是旧数据的但数据和position都对onBindViewHolder没有完整覆盖所有字段状态检查所有setVisibility、setText、setImageResource是否成对出现有if必有else显示内容和新数据对不上position也异常异步任务返回时覆盖了新数据检查图片或其他异步加载是否有tag校验、取消机制View交互后状态无法保持滚动后丢失或串台状态没有写回数据源或写回时位置错乱检查EditText/CheckBox的监听器里是否用getBindingAdapterPosition()更新数据源这三类的处理方案各不相同下一章我给出每种类型对应的标准写法。4. 从代码层面根治复用混乱正确姿势与完整示例4.1 让绑定方法幂等任何字段都要无条件的完整设置上一章反复出现的有if必有else落实到写法上就是一句话让onBindViewHolder成为幂等操作。意思是同一个ViewHolder不管之前绑定的什么数据这一次绑定之后所有可见状态必须完全由当前数据决定不残留任何上一任数据的状态。我把这段处理逻辑用一个表格对比一下你感受下差异写法错误示范正确示范设置文本if (bean.hasTitle) holder.tvTitle.setText(bean.title);holder.tvTitle.setText(bean.hasTitle ? bean.title : );设置可见性if (bean.isLiked) holder.ivLike.setVisibility(VISIBLE);holder.ivLike.setVisibility(bean.isLiked ? VISIBLE : GONE);设置图片if (bean.thumb ! null) holder.ivThumb.setImageURI(bean.thumb);if (bean.thumb ! null) { 加载 }; else { holder.ivThumb.setImageResource(defaultRes); }设置背景if (pos % 2 0) holder.root.setBackgroundColor(colorA);holder.root.setBackgroundColor(pos % 2 0 ? colorA : colorB);如果item的字段很多容易漏有一个实用技巧先列出数据模型里所有决定UI状态的字段然后在onBindViewHolder里逐个“配对检查”。代码注释里写清楚每个字段对应的Viewreview代码的时候一眼就能看出谁没有else分支。4.2 图片异步加载的彻底处理方案如果你的项目用Glide最无脑的写法是这样Glide.with(holder.itemView.getContext()) .load(contact.getAvatarUrl()) .placeholder(R.drawable.ic_avatar_default) .error(R.drawable.ic_avatar_default) .into(holder.ivAvatar);Glide内部会根据into(holder.ivAvatar)绑定到同一个Target新的load操作会取消之前的请求所以基本不会串图。但我遇到过两个Glide相关的小坑提一下。一个是with(context)的context类型。如果传的是ActivityGlide会跟随Activity生命周期取消请求传ApplicationContext的话生命周期覆盖全局请求不会被自动取消复用场景下风险更高。列表场景建议传holder.itemView.getContext()或者最稳妥的是传Fragment/Activity实例。另一个是本地加载别忘了占位图和默认图。即使数据是一张本地文件路径或DB里的blob加载过程中如果holder被复用受限于旧的加载任务可能短暂显示旧图。Glide对这类任务也有标准解法——placeholder和centerCrop能保证显示稳定。如果是自己写异步线程加载图片标准对抗方案是tag校验// 绑定图片 String url contact.getAvatarUrl(); holder.ivAvatar.setTag(R.id.key_avatar_url, url); holder.ivAvatar.setImageResource(R.drawable.ic_avatar_default); // 先放默认图 loadAvatarAsync(url, bitmap - { if (url.equals(holder.ivAvatar.getTag(R.id.key_avatar_url))) { holder.ivAvatar.setImageBitmap(bitmap); } });核心思路UI回调回来时先校验View上的tag是否还是当前数据的url不是就丢弃。这段代码里holder.ivAvatar.getTag(key)拿到的就是最后一次绑定的url旧任务的url和它不相等就不更新UI。4.3 多类型item的拆分与复用池隔离当列表里有多种完全不同的item时比如聊天列表的文本、图片、系统消息最忌讳的做法是把所有分支堆在一个ViewHolder里里面写一堆if (type TYPE_TEXT) ... else if (type TYPE_IMAGE) ...。这样不但性能差而且每种类型自身的View状态也容易串台因为ViewHolder复用时不会按类型区分。正确做法是让RecyclerView按类型自动隔离复用池Override public int getItemViewType(int position) { ChatItem item mData.get(position); if (item instanceof TextMessage) return TYPE_TEXT; if (item instanceof ImageMessage) return TYPE_IMAGE; return TYPE_SYSTEM; } Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { if (viewType TYPE_TEXT) return new TextViewHolder(...); if (viewType TYPE_IMAGE) return new ImageViewHolder(...); return new SystemViewHolder(...); }RecyclerView内部为每种viewType维护一个独立的RecycledViewPool不同类型的ViewHolder不会交叉复用这从机制上解决了类型之间的状态混乱。要注意的是getItemViewType的判定条件必须稳定——如果你根据position来返回type数据源变动时position会漂移就会导致ViewHolder和viewType不匹配的崩溃。ListVIew也是类似的通过getItemViewType和getViewTypeCount来区分不过RecyclerView对多类型支持更灵活类型数量也没有硬上限。我的经验是只要列表里存在结构差异明显的item就要走多type方案而不是在同一套布局里做N种显隐。4.4 交互控件状态保存与恢复的标准写法到了EditText和CheckBox这里代码要求更高我会把完整流程写出来方便直接抄。以CheckBox为例第一步在数据模型里加一个布尔字段public class ListItemBean { public String title; public boolean checked; }第二步onBindViewHolder里这样处理// 先移除监听器防止bind时 setChecked 触发回调 holder.cb.setOnCheckedChangeListener(null); // 从数据源恢复状态 holder.cb.setChecked(bean.checked); // 再设置监听器让用户操作写回数据源 holder.cb.setOnCheckedChangeListener((buttonView, isChecked) - { int pos holder.getBindingAdapterPosition(); if (pos ! RecyclerView.NO_POSITION) { mData.get(pos).checked isChecked; // 如果还需要联动其他UI在这里触发 } });这个顺序非常关键先移除监听再set状态最后添加监听。如果不先移除setChecked(bean.checked)会触发一次onCheckedChanged里面如果写了更新数据源的逻辑就可能把别的item的数据改了或者递归刷新列表造成死循环。这是列表开发里极其容易踩的坑。EditText稍微复杂一点因为它涉及文本监听和光标位置holder.etInput.setTextKeepState(bean.inputText); // 而不是 setText holder.etInput.setOnFocusChangeListener((v, hasFocus) - { if (!hasFocus) { int pos holder.getBindingAdapterPosition(); if (pos ! RecyclerView.NO_POSITION) { mData.get(pos).inputText holder.etInput.getText().toString(); } } });setTextKeepState()是EditText的方法它能在设置文本时尽量保留光标的可编辑位置减少跳动感。文本监听器我没有在bind时设置而是放在焦点变化里写回这样能大幅降低输入过程中的刷新干扰。如果你确实需要在输入过程中实时保存比如搜索列表那就只能在TextWatcher里加一个isBinding布尔标志来屏蔽主动setText时引发的回调。4.5 ListView和RecyclerView遵循的同一套原则虽然这两个控件API不同但如果你把视角拉高会发现复用混乱的所有根治方案都归结为了三条不变的原则绑定必须幂等view上所有受数据影响的状态都要在bind期间被无条件地设置成当前数据应有的值。异步回调用tag校验所有耗时任务图片、网络、DB查询回到UI线程时都必须校验发起任务的标识和当前View的状态是否匹配。数据源是唯一事实来源交互产生的变化第一时间写回数据源UI从数据源读取绝不直接修改View状态而不落数据。这三条规则对应解决三类错乱状态残留、异步覆盖、交互串台。ListView的getView和RecyclerView的onBindViewHolder只是换了个外壳内核逻辑一模一样。5. 进阶复杂场景下的复用控制与检测机制5.1 RecyclerViewPool的配置与跨列表复用风险RecyclerView默认每个viewType的回收池容量是5也就是RecycledViewPool.DEFAULT_MAX_SCRAP 5。当你的列表有几百条数据且item类型复杂时这个容量可能不够——回收池太小来不及复用的ViewHolder会被真正销毁频繁inflate新View卡顿就会回来。你可以自定义一个RecycledViewPoolRecycledViewPool sharedPool new RecycledViewPool(); sharedPool.setMaxRecycledViews(TYPE_TEXT, 10); sharedPool.setMaxRecycledViews(TYPE_IMAGE, 15); recyclerView.setRecycledViewPool(sharedPool);如果你打算让多个RecyclerView共享同一个pool有件事必须心里有数不同列表如果使用不同的ItemDecoration、不同的背景、不同的item布局复用过来的ViewHolder可能带有上一个列表的装饰状态比如分割线、选中背景造成UI错乱。所以多个列表共享pool只适用于item外观和数据模型完全一致的场景否则宁可各自维护也不要贪这个性能。5.2 StableId与DiffUtil让position不再成为误解的根源前面反复提到position在复用时的各种陷阱。如果列表数据会动态变化比如删除、插入、移动position的漂移会让复用问题雪上加霜。RecyclerView提供了一劳永逸的基础配置——setHasStableIds(true)同时让你的数据模型返回稳定唯一的getItemId()adapter.setHasStableIds(true); // in adapter: Override public long getItemId(int position) { return mData.get(position).getId(); // 每条数据有稳定的唯一id }开启稳定ID后RecyclerView在刷新时会尽量通过id定位item而不是纯靠position。配合DiffUtil做增量更新非常明显能减少不必要的onBindViewHolder调用从而降低“反复绑定导致状态串台”的概率。DiffUtil的使用框架也给出DiffUtil.DiffResult diffResult DiffUtil.calculateDiff(new DiffUtil.Callback() { Override public boolean areItemsTheSame(ListItemBean oldItem, ListItemBean newItem) { return oldItem.getId() newItem.getId(); } Override public boolean areContentsTheSame(ListItemBean oldItem, ListItemBean newItem) { return oldItem.equals(newItem); } }); diffResult.dispatchUpdatesTo(adapter);注意areContentsTheSame是在后台线程调用的别在里面做heavy操作否则主线程虽然不卡DiffUtil的计算线程也会拖慢整体效率。如果是超大列表建议把calculateDiff放到后台线程执行最后切回主线程分发结果。5.3 自己写一个Adapter的复用检测工具业务代码有了一定规模后靠人眼review很难覆盖每一个item的绑定逻辑。我给自己项目写过一个小工具思路不复杂分享给大家。包装一个DebugRecyclerViewAdapter内部代理真实的Adapter在onBindViewHolder里做三层记录记录当前绑定的holder.identityHashCode和position对应关系到一个Map。如果一个holder在短时间内被连续绑定到两个不同的position打印一行警告日志。遍历holder的所有子View检查那些设置了tag的异步任务是否和当前position的数据匹配。核心代码长这样public class DebugAdapterProxy extends RecyclerView.AdapterRecyclerView.ViewHolder { private final RecyclerView.Adapter? delegate; private final MapInteger, Long holderLastBoundPos new HashMap(); public DebugAdapterProxy(RecyclerView.Adapter? delegate) { this.delegate delegate; setHasStableIds(delegate.hasStableIds()); } Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return delegate.onCreateViewHolder(parent, viewType); } Override public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { int hash System.identityHashCode(holder); Long lastPos holderLastBoundPos.put(hash, (long) position); if (lastPos ! null lastPos ! position) { Log.w(DebugAdapterProxy, ViewHolder hash reused from pos lastPos to pos position); } delegate.onBindViewHolder((RecyclerView.ViewHolder) holder, position); } // 省略 getItemCount 和 getItemViewType 的代理... }你在开发调试阶段包一层就能把所有发生复用的holder变化轨迹全部抓下来结合业务数据一对照哪个position之间状态串台一目了然。上线版本记得去掉这层日志包装。5.4 兜底手段必要时关闭部分复用能力虽然不太常规但我确实遇到过一些极端场景一个item里嵌套了视频播放器、地图、Canvas动画这类重交互、状态复杂、很难幂等绑定的View。这种情况下复用机制反而是负担——每次绑定都要处理一大堆状态恢复重试多次依然有bug。这时候可以允许自己反优化一下为这一类item在getItemViewType里分配一个独立类型并把这个类型的回收池容量设成0也就是不回收、不复用每次创建新ViewHolder。牺牲一点性能换来绝对可控的状态管理对某些重场景来说是值得的。一个典型例子带有VideoView的item。VideoView内部持有MediaPlayer复用时如果不妥善处理播放状态、进度、画面都会串关掉复用一个类型是最省心的兜底。需要注意这种做法只能作为局部兜底不能全列表关闭复用——那就等于放弃了RecyclerView存在的意义列表一长必卡。5.5 还有两个容易被忽视的坑中坑最后补充两个不容易归类但实际开发中我踩过的坑。第一个是Adapter的notifyXX和数据源不同步。数据源更新和notify之间如果隔着网络回调、线程切换很容易出现数据源已经变了但Adapter还没通知列表的中间态。这个时候列表正在复用ViewHolder绑定的是旧数据等数据源到位后再notify位置可能已经错位。解决办法很简单所有数据修改和notify动作必须发生在同一线程、同一个同步块内顺序固定为改data → notify。第二个是RecyclerView的预取Prefetch。LayoutManager默认会提前创建并绑定即将滑入屏幕的item。预取本身不产生复用混乱但如果你的item在bind时有比较重的副作用比如数据埋点、启动动画、网络请求这些副作用会被提前执行导致列表还没滑过去就已经在调用bind了。表现上很像数据错乱。如果遇到这种情况可以在布局的RecyclerView.LayoutManager上禁用预取((LinearLayoutManager) recyclerView.getLayoutManager()).setItemPrefetchEnabled(false);代价是滑动流畅度会有所下降所以非必要不开。我自己在实际项目里有一个默认规范所有item绑定的异步操作必须走统一封装的数据加载器且所有ViewHolder的字段绑定全部用完整设置风格代码规范在Review阶段就把大部分复用问题掐死了。真到了测试阶段还能复现的基本都用第三部分的排查链路一个下午搞定。列表复用混乱不是一个高深的问题但它考察的就是你对View生命周期、绑定时机和数据流向的理解深度。把这篇文章里的原则吃透再用DebugAdapterProxy跑两轮这种问题就不该再出现了。
返回列表