0%

Listview 中 CheckBox 状态错误问题原因及解决方案

在开发过程中碰到很多问题,有些问题在锤子便签中记录了一个概要,有的问题甚至连记录都没有,此次开个头,将碰到的问题记录下来。

今天要写的问题跟Listview有关,顺便复习下ListView的相关复用机制,以及Listview的Adapter中getView方法为什么需要ViewHolder,是怎么提高加载效率的。下面开始进入回忆状态,事情的经过是这样的:

在平时的 Android 开发过程中,我们可能需要去实现以下效果:

使用场景

在 Listview 中使用CheckBox,但是会碰到 CheckBox 选中/非选中 这种状态错乱的问题,笔者最近在项目中就碰到了,比如我选中了 id0、id1、id2 三个 CheckBox ,再想选择 id15 ,这就要求滑动 Listview 了,滑到 id15 CheckBox 将其选中,再滑动到顶部,握草,发现 id0、id1、id2 已经变成 非选中 状态了,莫非是我记错了?再重新来一次,还是一样!这就不科学了,一定是哪里出了问题,我当时的代码是这样的:

@Override
public View getView(final int i, View view, ViewGroup viewGroup) {
    ViewHolder viewHolder = null;
    if(view == null){
        viewHolder = new ViewHolder();
        view = LayoutInflater.from(context).inflate(R.layout.layout,null);
        viewHolder.cb = (CheckBox) view.findViewById(R.id.select_cb);
        viewHolder.tvName = (TextView) view.findViewById(R.id.name_tv);
        view.setTag(viewHolder);
    }else{
        viewHolder = (ViewHolder) view.getTag();
    }
    viewHolder.cb.setChecked(data.get(i).isSlected());
    viewHolder.tvName.setText(data.get(i).getName());
    viewHolder.cb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
        @Override
        public void onCheckedChanged(CompoundButton compoundButton, boolean isChecked)     {
            data.get(i).setSlected(isChecked);
        }
    });
    return view;
}

脑子里第一反应是各处的 item 串了,联想到使用 viewholder 来复用 item ,于是就去了解了一番 Listview 对 item 的复用机制。

复用机制

我们知道,listview 需要承载大量的数据,并且需要写一个 Adapter 与其适配,这样数据就能展现出来了,但是不知道大家有没有仔细想过,为什么需要 Adapter 这个东西,它到底起了个什么作用。

从 Adapter 说起

说到底,Android 中控件就是为了展示数据以及交互用,只不过Listview特殊些,它用于展示大量的信息的,但是 Listview 只承担交互和展示工作的,至于数据来自哪里,它不care。这样,listview工作最基本需要一个 Listview 控件和一个数据源,但是数据源可能是数组,可能是集合,甚至可能是数据库表中查询出来的游标,如果 Listview 要去为每一种数据源进行匹配的话,它一定会变得非常臃肿了,于是 Adapter 出现了。

顾名思义,Adapter 是适配器的意思,它在 Listview 与数据源之间起了一个桥梁作用,与之前的情况不同的是,Adapter 的接口都是统一的,因此 Listview 不需要担心任何适配问题。而 Adapter 是个接口(interface),它可以有各种子类,比如 ArrayAdapter 可用于数组和 List 类型的数据源匹配,SimpleCursorAdapter 可以用于游标类型的数据源匹配,这样把适配问题解决了,并且扩展性不错。

RecycleBin 类

在解释复用机制之前,还有必要说一下 RecycleBin 类,因为它是 Listview 能够展现成百上千条数据并且不会 OOM 的关键,RecycleBin 是 AbsListview 的一个内部类,其主要代码如下:

/**
 * The RecycleBin facilitates reuse of views across layouts. The RecycleBin
 * has two levels of storage: ActiveViews and ScrapViews. ActiveViews are
 * those views which were onscreen at the start of a layout. By
 * construction, they are displaying current information. At the end of
 * layout, all views in ActiveViews are demoted to ScrapViews. ScrapViews
 * are old views that could potentially be used by the adapter to avoid
 * allocating views unnecessarily.
 */
class RecycleBin {
    private RecyclerListener mRecyclerListener;

    /**
     * The position of the first view stored in mActiveViews.
     */
    private int mFirstActivePosition;

    /**
     * Views that were on screen at the start of layout. This array is
     * populated at the start of layout, and at the end of layout all view
     * in mActiveViews are moved to mScrapViews. Views in mActiveViews
     * represent a contiguous range of Views, with position of the first
     * view store in mFirstActivePosition.
     */
    private View[] mActiveViews = new View[0];

    /**
     * Unsorted views that can be used by the adapter as a convert view.
     */
    private ArrayList<View>[] mScrapViews;

    private int mViewTypeCount;

    private ArrayList<View> mCurrentScrap;

    /**
     * Fill ActiveViews with all of the children of the AbsListView.
     * 
     * @param childCount
     *            The minimum number of views mActiveViews should hold
     * @param firstActivePosition
     *            The position of the first view that will be stored in
     *            mActiveViews
     */
    void fillActiveViews(int childCount, int firstActivePosition) {
        if (mActiveViews.length < childCount) {
            mActiveViews = new View[childCount];
        }
        mFirstActivePosition = firstActivePosition;
        final View[] activeViews = mActiveViews;
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            AbsListView.LayoutParams lp = (AbsListView.LayoutParams)     child.getLayoutParams();
            // Don't put header or footer views into the scrap heap
            if (lp != null && lp.viewType != ITEM_VIEW_TYPE_HEADER_OR_FOOTER) {
                // Note: We do place AdapterView.ITEM_VIEW_TYPE_IGNORE in
                // active views.
                // However, we will NOT place them into scrap views.
                activeViews[i] = child;
            }
        }
    }

    /**
     * Get the view corresponding to the specified position. The view will
     * be removed from mActiveViews if it is found.
     * 
     * @param position
     *            The position to look up in mActiveViews
     * @return The view if it is found, null otherwise
     */
    View getActiveView(int position) {
        int index = position - mFirstActivePosition;
        final View[] activeViews = mActiveViews;
        if (index >= 0 && index < activeViews.length) {
            final View match = activeViews[index];
            activeViews[index] = null;
            return match;
        }
        return null;
    }

    /**
     * Put a view into the ScapViews list. These views are unordered.
     * 
     * @param scrap
     *            The view to add
     */
    void addScrapView(View scrap) {
        AbsListView.LayoutParams lp = (AbsListView.LayoutParams) scrap.getLayoutParams();
        if (lp == null) {
            return;
        }
        // Don't put header or footer views or views that should be ignored
        // into the scrap heap
        int viewType = lp.viewType;
        if (!shouldRecycleViewType(viewType)) {
            if (viewType != ITEM_VIEW_TYPE_HEADER_OR_FOOTER) {
                removeDetachedView(scrap, false);
            }
            return;
        }
        if (mViewTypeCount == 1) {
            dispatchFinishTemporaryDetach(scrap);
            mCurrentScrap.add(scrap);
        } else {
            dispatchFinishTemporaryDetach(scrap);
            mScrapViews[viewType].add(scrap);
        }

        if (mRecyclerListener != null) {
            mRecyclerListener.onMovedToScrapHeap(scrap);
        }
    }

    /**
     * @return A view from the ScrapViews collection. These are unordered.
     */
    View getScrapView(int position) {
        ArrayList<View> scrapViews;
        if (mViewTypeCount == 1) {
            scrapViews = mCurrentScrap;
            int size = scrapViews.size();
            if (size > 0) {
                return scrapViews.remove(size - 1);
            } else {
                return null;
            }
        } else {
            int whichScrap = mAdapter.getItemViewType(position);
            if (whichScrap >= 0 && whichScrap < mScrapViews.length) {
                scrapViews = mScrapViews[whichScrap];
                int size = scrapViews.size();
                if (size > 0) {
                    return scrapViews.remove(size - 1);
                }
            }
        }
        return null;
    }

    public void setViewTypeCount(int viewTypeCount) {
        if (viewTypeCount < 1) {
            throw new IllegalArgumentException("Can't have a viewTypeCount < 1");
        }
        // noinspection unchecked
        ArrayList<View>[] scrapViews = new ArrayList[viewTypeCount];
        for (int i = 0; i < viewTypeCount; i++) {
            scrapViews[i] = new ArrayList<View>();
        }
        mViewTypeCount = viewTypeCount;
        mCurrentScrap = scrapViews[0];
        mScrapViews = scrapViews;
    }

}
  • 注释说 RecycleBin 用于view的reuse,它维持了两个存储空间,ActiveViews 和 ScrapViews,前者存放显示在屏幕上的view,到列表最后的时候,它里面的view都会去到 ScrapViews 中。而后者用于存放 old views ,这些view可能可以直接以convertView的形式直接利用,避免没必要的 allocat 内存,这就是Adapter中convertView的由来。

  • fillActiveViews() :这个方法会根据传入的参数来将 Listview 中指定的元素存储到 mActiveViews 数组中。

  • getActiveView() :跟 fillActiveViews 方法对应,用于从 mActiveViews 中获取数据,需要注意的是,一旦第 position 个数据被获取成功之后,该view就会从 mActiveViews 中移除,下次再获取第position个位置将会返回 null,也就是说mActiveViews不能复用。

  • addScrapView() :用于将一个废弃的view进行缓存,当一个view要废弃的时候(比如滚出屏幕),就调用该方法缓存,以便下次使用。

  • getScrapView() :从 ScrapViews 中取出一个view,这些废弃缓存中的view是没有顺序可言的,因此取的算法也非常简单,获取尾部的就行。

  • setViewTypeCount() :我们知道在 adapter 中我们可以重写 getViewTypeCount() 来表示Listview中有几种类型的数据项,而setViewTypeCount()的作用就是为每种类型的数据项都单独启用一个 RecycleBin 缓存机制。

扯点view的绘制

Listview 再牛逼,也是继承自view的,而view的执行流程就是3步,onMeasure() 用于测量 view 的大小,onLayout() 用于确定 View 的布局,onDraw() 用于将 view 绘制到界面上。

Listview 最特殊的地方在于 onLayout() ,而这是在它父类 AbsListview 中实现的,它主要就一个重要判断:如果 Listview 的大小或者位置发生了变化,那就要求所有子布局强制重绘。而 layoutchildren() 方法是用来进行子元素布局的,具体由 Listview 自己实现,可以解析下。

刚开始,Listview 中没有任何子view,因此会去调用 fillActiveViews() 方法,这是为了将 Listview 中的子 view 进行缓存的,由于此时子 view 为空,因此会调用 fillFromTop() ,最终调用到 fillDown() 方法,进行 Listview 的填充操作。fillDown() 中有个while循环,当遍历完从 Listview 顶部到底部的距离的item或者 adapter 中的数据遍历结束,while就跳出。在while中,执行 makeAndAddView() ,它会尝试从 RecycleBin 中快速获取 active view ,但此时 RecycleBin 中还未缓存任何view,因此获得null,所以就会尝试调用 obtainView() ,它是可以保证返回一个 view 的,于是将获取到的view立刻传入到了 setupChild() 中。

那到底 obtainView() 怎么保证获取到view的?不夸张地说,Listview 中最重要的内容都在 obtainView() 中了,该方法里首先调用了 RecycleBin 的 getScrapView() 方法尝试获取一个废弃缓存中的 view ,当然这时候是获取不到的,得到null,之后再调用 mAdapter 的 getView() 方法来获取一个 view ,这时候似曾相识了,有 adapter 和 getView() 方法了,对,就是我们平常写的那个 adapter,然后重写的那个 getView(),这时候会传入 position,convertView (此时为null),parent (当然是 this 了)。

捋一下item的复用

一切从 onLayout 开始,当大小或者位置发生了变化,就会调用onLayout,onLayout完毕之后,就剩下 ondraw 去绘制了。onLayout中,(为了叙述方便,不考虑数据不足以填满Listview的情况),首先要拿item的view放到Listview中,先从ActiveViews中拿,如果为空,则打算从 ScrapViews 中拿,还是为空,则利用 adapter 去创造,创造一屏 itemview 填充于 ActiveViews 中,之后 Listview 从 ActiveViews 中取出 itemview ,ActiveViews 删除该 itemview ,如果 itemview 滑动隐藏了,就会丢弃到 ScrapViews 中,这样滑动的时候触发 onLayout ,onLayout 再去找 itemview 填充,如果有现成的就用,没有就创造。

分析源码谈原因

再来看源码,为了更方便,加上toast提示:

@Override
public View getView(final int i, View view, ViewGroup viewGroup) {
    ViewHolder viewHolder = null;
    if(view == null){
        viewHolder = new ViewHolder();
        view = LayoutInflater.from(context).inflate(R.layout.layout,null);
        viewHolder.cb = (CheckBox) view.findViewById(R.id.select_cb);
        viewHolder.tvName = (TextView) view.findViewById(R.id.name_tv);
        view.setTag(viewHolder);
    }else{
        viewHolder = (ViewHolder) view.getTag();
    }
    viewHolder.cb.setChecked(data.get(i).isSlected());
    viewHolder.tvName.setText(data.get(i).getName());
    viewHolder.cb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
        @Override
        public void onCheckedChanged(CompoundButton compoundButton, boolean isChecked)     {

        Toast.makeText(context,"这是点击了第" + i + "个" ,Toast.LENGTH_SHORT).show();
            data.get(i).setSlected(isChecked);
        }
    });
    return view;
}

运行之后我们看到整个列表,选中第1个,会弹toast “这是点击了第0个” 接着往上慢慢滑动,直至将第一个item隐藏的时候,发现 toast 弹出来了,显示 这是点击了第0个!而这个时候最下面之前被第一条隐藏的item也展现出来了,综合上面的知识,可以知道,这个隐藏的item是复用了第1个item的view,复用view的时候,由于该隐藏item是未checked,而第一条item是已经checked,因此它执行

viewHolder.cb.setChecked(data.get(i).isSlected());

的时候,会触发 OnCheckedChangeListener ,由于之前第一个 item 设置了监听:

viewHolder.cb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(CompoundButton compoundButton, boolean     isChecked)     {

            Toast.makeText(context,"这是点击了第" + i + "个" ,Toast.LENGTH_SHORT).show();
                data.get(i).setSlected(isChecked);
            }
        });

这时候就触发了监听事件,因此toast就弹出来了,并且把第一条item的数据也由checked改成unchecked,因此你下次再见到第一个item的时候,状态就变成unchecked了。

结论

最后可以说结论了,这个现象是由于listview中item复用导致,如果你不用viewholder是不会有这问题的,其实这个结论并不重要,重要的是理解这里面的发生机制。当然,说了问题起因,当然得给个解决方案,方法不止一种,我个人用的一种方法是在

viewHolder.cb.setChecked(data.get(i).isSlected());

之前添加一句:

viewHolder.cb.setOnCheckedChangeListener(null);

我想你肯定知道为什么。

注:参考(引用)以下博客劳动成果:

郭霖 : Android ListView工作原理完全解析,带你从源码的角度彻底理解

谢谢你的鼓励