上周四去分享销客面试,整个过程面试官就问了一个问题:listview;其中涉及获得 listview 中的父控件中的子控件、listview 的优化(卡顿、层级)、listview 的性能提升以及查看 listview 性能的工具;坦白来讲,对于最后一个问题,我表示面试官很坑啊,listview 的监测工具 ??? 大写的什么鬼? 你怎么不直接问我检测内存的工具呢?我表示很懵逼啊;我表示不服,必须重新撸一遍 listview 的源码,以解我心头之恨。
Adapter 的作用
我们都知道 listview 中是没有数据的,所有的数据都是通过 adapter 这个接口来获得的,它可以实现各种各样的子类,每个子类都会通过自己的逻辑来适配不同的数据源;
RecycleBin 机制
它是 AbsListView 的内部类,ListView 和 GridView 都继承自 AbsListView类;RecycleBin 保证了 listview 加载上千条 item 都不会出现 OOM 的情况 ;源码如下:
|
|
在 RecycleBin 中有两个数组:mActiveViews 和 mScrapViews; mActiveViews 表示 listview 首屏展示的元素集合,mScrapViews 表示屏幕外被废弃的元素集合,它是无序的;这里面有几个非常重要的方法,我们现在逐一看看:
- fillActiveViews() 这个方法接收两个参数,第一个参数是要存储的 view 的数量,第二个参数是 listview 中第一个可见元素的 position;RecycleBin 中使用 mActiveViews 这个数组来存储 view;调用这个方法是将 AbsListView 中所有的子元素全部添加到 mActiveViews 中。
- getActiveView() 用于从mActiveViews 中获取元素。传入的 position 参数表示元素在 listview 中的位置,方法内部会自动转化成mActiveViews 数组中的下标值。 有一点需要注意:mActiveViews 中的 view 一旦被获取之后就会从 mActiveViews 中移除,下次在获取同样位置的 view 将会返回 null;也就是说 mActiveViews 不能被重复利用。
- addScrapView() 用于将一个废弃的 View 进行缓存;该方法接收两个参数,第一个参数表示要回收的 View ,第二个参数表示在父布局上的位置。RecycleBin 使用了 mScrapView 和 mCurrentScrap 两个 list 来存储废弃的 View。
- getScrapView() 用于从废弃缓存中取出一个 View ,该方法接收一个 position 参数,表示取出父布局上这个位置的 View;由于缓存中的 View 是无序的,因此这里面有一个判断,
- 如果只有一个元素,那就从 mCurrentScrap 中取;如果这个 position中的 View 还存在(ID /Position),那么就从 mCurrentScrap 中取出,如果不存在就取 mCurrentScrap 中的最后一个;
- 如果这个 position 小于 mScrapViews 的长度,那么就从 mScrapViews 中取,同样的,如果这个 position中的 View 还存在(ID /Position),那么就从 mScrapViews 中取出,如果不存在就取 mScrapViews 中的最后一个;
- setViewTypeCount() 作用是为每种类型的数据项单独启用一个 RecycleBin 缓存机制;
第一次 Layout
在 ListView 中我们并没有找到 onLayout() 方法,那一定是在父类中实现该方法,我们在AbsListView 中找到了这个方法,代码如下:
|
|
从这个方法的声明中我们知道,子类不需要重写这个方法,相应的子类应该重写 LayoutChildren() 方法;这也不难理解,因为子元素的布局应该由具体的实现类来完成,而不是父类来完成。
Listview 中的 LayoutChildren() 方法如下:
|
|
这段代码较长,我们挑重点看,首先可以确定,ListView 中没有任何子 View ,因此 getChildCount() 方法得到的值肯定是0,接着会根据 dataChanged 这个布尔值来判断执行逻辑,dataChanged 只有在数据源发生改变的时候才会变成 ture,其他情况下默认是 false,因此会调用 RecycleBin 的 fillActiveViews() 方法,调用 fillActiveViews() 方法的目的是将 ListView 中 的子 View 进行缓存,但是目前 ListView 此时并没有任何数据,因此这一行暂时不起作用。
接下来会根据 mLayoutMode 的值来决定布局模式,默认情况下都是普通模式 LAYOUT_NORMAL, 因此会进入 default 语句中。而下面会紧接着进行两次 if 判断,childCount 目前等于0,并且默认的布局顺序是从上而下的,因此会进入到 fillFromTop() 方法里,跟进去瞧一瞧:
|
|
这个方法的作用是从 mFirstPosition 开始,自顶至低取填充数据;从这个方法中,它只是简单的做了一个起始位置的判断并没有执行填充操作,因此我们有理由相信填充 ListView 的操作是在 fillDown() 方法中。跟进去瞧瞧:
|
|
可以看到,这里有一个 while 循环来执行重复逻辑,这里面有几个相关值需要说明一下:
- nextTop 表示第一个子元素顶部距离整个 ListView 顶部的像素值。
- pos 是传入的 mFirstPosition 值。
- end 是 ListView 底部减去顶部所得的像素值。
- mItemCount 是 Adapter 中的元素数量。
因此在开始的时候,nextTop 必定小于 end 并且 pos 小于 mItemCount值,没执行一次 while 循环,pos 的值自动加1,并且 nextTop 也会增加,当 nextTop 大约等于 end 时,也就是子元素超出了当前屏幕,或者 pos 大于等于 mItemCount 时候,也就是说 Adapter 中所有的元素都已遍历完成。之后就会结束 while 循环。
在 while 循环中,真正有意义的是 makeAndAddView() 方法,追进去看一下,代码如下:
|
|
在这个方法中首先尝试从 RecycleBin 中快速获取一个 activeView ,但是目前 RecycleBin 中还没有任何一个 View,所以这里得到的值肯定是null,那就跳出去继续执行下面语句,接下来调用 obtainView() 方法来重新获取一个 View ,这个方法会返回一个 View ,并将这个 View 传入 setupChild() 方法中;下面我们追到 obtainView() 方法中去看看,代码如下:
|
|
obtainView() 方法是整个 ListView 中的最重要的内容,首先调用 RecycleBin 的 getScrapView() 方法来获取一个废弃缓存的 View,因为 RecycleBin 中没有 View ,因此返回值为null,接着代码会执行 mAdapter 的 getView() 方法,是不是很熟悉,没错就是我们经常重写的方法,传入的三个参数分别为 position、 null 、this。
getView() 方法接收三个参数,第一个参数 position 代表当前子元素的位置,第二个元素是 convertView,由于我们传入的是null,因此 convertView 并没有用,我们会调用 LayoutInflater 的 inflate() 方法来加载一个布局,接下来会对这个 View 进行一下属性和值的设定,最后将 View 返回。
因此,这个 View 作为 ObtainView() 的结果返回,并传入 setupChild() 中。也就是说,第一次 layout 过程中,所有的子 View 都是通过 LayoutInflater 来加载进来的,这样相对比较耗时,但后面就不会出现这个情况啦,我们继续往下看:
|
|
在这个方法中将 ObtainView() 方法获取的子元素 View ,通过调用 addViewInLayout() 方法将它添加到 ListView 当中,addViewInLayout() 方法属于 ViewGroup,作用是在 layout 中添加 View,代码如下:
|
|
那么根据 fillDown() 方法中的 while 循环,会让子元素将整个 Listview 填满。 也就是说无论我们的 Adatper 中有多少条数据,ListView 只会加载第一屏的数据,剩下的数据完全不会加载,所以不会有多余的加载工作,这样可以保证 ListView 中的内容可以迅速的展示在屏幕上。
到此为止,第一阶段的 onLayout 过程结束。
第二次 Layout
我们还是从 LayoutChildren() 方法开始:
|
|
同样的调用 getChildCount() 方法来获取子 View 的数量,此时得到的值已经不是0了,而是一屏的 Listview 中子 view 的数量;调用 RecycleBin 的fillActiveViews() 方法,会将所有的子 view 都缓存到 RecycleBin 的 mActiveViews 数组中,后面将会用到。
接下来一个很重要的操作,调用 detachAllViewsFromParent() 方法,这个方法的作用是将 ListView 中所有的子 View 全部清除掉,从而保证第二次 layout 过程不会产生重复的数据。以后所有的 View 都会从 RecycleBin 的 mActiveViews 中获取。
接下来会进入到 mLayoutMode 的switch 判断中,走 default 情况,此时 childCount 不为0,会走到 else 里,里面有三个逻辑判断,第一个不成立,因为默认情况下我们没有选择任何子元素,mSeletedPosition 应该等于-1;第二个逻辑判断成立,那么进入到fillSpecific()方法当中,代码如下所示:
|
|
fillSpecific 主要作用是 优先将指定位置的子 View 先加载到屏幕上,然后再加载该子 view 往上以及往下的其他子 View。在这个方法中再次调用了 makeAndAddView() 方法,如下:
|
|
仍然是调用 RecycleBin 中的 Active View,这次active View 不为 null,因为前面我们调用了 RecycleBin 的 fillActiveViews() 方法来缓存子View。那么既然如此,就不会再进入到 obtainView() 方法,而是会直接进入 setupChild() 方法当中,这样也省去了很多时间,因为如果在 obtainView() 方法中又要去 infalte 布局的话,那么 ListView 的初始加载效率就大大降低了。
注意,在 setupChild() 方法中最后一个参数传入是 true,表明当前 View 是之前被回收过的,代码如下:
|
|
可以看到 ,setupChild() 方法的最后一个参数是 isAttachedToWindow,在接下来的代码中,对这个变量进行判断,会调用 attchViewToParent()方法,我们第一次调用的方法是 addViewInLayout()。这个两个方法的最大区别在于:
- 如果向 ViewGroup 中添加一个新的子 View ,应该调用 addViewInLayout() 方法;
- 如果想要将一个之前的 detach 的View 重新 attach 到 ViewGroup 上,应该调用 attachViewToParent() 方法。
前面在 layoutChildren() 方法当中调用了 detachAllViewsFromParent() 方法,这样ListView中所有的子 View 都是处于 detach 状态的,所以这里 attachViewToParent() 方法是正确的选择。
到此为止,第二次 Layout 结束。
滑动加载更多数据
由于滑动机制属于通用的,我们去 AbsListView 的 onTouchEvent() 方法中去一探究竟,代码如下:
|
|
|
|
手指在屏幕上滑动时,TouchMode 是 TOUCH_HOME_SCROLL 这个值,因此继续寻找,进入 scrollIfNeeded() 方法,代码如下:
|
|
调用 trackMotionScroll() 方法,只要我们的手指在屏幕上稍微有一点点移动,这个方法就会被调用多次,代码如下:
|
|
这个方法接收两个参数,deltaY 表示从手指按下时的位置到当前手指位置的距离,incrementalDeltaY 表示据上次触发 event 事件手指在 Y 方向位置的改变量,这个值的正负表示用户是向上或者向下滑动,如果 incrementalDeltaY 小于0,说明是向下滑动,否者是向上滑动。
下面将会进行一个边界检测的过程,当 ListView 向下滑动的时候,就会进入一个 for 循环当中,从上而下依次获取子 View,如果子 View 的bottom 值已经小于 top 值,就说明这个子 View 已经移除屏幕,所以会调用 RecycleBin 的 addScrapView() 方法将这个 View 加入到废弃缓存当中,并将 count 计数器加1,计数器用于记录移出屏幕的子 View 的数量。那么如果是 ListView 向上滑动的话,其实过程是基本相同的,只不过变成了从下往上依次获取子 View ,然后判断该子 View 的 top 值是不是大于 bottom 值了,如果大于的话说明子 View 已经移出了屏幕,同样把它加入到废弃缓存中,并将计数器加1。
接下来根据当前计数器的值来调用 detachViewsFromParent() 方法,它的作用是把所有的移除屏幕的子 View 全部 detach 掉。紧接着调用了 offsetChildrenTopAndBottom() 方法,并将 incrementalDeltaY 作为参数,它的作用是让 ListView 中所有的子 View 都按照传入的参数值进行相应的偏移,这样就实现啦随着手指的移动,ListView的内容也会随着滚动的效果。
接下来进行判断,如果 ListView 中最后一个 View 的底部已经移入屏幕,或者 ListView 中的第一个 View 的顶部移入了屏幕,就会调用 fillGap() 方法,它的作用是用来加载屏幕外的数据。代码如下:
|
|
这是一个抽象的方法,我们需要去 ListView 中去寻找,代码如下:
|
|
down 参数表示 ListView 是向下滑动还是向上滑动,如果向下滑动就调用 fillDown() 方法,如果向上滑动就调用 fillUp() 方法;我们太熟悉这两个方法,但是填充 ListView 是通过 调用 makeAndAddView() 方法来完成的,让我们仔细瞧瞧:
|
|
这里首先还是尝试调用 RecycleBin 的 getActiveView() 方法来获取子布局,只不过肯定是获取不到的了,因为在第二次 Layout 过程中我们已经从 mActiveViews 中获取过了数据,而根据 RecycleBin 的机制, mActiveViews 是不能够重复利用的,因此这里返回的值肯定是 null。
既然 getActiveView() 方法返回的值是 null,那么还是走到 obtainView() 方法中,代码如下:
|
|
这里会调用 RecycleBin 的 getScrapView() 方法来尝试从废弃缓存中获取一个 View,这个 View 是存在的,因为在 trackMotionScroll() 方法中,一旦有任何子 View 被移出屏幕,就会将它加入到废弃缓存中,而从obtainView() 方法中的逻辑来看,一旦有新的数据需要显示到屏幕上,就会尝试从废弃缓存中获取 View 。所以它们之间就形成了一个生产者和消费者的模式,那么 ListView 神奇的地方也就在这里体现出来了,不管你有任意多条数据需要显示,ListView 中的子 View 其实来来回回就那么几个,移出屏幕的子View会很快被移入屏幕的数据重新利用起来,因而不管我们加载多少数据都不会出现 OOM 的情况,甚至内存都不会有所增加。
这里获取的 scrapView,我们将它作为第二个参数放在了 Adapter 的 getView() 方法中,我们都知道 getView() 的第二个参数是 convertView,因此,第一次加载时,convertView 为 null ,需要调用 inflater 加载布局,不等于 null 就可以直接利用 convertView,因为 convertView 就是我们之间用过的 View,移出屏幕进入到废弃缓存中,现在重新拿出来使用。然后我们只需要把 convertView 中的数据更新成当前位置上应该显示的数据,那么看起来就好像是全新加载出来的一个布局一样,从缓存中拿到子View之后再调用 setupChild() 方法将它重新attach到ListView当中,因为缓存中的View也是之前从ListView中detach掉的,这部分代码就不再重复进行分析了。
到目前为止,我们终于把 ListView 的源码整个过程梳理了一遍,希望大家都能够得到帮助。
我在写这篇文章的时候,在想我们在用 BaseAdapter 的时候,为啥用 ViewHolder 呢?它到底有啥作用吗?最近在一次面试中,我想到这个问题:我们利用 ViewHolder 的目的就是减少 findViewById 的使用,减少查找次数。
还有一个问题:当我的 ListView 拥有不同类型的时候,当加载 item 时,前后两个 item 的类型不一致,那么由于 RecycleBin 机制,后面的 item 是否为空?个人的判断是 空。还没有验证。
ListView 还有很多可以考察的点啊,未完待续…
参考文章:
1.郭霖
[http://blog.csdn.net/guolin_blog/article/details/44996879]: