由于 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)。
写这么多字都没个图,就来个最常见的把~
注意一种情况:比如说还是一页的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复用机制