自 RecyclerView 出世后,ListView 慢慢退出历史的舞台。说实话,之后Android中的列表实现,我都是使用 RecyclerView 来实现。先不说 RecyclerView 相对于 ListView 是如何强大并身兼多职的,这次只总结一下自己对 ListView 强大的布局复用功能的理解,毕竟,这么厉害的复用功能,为人津津乐道,无论ListView 是否会被 Deprecated,多一份认知,为啥不想去了解其中的实现原理?
相关类说明
ListView 与其数据加载和布局复用功能紧密相关的类主要为 Adapter 和 RecycleBin。整理 ListView、Adapter 和 RecycleBin 三者之间的关系如下所示:
- Adapter 接口:主要用于适配数据源,可以想象,若没有适配数据类型,那 ListView 加载的数据局限性就很大了。借鉴这种数据适配模式,我们在自己实现某些控件,比如 banner 或者动态添加子 View 的控件时,使用这种模式,可以自定义不同的布局,然后适配不同类型的数据。
- AbsListView.RecycleBin 类:布局复用的精髓所在,让 ListView 加载成千上百条数据都不会OOM的重要原因之一。因为是 AbsListView 的内部类,所以只要是 AbsListView 的子类,都有布局复用的功能。
对于 Adapter ,大家都很熟悉,这里就略过其他,要注意的是 getView(int pos, View convertView, ViewGroup parent) 这个需要我们自己重写的方法,在后面会重点讲到。
实现缓存机制的 RecycleBin 类,需要注意的字段和方法如上述类图所示,主要作用如下:
- mActiveViews: View[] -> 缓存当前展示在屏幕上的子View。在布局结束时,mActiveViews中的所有视图都移动到mScrapViews。 mActiveViews中的视图表示连续的视图范围,第一个视图存储的位置在mFirstActivePosition中
- mCurrentScrapViews: ArrayList<View> -> 若当前 mViewTypeCount==1,也即只有一种布局类型,则直接从该列表取废弃缓存
- mScrapViews: ArrayList<View> -> 缓存不同 mViewType 的废弃View,也即是每种不同的 ViewType,对应不同的 scrapViews 废弃缓存列表
- mViewTypeCount: int -> 加载的列表ViewType数量
- mFirstActivePosition: int -> 缓存在mActiveViews中第一个View的position
- setViewTypeCount() -> 为mViewTypeCount设置childView布局类型总数,并为每种类型的childView单独启用一个RecycleBin缓存机制。
- fillActiveViews(int childCount, int firstActivePosition) -> 此方法会将ListView中的指定元素存储到mActiveViews数组当中。
- getActiveView(int position) -> 从mActiveViews中获取指定的元素。取出view后,在mActiveViews里的该指定位置将被置空。所以这个mActiveViews只能使用一次,并不能复用。
- getScrapView(int position) -> 从废弃缓存中取出一个View。同理,如果childView的布局类型只有一项,就直接从mCurrentScrap中取。如果多种布局,则从mScrapViews找到相对应的缓存ArrayList再取出view。
- addScrapView(View scrap, int position) -> 将一个废弃的view进行缓存。如果childView的布局类型只有一项,就直接缓存到mCurrentScrap。如果多种布局,则从mScrapViews找到相对应的废弃缓存ArrayList并缓存view。
ListView.setAdapter()
先来看下 ListView 的使用,套路如下:
步骤一:重写 BaseAdapter 方法,实现在定义的子布局
public class MyAdapter extends BaseAdapter {
// ...省略其他抽象方法
public void setData(List<String> datas) {
this.datas = datas;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ViewHolder holder;
if (convertView == null) {
TextView tv = new TextView(parent.getContext());
convertView = tv;
holder = new ViewHolder();
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}
holder.tv = (TextView) convertView;
return convertView;
}
}
static class ViewHolder {
TextView tv;
}
步骤二:给 ListView 设置数据源
MyAdapter adapter = new MyAdapter();
adapter.setData(myDatsSet);
listView.setAdapter(adapter);
在没有真正研究过 ListView 复用功能时,我一直没有去想两个问题:
- 自定义的 item 布局文件是如何添加到 ListView 上的?
- 为什么自定义的 getView() 方法中,要对 convertView 进行判空,然后就可以达到布局复用的效果了?
对于问题一,ListView 也是继承自 View,所以其会遵循 View 的绘制原则,按照 measure、layout 和 draw 三步操作执行,但是 ListView 占用空间通常是整个屏幕,绘制也只是绘制加载的 子View,并没有实际意义。ListView 最主要的逻辑则是在 layout() 中,trace源码,我们的分析主要从 layout() 开始。
经测试,执行 listView.setAdapter() 后,会调用 requestLayout(),ListView 会执行两次 layout 。为什么会执行两次,大家都没找到原因,我只能猜测,在 requestLayout() 后,会将重新布局操作层层递归传给 ViewRootImpl 顶层父控件执行 performTraversal() 方法,这个方法中在某些条件下会执行两次 layout() 方法,具体这些条件是什么,暂时不深究,这里只先确定,设置数据源后,确实会触发 listView 执行两次 layout 。
ListView 重新布局,主要处理逻辑在其 layoutChildren() 方法中,贴出该方法的关键代码如下:
@Override
protected void layoutChildren() {
// ...省略其他代码逻辑...
// Pull all children into the RecycleBin.
// These views will be reused if possible
final int firstPosition = mFirstPosition;
final RecycleBin recycleBin = mRecycler;
if (dataChanged) {
for (int i = 0; i < childCount; i++) {
recycleBin.addScrapView(getChildAt(i), firstPosition+i);
}
} else {
recycleBin.fillActiveViews(childCount, firstPosition);
}
// Clear out old views
detachAllViewsFromParent();
recycleBin.removeSkippedScrap();
switch (mLayoutMode) {
// mLayoutMode 默认为 LAYOUT_NORMAL,省略其他条件...
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);
}
} else {
if (mSelectedPosition >= 0 && mSelectedPosition < mItemCount) {
sel = fillSpecific(mSelectedPosition,
oldSel == null ? childrenTop : oldSel.getTop());
} else if (mFirstPosition < mItemCount) {
sel = fillSpecific(mFirstPosition,
oldFirst == null ? childrenTop : oldFirst.getTop());
} else {
sel = fillSpecific(0, childrenTop);
}
}
break;
}
// ...省略其他代码逻辑...
}
总结初始两次布局的流程如下图所示:
上述流程图为代码执行流程,直接用文字简述如下:
第一次 layout 布局:
- 方法调用链:recycleBin.fillActiveViews() -> detachAllViewsFromParent() ,都无实际意义;
- 从 fillFromTop() 开始,调用 makeAndAddView() 方法创建并添加 View 到 ListView 上,这里只会创建在屏幕上展示的个数的子View;
- Trace源码,实际会执行 obtainView() 方法,通过 adapter.getView(pos, null, parent); 方法返回 View,根据我们重写的 getView() 方法,convertView 为 null 时,会通过 inflate() 方法加载布局文件,最终返回创建的 convertView;
- 最后一步,调用 setUpChild() 方法,因为是第一次添加,则调用 addViewInLayout(); 方法重新添加 View 到父控件;
第二次 layout 布局:
- 调用 recycleBin.fillActiveViews();方法,将第一步创建的 childView 缓存到 mActiveViews 数组中;
- 接着调用 detachAllViewsFromParent(); 方法,将 childView 依次从父控件 detach 掉;
- 接着则不是调用 fillFromTop(),而是调用 fillSpecific() 方法,但最终都会执行 makeAndAddView() 方法,这时则先调用 recycleBin.getActiveView(); 方法,从 mActiveViews 数组中取出 View,此时取出的 view 不为 null;
- 最后一步,调用 setUpChild() 方法,因为之前缓存到 mActiveViews 中的View,取出来后相当于是从父控件 detach 掉的,此时会调用 attachViewToParent() 方法重新将该 activeView attach到父控件。
- 经过一次 detach 到 attach 的过程,ListView 中的所有子控件又可以展示到屏幕。
总结:ListView 添加数据源后,会进行两次 layout ,第一次布局,会通过 adapter.getView() 方法创建 View,然后添加到父控件中;第二次布局,会将之前创建的子View缓存到 RecycleBin 的 mActiveVies 数组中,然后 detach 掉父控件所有的子View,接下来则是从 mActiveVies 数组中取出所有的子 View,然后重新 attach 到父控件,而不是重新添加布局到父控件。
好了,上述场景是ListView第一次加载时,通过对此场景的研究,对相关字段和方法功能有了初步的认识,再来看看滑动过程中的布局复用,这才是重中之重的逻辑场景。
滑动复用布局
具体执行逻辑是在 onTouchEvent() 方法开始的,简单流程图总结如下:
问题总结
Q:若 ListView 设置的数据源包含100条数据,那么通过listView.getChildCount() 返回的子 View 的个数是多少?
A:在 ListView 可复用的前提下,getChildCount() 返回的个数为当前屏幕可展示的 childView 的个数,不在屏幕内的不算在其中。可以结合 ListView 的加载复用流程,仅在当前屏幕可见时,子 View 才会 add 到 ListView 中,当滑动时,使用的是从 mScrapViews 废弃列表中取出的 View,重新设置数据并 attach 到 ListView 上。
Q: 什么时候调用 mAdapter.getView(in pos, View convertView, ViewGroup parent); ?
A:参考上述流程图。这里需要注意的是 convertView 是从 mScrapView 中取出的 View,当为 null 时,说明是第一次创建,此时没有缓存废弃的 View,当屏幕滑动时,移除屏幕的 View 会进行缓存,然后会将新移入的 数据重新更新到 移除屏幕区的 View 上,此时 convertView 不为 null,所以我们自定义处理布局时,要添加一次 convertView 的判断,其实是配合内部的缓存机制,才打到真正的布局复用。若没有不为 null 的判断,则都是通过 inflate 过来的,效率着实低下。
更多文章
Android ListView工作原理完全解析,带你从源码的角度彻底理解
解析Android ListView工作原理及其缓存机制
