从一个神奇的bug开始的RecyclerView复用分析

2,281 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

神奇bug

本来是转测版本后开开心心的第二天,突然测试同事就找到我说,你这标题栏有一个bug,会把一个标题的图片加载到上一个标题去。我听了之后,直接三连:不可能,数据配错了,复现给我看

啪的一下!很快啊,立马复现,直接打脸。我捂着脸去回去翻代码,下面是简化后的代码: 很简单,一个RecyclerView,表项是TextView+ImageView,实体类是标题名+图片

TabBean

data class TabBean(var title: String, var stateDrawable: Drawable?)

TabAdapter

class TabAdapter(val mContext: Context) : RecyclerView.Adapter<TabAdapter.ViewHolder>() {
    
    private val mTabBeanList = ArrayList<TabBean>()

    ……
    
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val tabBean = mTabBeanList[position]
        holder.mTvTitle.text = tabBean.title
        tabBean.stateDrawable?.let {
            holder.mIvLogo.setImageDrawable(it)
        }
    }
    
    ……
}

数据

arrayListOf(
            TabBean("犬夜叉", getDrawable(R.drawable.selector_logo_qyc)),
            TabBean("圣斗士", null),
            TabBean("洗衣机", null),
            TabBean("暴龙兽", null),
            TabBean("那撸多", getDrawable(R.drawable.selector_logo_naruto)),
            TabBean("海贼王", null),
            //第二次增加这一条数据
            //TabBean("金木研", null)
        )

到这里一切都很正常,但是当我将数据增加一条,再重新设置数据调用notifyDataSetChanged更新后,问题就来了,下面是两次的效果图:

device-2022-04-05-000534.png device-2022-04-05-000453.png

现象

增加了一条数据,刷新后竟然让本没有图片的“暴龙兽”获得了“那撸多”的图片!

啊,这波啊,这波是多重影分身之术!来,换个小李的图片就没问题了

减少列表里的数据,或者刷新时再多增加一两条数据,最后的结果也会变得不同,这是为什么呢?看现象可以推测是复用相关的问题。


解决bug

其实看到代码,可能大家就已经发现问题的所在,那就是我在TabAdapteronBindViewHolder方法里,当图片为null时,并没有将imageViewdrawable设置为null,导致发生了这样的问题。 所以只要如下修改,就可以恢复:

if (tabBean.stateDrawable == null){
    holder.mIvLogo.setImageDrawable(null)
}

不过我还是很奇怪,为什么图片会错位“复用”到上一个表项上?于是我打印了两次加载的表项,惊奇地发现:

刷新后,Recyclerview某些位置的表项,复用了之前后一个位置的表项。

也就是是说,刷新后的“暴龙兽”,其实是之前的“那撸多”换了名字(setText)而已,所以并不是它错误的加载了图片,而是它本来就是如此。

notify.png

带着这些疑惑,我开始翻看源码。

RecyclerView复用逻辑分析

既然是数据更新时出了问题,那么久从notifyDataSetChanged()onBindViewHolder两个方面来看

1. notifyDataSetChanged()

调用notifyDataSetChanged(),实质是让被观察者mObservable去通知观察者数据已经发生了变化,从而更新。mObservable(AdapterDataObservable)继承自android.database.Observable,其中有一个观察者数组,通过registerObserver()方法去注册添加观察者Observer,最终调用的是Observer的onChanged()方法

public final void notifyDataSetChanged() {
    //被观察者通知数据发生改变
    mObservable.notifyChanged();
}

//AdapterDataObservable继承了Observable被观察者,然后通过registerObserver方法注册观察者
static class AdapterDataObservable extends Observable<AdapterDataObserver> 

//观察者
private class RecyclerViewDataObserver extends AdapterDataObserver {
        @Override
        public void onChanged() {
            assertNotInLayoutOrScroll(null);
            mState.mStructureChanged = true;

            processDataSetCompletelyChanged(true);
            if (!mAdapterHelper.hasPendingUpdates()) {
                requestLayout();
            }
        }
}

继续看观察者的onChanged()方法,发现processDataSetCompletelyChanged()方法

    void processDataSetCompletelyChanged(boolean dispatchItemsChanged) {
        //从字面意思上是将已知的view都标记为失效,接下来往里看
        markKnownViewsInvalid();
    }
    
    void markKnownViewsInvalid() {
        final int childCount = mChildHelper.getUnfilteredChildCount();
        for (int i = 0; i < childCount; i++) {
            final ViewHolder holder = getChildViewHolderInt(mChildHelper.getUnfilteredChildAt(i));
            if (holder != null && !holder.shouldIgnore()) {
                //这里给viewHolder设置flag,为UPDATE和INVALID,后面有用
                holder.addFlags(ViewHolder.FLAG_UPDATE | ViewHolder.FLAG_INVALID);
            }
        }
        markItemDecorInsetsDirty();
        mRecycler.markKnownViewsInvalid();
    }
    
    void markKnownViewsInvalid() {
            ……
            if (mAdapter == null || !mAdapter.hasStableIds()) {
                //这里满足条件的话,cachedViews会被全部回收,不过我这里没有离屏缓存,就可以先不往下看了
                recycleAndClearCachedViews();
            }
    }
    
    //中间这些就只列出关键代码和它们的作用
    recycleCachedViewAt(i);//倒序遍历mCachedViews,开始回收
    addViewHolderToRecycledViewPool(viewHolder, true);//获取到ViewHolder,准备加入RecycledViewPool
    getRecycledViewPool().putRecycledView(holder);//放入Pool中

这里主要就是给viewHolder设置标识位,以及回收cachedViews

接下来再看onBindViewHolder

2. onBindViewHolder

调用链

onBindViewHolder的调用的地方,一路往上找,方法的调用链如下:

LinearLayoutManager ->

onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state)
    ∟ fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable)
        ∟ layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state, LayoutState layoutState, LayoutChunkResult result)
            ∟ next(RecyclerView.Recycler recycler)

RecyclerView ->

    ∟ getViewForPosition(int position)
        ∟ getViewForPosition(int position, boolean dryRun)
            ∟ tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs)
                ∟ tryBindViewHolderByDeadline(@NonNull ViewHolder holder, int offsetPosition, int position, long deadlineNs)
                    ∟ bindViewHolder(@NonNull VH holder, int position)
                        ∟ onBindViewHolder(@NonNull VH holder, int position, @NonNull List<Object> payloads)

最终找到了开始的地方onLayoutChildren,那么来看一下它的关键代码

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    ……
    detachAndScrapAttachedViews(recycler);
    ……
    fill(recycler, mLayoutState, state, false);
}

分离、废弃和回收view

可以看到在填充前,还调用了detachAndScrapAttachedViews

public void detachAndScrapAttachedViews(@NonNull Recycler recycler) {
    final int childCount = getChildCount();
    //遍历出hideView外所有的view,注意这里是倒序
    for (int i = childCount - 1; i >= 0; i--) {
        final View v = getChildAt(i);
        scrapOrRecycleView(recycler, i, v);
    }
}

private void scrapOrRecycleView(Recycler recycler, int index, View view) {
    final ViewHolder viewHolder = getChildViewHolderInt(view);
    //之前已经给viewHolder设置了invalid的flag标志位,条件满足
    if (viewHolder.isInvalid() && !viewHolder.isRemoved()
                    && !mRecyclerView.mAdapter.hasStableIds()) {
        //移除
        removeViewAt(index);
        recycler.recycleViewHolderInternal(viewHolder);
    } else {
        ……
    }
}

void recycleViewHolderInternal(ViewHolder holder) {
    ……
    boolean cached = false;
    //viewholder的flag有update和invalid,不满足条件,所以cached不改变,还是false
    if (mViewCacheMax > 0
                        && !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID
                        | ViewHolder.FLAG_REMOVED
                        | ViewHolder.FLAG_UPDATE
                        | ViewHolder.FLAG_ADAPTER_POSITION_UNKNOWN)) {
         cached = true;
    }
    if (!cached) {
        //获取到的ViewHolder,准备加入RecycledViewPool
        addViewHolderToRecycledViewPool(holder, true);
        recycled = true;
    }
}

从代码来看,detachAndScrapAttachedViews是先从mChildHelper中倒序遍历出所有的view,这个ChildHelper是管理LayoutManager和RecyclerView子项的帮助类,然后通过view获取viewholder,接下来判断这个viewHolder是否失效、是否已被移除、是否有设置了id等等条件。上面说过在调用notifyDataSetChange时给viewholder设置了FLAG_INVALID|FLAG_UPDATE标识位,所以最终viewHolder只被回收到了RecycleredViewPool中。

回收到pool中

void addViewHolderToRecycledViewPool(@NonNull ViewHolder holder, boolean dispatchRecycled) {
    //放入Pool中
    getRecycledViewPool().putRecycledView(holder);
}

//关键点来了,分析在下面
public void putRecycledView(ViewHolder scrap) {
    final int viewType = scrap.getItemViewType();
    final ArrayList<ViewHolder> scrapHeap = getScrapDataForType(viewType).mScrapHeap;
    if (mScrap.get(viewType).mMaxScrap <= scrapHeap.size()) {
        return;
    }
    if (DEBUG && scrapHeap.contains(scrap)) {
        throw new IllegalArgumentException("this scrap item already exists");
    }
    scrap.resetInternal();
    scrapHeap.add(scrap);
}

从源码来看,putRecycledView中先获取viewholder的类型,然后根据类型viewTypemScrap中获取对应的集合,如果这个集合中已存在的元素数量小于它的最大值mMaxScrap(默认是5,可以手动设置),那么就将要回收的viewHolder添加到里面去,其实也就是说每个类型最多会回收mMaxScrap个viewHolder。

别忘了前面在detachAndScrapAttachedViews里可是倒序遍历view的,所以这里也是倒序添加的。而我的代码中最初有6个view,且没有重设最大值,那么此时只回收了6,5,4,3,2共5个view的viewholder,剩下没有被回收的1,就被无情地丢弃掉了。

填充

看完了废弃和回收,接下来看看填充方法fill()

int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) {
    //剩余空间
    int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
    LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
    //只要有剩余空间,就要继续循环填充
    while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
        layoutChunk(recycler, state, layoutState, layoutChunkResult);
    }
}

void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state, LayoutState layoutState, LayoutChunkResult result) {
    View view = layoutState.next(recycler);
    ……
    addView(view);
}

View next(RecyclerView.Recycler recycler) {
    ……
    final View view = recycler.getViewForPosition(mCurrentPosition); 
    ……
    return view;
}

public View getViewForPosition(int position)
View getViewForPosition(int position, boolean dryRun)
ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs)

根据上面列出的调用链,可以一路找到tryGetViewHolderForPositionByDeadline,这里是真正决定复用或者生成ViewHolder的地方。

方法很长,先看注释(笑)。我从方法的注释中了解到,这是一个试图根据指定位置获取ViewHolder的方法,会尝试从scrap, 或者cache,亦或RecycledViewPool中获取,实在没有就直接创建一个。

Attempts to get the ViewHolder for the given position, either from the Recycler scrap, cache, the RecycledViewPool, or creating it directly.

ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs) {
            boolean fromScrapOrHiddenOrCache = false;
            ViewHolder holder = null;
            // 0) If there is a changed scrap, try to find from there
            // 0) 先从changed Scrap中获取viewholder
            if (mState.isPreLayout()) {
                holder = getChangedScrapViewForPosition(position);
                fromScrapOrHiddenOrCache = holder != null;
            }
            // 1) Find by position from scrap/hidden list/cache
            // 1) 根据位置position从scrap/hidden list/cache这几个地方获取viewholder
            if (holder == null) {
                holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
                ……
            }
            if (holder == null) {
                final int type = mAdapter.getItemViewType(offsetPosition);
                // 2) Find from scrap/cache via stable ids, if exists
                // 2) 根据id查找scrap/cache
                if (mAdapter.hasStableIds()) {
                    holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition), type, dryRun);
                    ……
                }
                if (holder == null && mViewCacheExtension != null) {
                    //这是从开发人员自定义的缓存中取
                    final View view = mViewCacheExtension.getViewForPositionAndType(this, position, type);
                    ……
                }
                if (holder == null) { // fallback to pool
                    //从缓存池中取
                    holder = getRecycledViewPool().getRecycledView(type);
                    ……
                }
                if (holder == null) {
                    ……
                    //直接创建
                    holder = mAdapter.createViewHolder(RecyclerView.this, type);
                    ……
                }
            }
            ……
            return holder;
        }

从代码来看,获取ViewHolder其实经过了几个步骤:直接从changed scrap中查找;通过position或id查找scrap、hidden list、cache;查找自定义缓存;查找缓冲池;直接创建。任意一个步骤获取到了viewholder,就直接返回,否则继续进行下一步直至创建为止。

对于我的代码来说,既没有设置id或自定义缓存,又因为notifyDataSetChange使一些缓存失效,最后能用的只有RecyclerViewPool。前面也分析过,1个被抛弃,只缓存了5个,因此还要再新创建两个,最终导致了开头的结果。惭愧,惭愧~(掩面)。

第一次写这种分析文章,不当之处,请大家不吝赐教,谢谢