ListView复用机制(源码分析)

由于 Android学习笔记之ListView复用机制 这篇文章总结性语句通俗易懂。我又是以学习总结为目的,所以用了很多这篇文章的举例以及总结,如有侵权,请联系我删除,谢谢。

注意:本文中所有源码分析部分均基于 API25 版本,由于安卓系统源码改变很多,可能与之前版本有所不同。

下文提到的知识点

  • ListView复用机制(静态加载时和滑动时)
  • ViewHolder的理解
ListView复用机制

ListView复用机制包括了限制加载的item数量(一个屏幕以内),还有使用缓存的View(应该可以这么说,毕竟它是将废弃掉的View保存在另外一个集合里,等待二次使用)。那么首先了解一下复用的关键类RecycleBin。

RecycleBin是2级的存储结构,
ActiveViews: 当前屏幕上的活动View
ScrapViews: 废弃View,可复用的旧View
然后再看一下需要了解的成员变量

        // 第一个活动view的position,即第一个可视view的position 
        private int mFirstActivePosition;
        
        //活动view的集合
        private View[] mActiveViews = new View[0];

        //废弃的可修复view集合,复用时传递到Adapter#getView方法的convertView参数。
        //因为item type可能大于1,只有view type相同的view之间才能复用,所以是个二维数组
        private ArrayList<View>[] mScrapViews;

        //ListView item type数量  
        private int mViewTypeCount;

        //当前的废弃view数组,定义这个成员是为了在mViewTypeCount为1时使用方便,不需要去取mScrapViews的第一个元素  
        private ArrayList<View> mCurrentScrap;

        //被跳过的,不能复用的view集合。view type小于0或者处理transient状态的view不能被复用。  
        private ArrayList<View> mSkippedScrap;

从RecycleBin成员变量的定义基本可以看出复用的原理:
1.废弃的view保存在一个数组中,复用时从中取出
2.拥有相同view type的view之间才能复用,所以mScrapViews是个二维数组
3.处于transient状态的view不能被复用

简单总结一下RecycleBind的使用思路:
首先我们需要明确ActiveView的概念,ActivityView其实就是在UI屏幕上可见的视图(onScreenView),也是与用户进行交互的View,那么这些View会通过RecycleBin直接存储到mActivityViews数组当中,以便为了直接复用,那么当我们滑动ListView的时候,有些View被滑动到屏幕之外(offScreen) View,那么这些View就成为了ScrapView,也就是废弃的View,已经无法与用户进行交互了,这样在UI视图改变的时候就没有绘制这些无用视图的必要了。。他将会被RecycleBin存储到mScrapView数组当中,但是没有被销毁掉,目的是为了二次复用,也就是间接复用。当新的View需要显示的时候,先判断mActivityView中是否存在,如果存在那么我们就可以从mActivityViews数组当中直接取出复用,也就是直接复用,否则的话从mScrapViews数组当中进行判断,如果存在,那么二次复用当前的视图,如果不存在,那么就需要inflate View了。
ListView在第一次加载是会调用onLayout方法,但是我们发现ListView中根本没有重写onLayout方法,所以我们只能去它的父类AbsListView。

2162        layoutChildren();

发现AbsListView方法里有一个空方法layoutChildren(),这个就是用来区分ListVIew和GridView的方法,所以相应的布局也在这里面。

layoutChildren(){
...
switch (mLayoutMode) {
  ...
  default:
        if (childCount == 0) {
            //列表堆放是否从底部开始  这个应该是区分上下滑动的
            if (!mStackFromBottom) {
                final int position = lookForSelectablePosition(0, true);
                setSelectedPositionInt(position);
                sel = fillFromTop(childrenTop);
            } else {
                final int position = lookForSelectablePosition(mItemCount - 1, false);
                setSelectedPositionInt(position);
                sel = fillUp(mItemCount - 1, childrenBottom);
            }
        }
  ...
}
...
}

在这里mLayoutMode如果不去设置的话这个值为LAYOUT_NORMAL,但是这里的case没有这个值所以会走到default这里,在我们第一次加载时childCount(ListView里边的item个数)肯定是为0的,并且mStackFromBottom(列表堆放是否从底部开始)为false,它就会进入一个fillFromTop()的方法里,这个方法有会进入fillDown()。

    private View fillDown(int pos, int nextTop) {
        //pos:列表中的一个绘制的Item在Adapter数据源中对应的位置
        //nextTop:表示当前绘制的Item在ListView中的实际位置..
        View selectedView = null;

        int end = (mBottom - mTop);
        if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
            end -= mListPadding.bottom;
        }
           //end用来判断Item是否已经将ListView填充满
        while (nextTop < end && pos < mItemCount) {
            // is this the selected item?
            boolean selected = pos == mSelectedPosition;
            View child = makeAndAddView(pos, nextTop, true, mListPadding.left, selected);
 
            nextTop = child.getBottom() + mDividerHeight;
            if (selected) {
                selectedView = child;
            }
            pos++;
        }

        setVisibleRangeHint(mFirstPosition, mFirstPosition + getChildCount() - 1);
        return selectedView;
    }
  • 在while循环中添加子View,我们先不看while循环的具体条件,先看一下循环体。在循环体中,将pos和nextTop传递给makeAndAddView方法,该方法返回一个View作为child,该方法会创建View,并把该View作为child添加到ListView的children数组中。

  • 然后执行nextTop = child.getBottom() + mDividerHeight,child的bottom值表示的是该child的底部到ListView顶部的距离,将该child的bottom作为下一个child的top,也就是说nextTop一直保存着下一个child的top值。

  • 最后调用pos++实现position指针下移。现在我们回过头来看一下while循环的条件while (nextTop < end && pos < mItemCount)。

  • nextTop < end确保了我们只要将新增的子View能够覆盖ListView的界面就可以了,比如ListView的高度最多显示10个子View,我们没必要向ListView中加入11个子View。

  • pos < mItemCount确保了我们新增的子View在Adapter中都有对应的数据源item,比如ListView的高度最多显示10个子View,但是我们Adapter中一共才有5条数据,这种情况下只能向ListView中加入5个子View,从而不能填充满ListView的全部高度。

这里存在一个关键方法,也就是makeAndAddView()方法,这是ListView将Item显示出来的核心部分,也是这个部分涉及到了ListView的复用。

    private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
            boolean selected) {
        //判断数据源是否发生了变化.
        if (!mDataChanged) {
            // Try to use an existing view for this position.
             //如果mActivityViews数组中存在可以直接复用的View,那么直接获取,然后重新布局.
            final View activeView = mRecycler.getActiveView(position);
            if (activeView != null) {
                // Found it. We're reusing an existing child, so it just needs
                // to be positioned like a scrap view.
                setupChild(activeView, position, y, flow, childrenLeft, selected, true);
                return activeView;
            }
        }
        //如果mActivityViews数组中没有可用的View,就会执行到obtainView()
        // Make a new view for this position, or convert an unused view if
        // possible.
        final View child = obtainView(position, mIsScrap);

        // This needs to be positioned and measured.
        setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]);

        return child;
    }

setupChild()方法的作用是根据最后一个参数来判断的,最后一个参数是判断是否有复用的View,然后在添加View的时候调用不同的方法来优化显示过程,由于我们在自定义View的时候总会调用两次onLayout方法,所以这里两个setupChild()方法分别会在第一次onLayout和第二次执行。详细请看最后推荐中郭神的文章。
obtainView方法在AbsListView中

        ...
        //获取mScrapViews中的view,如果有则返回复用的view,没有则返回null,所以这里的scrapView是可能为null的,而这里的scrapView就是我们adapter中getView方法里的convertView。
        final View scrapView = mRecycler.getScrapView(position);
        final View child = mAdapter.getView(position, scrapView, this);
        ...

这里就是从mScrapViews获取复用的view,如果有则返回复用的view,没有则返回null,所以这里的scrapView是可能为null的,而这里的scrapView就是adapter中getView方法里的convertView参数。点击getView()看一下。

    View getView(int position, View convertView, ViewGroup parent);

多么熟悉的方法。因为scrapView可能为空,所以我们的adapter中应该这样写

@Override
public View getView(int position, View convertView, ViewGroup parent) {
    if(convertView == null){
        convertView = View.inflate(context, R.layout.list_item_layout, null);
    }
    return convertView;
}

现在了解的ListView在静态的时候如何实现复用,接下来看以下ListView是如何实现滑动和在滑动时实现复用的。
首先看一下滑动,滑动是调用onTouchEvent()方法进行处理,发现ListView并没有这个方法,所以还是去AbsListView中寻找,并且找到Move事件,move的时候调用了一个方法onTouchMove(),在这个方法中有一个switch(mTouchMode)这个在滑动的时候一般mTouchMode是等于TOUCH_MODE_SCROLL的,直接进入这个分支下的scrollIfNeeded()方法。

    private void scrollIfNeeded(int x, int y, MotionEvent vtev) {
    ...
     //这个变量是判断手指滑动距离,有正负之分判断上滑还是下滑
     //y 是当前手指的Y值  mLastY是手指落下去时的Y值
3521     int incrementalDeltaY = mLastY != Integer.MIN_VALUE ? y - mLastY + scrollConsumedCorrection : deltaY;
    ...
3678     trackMotionScroll(incrementalDeltaY, incrementalDeltaY);
    ...
    }

正常的在屏幕上滑动都会调用 3678行这个方法,那就看看这个方法做了什么

        final boolean down = incrementalDeltaY < 0;
        int start = 0;
        int count = 0;

        if (down) {
            //向上滑动 因为源码中incrementalDeltaY=现在的Y值-之前手指落下的Y值 
            //屏幕Y值从上到下逐渐变大   所以这里为向上滑动
            int top = -incrementalDeltaY;
            if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
                top += listPadding.top;
            }
            //这里用for循环是为了防止快速的滑动 一次性画出多个View
            for (int i = 0; i < childCount; i++) {
                final View child = getChildAt(i);
                //边界检验 判断屏幕中的child是否被完全滑出去了
                //如果child的底部 >= 屏幕顶部 证明还没有滑出去 不做处理 , 这里再次证明 这里是向上滑动  因为很多博客在这都写错了 所以重点强调一下
                if (child.getBottom() >= top) {
                    break;
                } else {
                    count++;
                    int position = firstPosition + i;
                    if (position >= headerViewsCount && position < footerViewsStart) {
                        // The view will be rebound to new data, clear any
                        // system-managed transient state.
                        child.clearAccessibilityFocus();
                        //添加复用的VIew 根据ItemType来添加
                        mRecycler.addScrapView(child, position);
                    }
                }
            }
        } else {
            int bottom = getHeight() - incrementalDeltaY;
            if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
                bottom -= listPadding.bottom;
            }
            for (int i = childCount - 1; i >= 0; i--) {
                final View child = getChildAt(i);
                if (child.getTop() <= bottom) {
                    break;
                } else {
                    start = i;
                    count++;
                    int position = firstPosition + i;
                    if (position >= headerViewsCount && position < footerViewsStart) {
                        // The view will be rebound to new data, clear any
                        // system-managed transient state.
                        child.clearAccessibilityFocus();
                        mRecycler.addScrapView(child, position);
                    }
                }
            }
        }
        //当count>0的时候证明有子View被完全滑出了屏幕 所以这里要把所有移出屏幕的子View全部detach掉
        if (count > 0) {
            detachViewsFromParent(start, count);
            mRecycler.removeSkippedScrap();
        }
        //这个方法的作用是让ListView中所有的子View都按照传入的参数值进行相应的偏移,这样就实现了随着手指的拖动,ListView的内容也会随着滚动的效果。
        offsetChildrenTopAndBottom(incrementalDeltaY);
        //如果是向上滑动第一个item的pos应该增加
        if (down) {
            mFirstPosition += count;
        }
        //如果ListView中最后一个View的底部已经移入了屏幕,或者ListView中第一个View的顶部移入了屏幕
        if (spaceAbove < absIncrementalDeltaY || spaceBelow < absIncrementalDeltaY) {
            fillGap(down);
        }

这里只贴出了一些重要的代码,首先她会判读incrementalDeltaY是否小于0,如果小于0,down为true那么就是手指向上滑动,否则相反。向上滑动时会用一个for循环判断有多少个子View被滑出屏幕,让后将滑出的子VIew根据ItemType添加到废弃的View数组中。添加完后还需要调用detachViewsFromParent()把滑出去的View与当前的ListView分离。最后调用fillGap()处理新添加的View。但是这是个抽象的方法所以到ListView中找到它的实现方法

    @Override
    void fillGap(boolean down) {
        final int count = getChildCount();
        if (down) {
            int paddingTop = 0;
            if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
                paddingTop = getListPaddingTop();
            }
            final int startOffset = count > 0 ? getChildAt(count - 1).getBottom() + mDividerHeight :
                    paddingTop;
            //从上往下填充,从第mFirstPosition + count条开始,
            //count是ViewGroup里的子View的个数,由于在滑动时已经将滑出的View移除,count也做了相应的处理,所以这里就是剩下的View的个数
            //所以这里就是从屏幕可见第一个item的pos+之前剩下View的数量之和的position开始,填满屏幕
            fillDown(mFirstPosition + count, startOffset);
            correctTooHigh(getChildCount());
        } else {
            int paddingBottom = 0;
            if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
                paddingBottom = getListPaddingBottom();
            }
            final int startOffset = count > 0 ? getChildAt(0).getTop() - mDividerHeight :
                    getHeight() - paddingBottom;
            //从下往上填充,从之前计算的屏幕第一个item的pos-1开始填满屏幕
            fillUp(mFirstPosition - 1, startOffset);
            correctTooLow(getChildCount());
        }
    }

这个方法通过判断滑动方向对应的调用fillDown或fillUp,这里解释一下第一个参数,重点也是在这.。
fillDown是在上滑的时候调用,并且是从上往下填充View。举个栗子,当屏幕中有10个子VIew的时候(所以屏幕中View的position为0~9),count为10(不考虑新View出现半个的情况,这里只是简单解释),mFirstPosition默认为0,当上滑完全移出2个View的时候,trackMotionScroll()方法中,使用ViewGroup对View做了分离操作count变为8,在分离后进行判断,如果是向下滑动mFirstPosition+滑出去View的个数,所以mFirstPosition变为2,所以这里第一个参数传入8+2=10,从position=10的View开始填充,知道屏幕填满,填满之后count恢复为10(这里个人认为是要从新计算的,但一直找不到源码在哪,希望大神来指点),mFirstPosition还是为2。这样也就好理解继续向下滑的情况了mFirstPosition只加不减,count每回都一样。
fillUp是在下滑的时候调用,是从下往上填充View,根据刚才的栗子继续,我相信fillUp也会理解的。

后面的思路就和之前一样了,就是在makeAndAddView方法中添加View,是复用呢还是新建呢~

总结一下

从makeAndAddView方法开始,首先判断数据源有没有变化,如果没有变化,会从mActivityView数组中判断是否存在可以直接复用的View。
解释一下这里的直接复用,举个例子,比如说我们ListView一页可以显示10条数据,那么我们在这个时候滑动一个Item的距离,也就是说把position = 0的Item移除屏幕,将position = 10 的Item移入屏幕,那么position = 1~9的Item是直接能够从mActivityView数组中拿到的,因为我们在第一次加载Item数据的时候,已经将position = 0~9的Item加入到了mActivityView数组当中,那么在第二次加载的时候,由于position = 1~9 的Item还是ActivityView,那么这里就可以直接从数组中获取,然后重新布局。这里也就表示的是Item的直接复用。
如果我们在mActivityViews数组中获取不到position对应的View,那么就尝试从mScrapViews废弃View数组中尝试去获取,还拿刚才的例子来说当position = 0的Item被移除屏幕的时候,首先会Detach让View和视图进行分离,清空children,然后将废弃View添加到mScrapViews数组当中,当加载position = 10的Item时,mActivityViews数组肯定是没有的,也就无法获取到,同样mScrapViews中也是不存在postion = 10与之对应的废弃View,说白了就是mScrapView数组只有mScrapViews[0]这一项数据,肯定是没有mScrapViews[10]这项数据的,那么我们就会这样想,肯定是从Adapter中的getView方法获取新的数据喽,其实并不是这样,虽然mScrapView中虽然没有与之对应的废弃View,但是会返回最后一个缓存的View传递给convertview。那么也就是将mScrapViews[0]对应的View返回(当然在获取缓存View的时候她会判断ItemType,对应源码obtainView()方法中的getScrapView()方法,然后返回相同type的VIew)。
写这么多字都没个图,就来个最常见的把~

VLG9g.jpg

注意一种情况:比如说还是一页的Item,但是position = 0的Item没有完全滑动出UI,position = 10的Item没有完全进入到UI的时候,那么position = 0的Item不会被detach掉,同样不会被加入到废弃View数组,这时mScrapView是空的,没有任何数据,那么position = 10的Item即无法从mActivityView中直接复用View,因为是第一次加载。mActivityView[10]是不存在的,同时mScrapView是空的,因此position = 10的Item只能重新生成View,也就是从getView方法中inflate。

ViewHolder的理解

反正我在使用ViewHolder时候一直以为是ViewHolder的使用才能是ListView的item可以复用,其实不是,虽然ViewHolder也是在复用的时候进行一些操作,但是和复用机制是没太大关系的,他的主要目的是持有Item中控件的引用,从而减少findViewById()的次数,因为findViewById()方法也是会影响效率的,因此在复用的时候他起的作用是这个,减少方法执行次数增加效率。

虽然这篇文章用了很多别的大神的总结性语句,但也有很多我自己的理解和思路,可想而知我还是个很小的菜鸟,如果其中有错误还请指出,我会尽快修改文章,并改正自己的理解,谢谢。

最后推荐

Android ListView工作原理完全解析,带你从源码的角度彻底理解
Android学习笔记之ListView复用机制

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,723评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,080评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,604评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,440评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,431评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,499评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,893评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,541评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,751评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,547评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,619评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,320评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,890评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,896评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,137评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,796评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,335评论 2 342

推荐阅读更多精彩内容