前言
ListView——列表,它作为一个非常重要的显示方式,不管是在Web中还是在移动平台中,都是一个非常好的、不可或缺的展示信息的工具。在android中,ListView控件接管了这一重担,在大量的场合下,我们都需要使用这个控件。虽然在5.X时代,RecyclerView在很多地方逐渐取代了ListView,但ListView的使用范围还是非常广泛。
ListView显示需要三要素:
- ListView是用来展示列表的View。
- 适配器Adapter是用来把数据映射到ListView上的中介。
- 数据是具体的将要被映射的字符串、图片或者基本组件。
ListView还有一个神奇的功能,相信大多数人都体验过,即使在ListView中加载非常多的数据,ListView都不会发生OOM或者奔溃,而且随着我们手指滑动来浏览更多数据时,程序所占用的内存竟然都不会跟着增长,这里涉及到ListView的视图缓存机制。
ListView的继承结构如下。
可以看到,ListView的继承结构还是相当复杂的,它是直接继承自AbsListView,而AbsListView有两个子实现类,一个是ListView,另一个就是GridView,因此从这一点可以猜出ListView和GridView在工作原理和实现上都是有很多共同点的。然后AbsListView又继承自AdapterView,AdapterView继承自ViewGroup,最后再到View和Object。
Adapter的作用
归根结底,一个View控件就是为了交互和展示数据用的,只不过ListView更加特殊,它是为了展示很多数据用的,但是ListView只承担交互和展示工作而已,至于这些数据来自哪里,ListView并不关心。因此,我们能设想到的最基本的ListView工作模式就是要有一个ListView控件和一个数据源。
不过如果真的让ListView和数据源直接打交道的话,那ListView所要做的适配工作就非常复杂了。因为数据源这个概念太模糊了,我们只知道它包含了很多数据而已,至于这个数据源到底是什么类型,并没有严格的定义,有可能是数组或集合,甚至有可能是数据库表中查询出来的游标。所以说如果让ListView为每一种数据源都进行适配操作的话,一是扩展性会比较差,内置了几种适配就只有几种适配,不能动态进行添加,二是超出了它本身应该负责的工作范围,不再是仅仅承担交互和展示工作就可以了,这样ListView就会变得很臃肿。
于是就有了Adapter这样一个机制的出现。Adapter在ListView和数据源之间起到了一个桥梁的作用,ListView并不会直接和数据源打交道,而是会借助Adapter这个桥梁来去访问真正的数据源,与之前接口不同的是,Adapter的接口都是统一的,因此ListView不用再去担心任何适配方面的问题。而Adapter又是一个接口,它可以去实现各种各样的子类,每个子类都能通过自己的逻辑去完成特定的功能,以及与特定数据源的适配操作,比如ArrayAdapter可以用于数组和List类型的数据源适配,SimpleCursorAdapter可以用于游标类型的数据源适配,这样就非常巧妙地把数据源适配困难的问题解决掉了,并且还拥有相当不错的扩展性。简单的原理示意图如下所示。
当然Adapter的作用不仅仅只有数据源适配这一点,还有一个非常重要的方法也需要我们在Adapter当中去重写,就是getView()方法。
RecyclerBin机制
在开始分析ListView源码之前,有一个东西是我们需要提前了解的,就是RecyclerBin机制,这个机制也是ListView能够实现成百上千条数据都不会OOM最重要的一个原因。RecyclerBin的代码并不多,它是写在AbsListView中的一个内部类。先来看一下RecyclerBin中的主要代码实现。
|
|
接下来先简单解读一下RecycleBin最主要的几个方法。
- fillActiveViews(int childCount, int firstActivePosition)这个方法接收两个参数,第一个参数标识要存储的view的数量,第二个参数标识ListView中第一个可见元素的position值。RecycleBin当中使用mActiveViews这个数组来存储View,调用这个方法后就会根据传入的参数来将ListView中指定的元素存储到mActiveViews数组中。
- View getActiveView(int position)这个方法和fillActiveViews()是对应的,用于从mActiveViews数组当中获取数据。该方法接收一个position参数,表示元素在ListView当中的位置,方法内部会自动将position值转换成mActiveViews数组对应的下标值。需要注意的是,mActiveViews当中所存储的View,一旦被获取了之后就会从mActiveViews当中移除,下次获取同样位置的View将会返回null,也就是说mActiveViews不能被重复利用。
- void addScrapView(View scrap, int position)用于将一个废弃的View进行缓存,该方法接收一个View参数,当有某个View确定要废弃掉的时候(比如滚动出了屏幕),就应该调用这个方法来对View进行缓存,RecycleBin当中使用mScrapViews和mCurrentScrap这两个List来存储废弃View。
- View getScrapView(int position)用于从废弃缓存中取出一个View,这些废弃缓存中的View是没有顺序可言的,因此getScrapView()方法中的算法也非常简单,就是直接从mCurrentScrap当中获取尾部的一个scrap view进行返回。
- void setViewTypeCount(int viewTypeCount)。我们都知道Adapter当中可以重写一个getViewTypeCount()来表示ListView中有几种类型的数据项,而setViewTypeCount()方法的作用就是为每种类型的数据项都单独启用一个RecycleBin缓存机制。实际上,getViewTypeCount()方法通常情况下使用的并不是很多,所以我们只要知道RecycleBin当中有这样一个功能就行了。
ListView的布局工作原理
ListView即使再特殊最终还是继承自View的,因此它的执行流程还会按照View的规则来执行。View的执行流程无非就分为3步,onMeasure()用于测量View的大小,onLayout()用于确定View的布局,onDraw()用于将View绘制到界面上。而在ListView当中,onMeasure()并没有什么特殊的地方,因为它终归是一个View,占用的空间最多并且通常也就是整个屏幕。onDraw()在listView当中也没有什么意义,因为ListView本身并不负责绘制,而是由ListView当中的子元素来进行绘制的。那么ListView大部分的神奇功能其实都是在onLayout()方法中进行的了,因此接下来主要分析这个方法的工作原理。
ListView的onLayout()方法是在父类AbsListView中实现的,源码实现如下。
|
|
可以看到,onLayout()方法中并没有做什么复杂的逻辑操作,主要就是一个判断,如果ListView的大小或位置发生了变化,那么changed变量就会变成true,此时会要求所有的子布局都强制进行重绘。再看到layoutChildren()这个方法,这个方法是用来进行子元素布局的,但是这个方法是个空方法,因为子元素的布局应该是由具体的实现类来负责完成的,而不是由父类来完成的。所以,进入ListView的layoutChildren()方法进一步分析。
|
|
ListView第一次layout过程中,ListView当中还没有任何子View,数据都还是由Adapter管理的,并没有展示到界面上,因此getChildCount()方法得到的值是0,接着会根据dataChanged这个布尔值来进行判断执行逻辑,dataChanged只有在数据源发生改变的情况下才会变成true,其他情况下都是false,因此,会进入RecycleBin的fillActiveViews()方法,这个方法调用是为了将ListView的子View进行缓存的,但是目前ListView中还没有任何子View,因此这一步暂时还起不了作用。
接下来会根据mLayoutMode的值来决定布局模式,默认情况下都是普通模式LAYOUT_NORMAL,因此会进入到default语句当中,紧接着进行2次if判断,childCount目前是0,并且默认的布局顺序是从上往下,因此会进入到fillFrimTop()方法。
|
|
这个方法所负责的主要任务就是从mFirstPosition开始,自顶至底去填充ListView。而这个方法本身没有什么逻辑,就是判断一下mFirstPosition值的合法性,然后调用fillDown()方法,因此,填充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当中去快速获取一个active view,不过目前RecycleBin当中还没有缓存任何View,所以这里得到的值是null。得到null后会继续向下运行,调用obtainView()方法来再次尝试获取一个View,这个方法是可以保证一定返回一个View的,于是下面立刻将获取到的View传入到了setupChild()方法当中。先来看看obtainView()方法的实现。
|
|
obtainView()方法在第一次layout的时候会调用RecycleBin的getScrapView()方法来尝试获取一个废弃缓存中的View,这里会返回null,于是紧接着会调用mAdapter的getView()方法来获取一个View。这里mAdapter就是与ListView关联的适配器,而getView()方法就是我们最为熟悉的在适配器里面的的重载方法。getView()方法接受的三个参数,第一个参数position代表当前子元素的位置,可以通过具体的位置来获取与其相关的数据,第二个参数convertView,刚才传入的是null,说明没有convertView可以利用,因此我们会调用LayoutInflater的inflate()方法去加载一个布局,接下来就是对这个view进行一些属性和值的设定,最后将view返回。
这个View也会作为obtainView()的结果进行返回,并最终传入到setupChild()方法当中。
|
|
setupChild()方法当中的代码虽然比较多,但是核心代码不多。刚才调用的obtainView()方法获取到的子元素View,最终会调用addViewInLayout()方法将它添加到ListView当中。
根据fillDown()方法中的while循环,会让子元素View将整个ListView控件填满后就跳出,也就是说即使Adapter中有一千多条数据,ListView也只会加载满第一屏的数据,剩下的数据目前在屏幕上看不到,所以不会去做多余的加载工作,这样就可以保证ListView中的内容能够迅速展示到屏幕上了。到此,ListView的第一次layout过程结束。
我们知道,View初始化时会measure三次,layout两次,draw一次,这是一个很小的细节,平时影响并不大,因为不管测量布局几次,都是执行相同的逻辑,所以并不予以过多关心。但是在ListView中情况就不一样了,因为这意味着layouChildren()过程会执行两次,而这个过程当涉及向ListView中添加子元素,如果相同的逻辑执行两遍的话,ListView中就会存在一份重复的数据了。因此ListView在layoutChildren()过程中做了第二次layout的逻辑处理,巧妙地解决了这个问题。下面开始分析第二次layout的过程。
ListView第二次layout过程中,调用getChildCount()方法来获取子View的数量,这一次得到的值不再为0,而是ListView中一屏可以显示的子View数量。紧接着调用RecycleBin的fillActiveViews()方法,因为目前ListView中已经有子View了,这样所有的子View都会被缓存到RecycleBin的mActiveViews数组当中。接着是一个非常重要的操作,即调用detachAllViewsFromParent()方法将所有ListView中的子View清除掉,从而保证第二次layout过程不会产生一份重复的数据,因为之前调用了RecycleBin的fillActiveViews()方法来缓存子View,所以第二次重新加载直接使用这些缓存好的View就可以了,并不会重新执行一次inflate过程,因此效率方面并不会有明显的影响。
第二次layout过程中,由于getChildCount()不为0,所以取代fillDown()或fillUp()方法的是fillSpecific()方法,这个方法与前两个的功能也是差不多的,主要区别在于,fillSpecial()方法会优先将指定位置的子View先加载到屏幕上,然后再加载该子View往上以及往下的其它子View。这里不再去关注太多细节,而是将精力放回makeAndAddView()方法来。makeAndAddView()方法仍然尝试从RecycleBin当中获取Active View,然而这次就可以取到了,所以就不用进入obtainView()方法,而是直接进入setupChild()方法当中,这样也会节省了很多时间,提高了效率。setupChild()方法也不再调用addViewInLayout()方法,而是改调用attachViewToParent()方法将在前面被detachAllViewsFromParent()方法置为detach状态的子View重新变为attach状态的,最终ListView中所有的子View又都可以正常显示出来了,至此ListView的第二次layout就结束了。
ListView滑动加载更多数据工作原理
经过前面分析的两次layout过程,我们已经可以在ListView中看到内容了,但是目前ListView中只是加载并显示了第一屏的数据而已。因此,接下来就要看一下ListView滑动部分的源码了,因为我们是通过手指滑动来显示更多数据的。
由于滑动部分的机制是属于通用型的,即ListView和GridView都会使用同样的机制,因此这部分代码是写在AbsListView当中的,所以接下来看一下AbsListView中监听触控事件的onTouchEvent()方法。
|
|
因为目前所关心的就只有手指在屏幕上滑动这一事件而已,所以这里只需要关注ACTION_MOVE这个动作。ACTION_MOVE这个case里面调用了onTouchMove()方法。
|
|
当手指在屏幕上滑动的时候,又会进入TOUCH_MODE_SCROLL这个动作,这个动作下又会调用scrollIfNeeded()方法。
|
|
scrollIfNeeded()这个方法通过计算incrementalDeltaY的正负情况来判断用户是向上还是向下滑动的(如果小于0标识向下滑动,否则就是向上滑动),并且会进行边界值检测的过程,主要是通过调用trackMotionScroll()这个方法来实现的。
|
|
这个方法会在ListView向下滑动的时候,进入一个for循环,从上到下一次获取子View,如果该子View的bottom已经小于top值了,就说明这个子View已经移出屏幕了,所以会调用RecycleBin的addScrapView()方法将这个View加入到废弃缓存当中,并将count计数器加1,计数器用于记录有多少个子View被移出了屏幕。之后会根据计算器的值来进行一次detach操作。紧接着调用offsetChildrenTopAndBottom()并将incrementalDeltaY作为参数传入,这个方法让ListView中所有的子View都按照传入的参数值进行相对的偏移,这样就实现了随着手指的拖动,ListView的内容也会随着滚动的效果。如果ListView中最后一个View的底部已经移入了屏幕,或者第一个View的顶部移入了屏幕,就会调用fillGap()方法用来加载屏幕外的数据。
|
|
fillGap()这个方法通过down参数判断ListView是向下滑动还是向上滑动,并相应调用fillDown()或fillUp()方法。这两个方法前面已经分析过,内部是通过循环对ListView进行填充,循环的主要逻辑就是makeAndAddView()方法,这时候这个方法会调用RecycleBin的getScrapView()方法从废弃缓存中获取一个View。
这样它们之间就形成了一个生产者和消费者的模式,不管有任意多条数据需要显示,ListView中的子View其实来来回回就那么几个,移出屏幕的子View会很快被移如屏幕的数据重新利用起来,因此不管我们加载多少数据都不会出现OOM的情况,甚至内存都不会有所增加。
最后附上一张图来总结一下ListView滑动加载数据的工作原理。