ListView 布局复用原理分析

自 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流程图

上述流程图为代码执行流程,直接用文字简述如下:
第一次 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工作原理及其缓存机制

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容