listview源码学习

作者: chefish | 来源:发表于2016-10-24 18:43 被阅读489次

    前言

    本文从源码角度出发学习listview,主要分析首次RecycleBin的组成,layout的过程,滑动过程,item的点击实现,如何支持Header,notifyDataSetChanged原理。

    问题

    用了好几年的listview,有几个问题却一直不清楚
    1、如何让一个itemview不被回收,比如我的listview里有个viewpager比较复杂,不想让他被回收又重新创建。
    2、head和foot的原理是怎么样的,会被回收吗?
    3、scrap里的view会被进一步回收掉吗?

    基础知识

    读了3遍郭神的http://blog.csdn.net/sinyu890807/article/details/44996879 真是受益匪浅,原来listview是这么实现的。
    listview的实现方法跟scrollview完全不同,scrollview是内部实例化了所有的view,在滚动的时候只是改变可见的部分,scrollview的高度可能是几千几万。如果item数很多的话,必然会oom。
    而listview是首先画出listview的壳,然后去adapter里取数据,取到数据inflate为view,填到listview里面去,填满了就好了。即使adapter里有1万个数据,第一次layout的时候取的也是很少的数据(看当前屏幕需要,假设10个)。然后在上滑的过程中,首先用offsetTopAndBottom对所有child进行移动,此时顶部view就会滑出部分,那么底部会出现gap,再去adapter里面捞数据,填到底部;然后顶部的view逐渐的被完全移出屏幕,先detach,然后把这个view丢到scrap里面去,继续滑动底部又出现了gap,就去scrap里面拿现成的view。如此往复循环,这就是listview的原理。
    和scrollview对比,listview的滑动过程中伴随着view的detach,attach,但是这些都不是耗时的东西,时间上没什么损失,但是空间上减少了大量的内存开销。先分析下layout过程和滑动过程。
    listview内的缓存主要就是scrap,离屏就会进入scrap。scrap在layout的时候会进行裁剪,去调尾部的一些view,但是实际上这种情况发生的不多,后边会详细说。

    layout过程

    我测试了下layout的次数,郭神文章说的是2次,我这里会有3次。

    第一次layout

    onLayout -> layoutChildren -> fillFromTop-> fillDown-> while() makeAndAddView
    
    makeAndAddView ->  
                        1、obtainView  -> getView -> inflate       
                        2、setupChild  -> addViewInLayout
                                       ->child.measure
                                       ->child.layout
    

    第二次layout

    onLayout -> layoutChildren -> 
    1、fillActiveViews   
    2、detachAllViewsFromParent  
    3、fillSpecific-> fillDown->while() makeAndAddView
                         
            makeAndAddView -> getActiveView
                              ->setupChild -> attachViewToParent     
    
    

    第三次layout

    onLayout -> layoutChildren -> 
    1、fillActiveViews   
    2、detachAllViewsFromParent  
    3、fillSpecific-> fillDown->while() makeAndAddView
                         
            makeAndAddView -> getActiveView
                              ->setupChild -> attachViewToParent    
                                           ->child.measure
                                           ->child.layout  
    
    

    可以看到后2次基本差不多,区别在于在setupChild内是否要执行child的measure和layout。为什么第3次layout会调用measure和layout,而第二次不会呢?看下边的代码,差别就在于第三次child.isLayoutRequested()变为了true。

    //setupChild
    final boolean needToMeasure = !recycled || updateChildSelected || child.isLayoutRequested();
    

    我在重新捋一下流程
    1、ViewRootImpl#dispatchResized被调用,发出MSG_RESIZED_REPORT消息
    2、第一次ListView:layoutChildren
    3、第二次ListView:layoutChildren
    4、收到发出MSG_RESIZED_REPORT消息,ViewRootImpl#forceLayout
    5、第三次ListView:layoutChildren

    所以第三次ListView:layoutChildren的时候会触发child.measure和child.layout。奇怪的是,每次都是第2次layout之后收到MSG_RESIZED_REPORT消息

    滑动

    对移除屏幕的view addScrapView、detachViewsFromParent
    对屏幕内的view offsetChildrenTopAndBottom
    对屏幕内空白的地方 fillGap -> fillDown->while() makeAndAddView
    makeAndAddView -> obtainView、setupChild
    obtainView-》getScrapView-》adapter.getView(convertview....)

    void fillGap(boolean down) {  
        final int count = getChildCount();  
        if (down) {  
            final int startOffset = count > 0 ? getChildAt(count - 1).getBottom() + mDividerHeight :  
                    getListPaddingTop();  
            //手指向上滑动,所以需要填充底部        
            fillDown(mFirstPosition + count, startOffset);  
            correctTooHigh(getChildCount());  
        } else {  
            final int startOffset = count > 0 ? getChildAt(0).getTop() - mDividerHeight :  
                    getHeight() - getListPaddingBottom();  
            fillUp(mFirstPosition - 1, startOffset);  
            correctTooLow(getChildCount());  
        }  
    }  
    

    滑动过程中不会调用onMeasure或者onLayout

    RecycleBin基本成员与方法

    view的回收复用主要就依靠RecycleBin,所以重点分析下RecycleBin

    mScrapViews

    RecycleBin内有个垃圾箱,mScrapViews用来存放移除屏幕的view。

     private ArrayList<View>[] mScrapViews;
     private ArrayList<View> mCurrentScrap = mScrapViews[0];;
    

    为什么是个数组呢?数组的每一项都是个ArrayList<View>,代表着某个type的垃圾view集合.如果只有一种type,那么垃圾都存在mScrapViews[0]内,mCurrentScrap = scrapViews[0];如果只有一个类型,我们直接操作mCurrentScrap即可

    addScrapView

    addScrapView就是把一个view加入到垃圾箱内,一般在view离开屏幕的时候调用。如果数据未变,adapter有stable IDs,有暂态,那就不会被收到垃圾箱里,会存着备用。。如果是header、footer那么就放入mSkippedScrap内,不放入mScrapViews。如果是暂态而且有有stable IDs,就丢到mTransientStateViewsById里面去。如果不需要stable IDs,数据未变可以丢到mTransientStateViews

        /**
             * Puts a view into the list of scrap views.
             * <p>
             * If the list data hasn't changed or the adapter has stable IDs, views
             * with transient state will be preserved for later retrieval.
             *
             * @param scrap The view to add
             * @param position The view's position within its parent
             */
            void addScrapView(View scrap, int position) {
                final AbsListView.LayoutParams lp = (AbsListView.LayoutParams) scrap.getLayoutParams();
                if (lp == null) {
                    // Can't recycle, but we don't know anything about the view.
                    // Ignore it completely.
                    return;
                }
    
                lp.scrappedFromPosition = position;
    
                // Remove but don't scrap header or footer views, or views that
                // should otherwise not be recycled.
                final int viewType = lp.viewType;
                if (!shouldRecycleViewType(viewType)) {
                    // Can't recycle. If it's not a header or footer, which have
                    // special handling and should be ignored, then skip the scrap
                    // heap and we'll fully detach the view later.
                    if (viewType != ITEM_VIEW_TYPE_HEADER_OR_FOOTER) {
                        getSkippedScrap().add(scrap);
                    }
                    return;
                }
    
                scrap.dispatchStartTemporaryDetach();
    
                // The the accessibility state of the view may change while temporary
                // detached and we do not allow detached views to fire accessibility
                // events. So we are announcing that the subtree changed giving a chance
                // to clients holding on to a view in this subtree to refresh it.
                notifyViewAccessibilityStateChangedIfNeeded(
                        AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE);
    
                // Don't scrap views that have transient state.
                final boolean scrapHasTransientState = scrap.hasTransientState();
                if (scrapHasTransientState) {
                    if (mAdapter != null && mAdapterHasStableIds) {
                        // If the adapter has stable IDs, we can reuse the view for
                        // the same data.
                        //是暂态view,并且需要stable ID就丢到mTransientStateViewsById里面去
                        if (mTransientStateViewsById == null) {
                            mTransientStateViewsById = new LongSparseArray<>();
                        }
                        mTransientStateViewsById.put(lp.itemId, scrap);
                    } else if (!mDataChanged) {
                        // If the data hasn't changed, we can reuse the views at
                        // their old positions.
                        if (mTransientStateViews == null) {
                            mTransientStateViews = new SparseArray<>();
                        }
                        //数据未变可以丢到mTransientStateViews
                        mTransientStateViews.put(position, scrap);
                    } else {
                        // Otherwise, we'll have to remove the view and start over.
                        getSkippedScrap().add(scrap);
                    }
                } else {
                    if (mViewTypeCount == 1) {
                        mCurrentScrap.add(scrap);
                    } else {
                        mScrapViews[viewType].add(scrap);
                    }
    
                    if (mRecyclerListener != null) {
                        mRecyclerListener.onMovedToScrapHeap(scrap);
                    }
                }
            }
    

    retrieveFromScrap

    从scrap里取view,核心代码如下,如果是固定id的,那就根据adapter的id来找,否则就根据scrappedFromPosition 来找,比如第7个item被回收到scrap里了,记下这个view的scrappedFromPosition为7, 那下次滑回第7个item,就尽量给scrappedFromPosition为7的view给他,简单的说就是从哪里回收来的,还回哪里去。如果根据scrappedFromPosition找不到,那就直接取scrap的最后一个

              if (mAdapterHasStableIds) {
                            final long id = mAdapter.getItemId(position);
                            if (id == params.itemId) {
                                return scrapViews.remove(i);
                            }
                        } else if (params.scrappedFromPosition == position) {
                            final View scrap = scrapViews.remove(i);
                            clearAccessibilityFromScrap(scrap);
                            return scrap;
                        }
            final View scrap = scrapViews.remove(size - 1);            
            return scrap;
    

    activeViews

    这个有什么意义,没看懂。根据上面的分析,在第二次layout的过程中,首先会把当前屏幕的itemview给detach掉,扔到activeViews内,然后又把他们抓出来,给attach上,此时activeViews必定为空,如果不为空,把残余的view丢到mScrapViews内(scrapActiveViews) 我实在不明白这么搞有什么意义。

    shouldRecycleViewType

    根据type类型来确定这个view是否能回收,type类型一般可以在adapter里指定,但是系统默认提供了2个类型,一个是ITEM_VIEW_TYPE_IGNORE=-1,一个是ITEM_VIEW_TYPE_HEADER_OR_FOOTER=-2。第二个很明显就是listview的头和尾。第一个是什么呢?如果我们希望某个view不被回收的话,可以设置ITEM_VIEW_TYPE_IGNORE,这样就可以了。(recyclerView有类似的吗?)

            public boolean shouldRecycleViewType(int viewType) {
                return viewType >= 0;
            }
    

    mRecyclerListener

    当发生View回收时,mRecyclerListener若有注册,则会通知给注册者.RecyclerListener接口只有一个函数onMovedToScrapHeap,指明某个view被回收到了scrap heap.可以在这个接口回调里进行昂贵资源的回收(比如bitmap)。可以直接用listview来注册监听者.

            listview.setRecyclerListener(new AbsListView.RecyclerListener() {
                @Override
                public void onMovedToScrapHeap(View view) {
                }
            });
    

    点击item

    点击一个item,是和第二次layout类似的,会调用layoutChildren,然后把界面上的view抓起来丢到activeViews内,然后又重新填充,setupChild内不会调用measure和layout

    onTouchUp -> layoutChildren -> 
    1、fillActiveViews   
    2、detachAllViewsFromParent  
    3、fillSpecific-> fillDown->while() makeAndAddView
            makeAndAddView -> getActiveView
                              ->setupChild -> attachViewToParent    
                                          
    
    

    跟第二次layout的区别就是没有调用child的onMeasure和onLayout,关键代码如下,这里needToMeasure为false

    final boolean needToMeasure = !recycled || updateChildSelected || child.isLayoutRequested();
    

    Header

    我们可以轻易的给使用addHeaderView一个listview加上header。
    我知道addHeaderView必须在setAdapter之前,可以add多个head
    那么问题来了,为什么addHeaderView必须在setAdapter之前?
    看setAdapter的部分代码可以明白,如果之前设置了header,那mAdapter将会被包装起来HeaderViewListAdapter

    //setAdapter
            if (mHeaderViewInfos.size() > 0|| mFooterViewInfos.size() > 0) {
                mAdapter = new HeaderViewListAdapter(mHeaderViewInfos, mFooterViewInfos, adapter);
            } else {
                mAdapter = adapter;
            }
    

    再看看HeaderViewListAdapter,看下边的代码可以看到实际上HeaderViewListAdapter实现了Adapter的各种接口,比如getCount,getItem,getItemViewType,getView,这就是把原来的adapter进行包装,然后实现对应接口,把Header作为一种特殊类型AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER,在ListView看来,他就是一个普通的adapter。

    //HeaderViewListAdapter
    public class HeaderViewListAdapter implements WrapperListAdapter, Filterable {
    
        public int getCount() {
            if (mAdapter != null) {
                return getFootersCount() + getHeadersCount() + mAdapter.getCount();
            } else {
                return getFootersCount() + getHeadersCount();
            }
        }
        
        public Object getItem(int position) {
            // Header (negative positions will throw an IndexOutOfBoundsException)
            int numHeaders = getHeadersCount();
            if (position < numHeaders) {
                return mHeaderViewInfos.get(position).data;
            }
    
            // Adapter
            final int adjPosition = position - numHeaders;
            int adapterCount = 0;
            if (mAdapter != null) {
                adapterCount = mAdapter.getCount();
                if (adjPosition < adapterCount) {
                    return mAdapter.getItem(adjPosition);
                }
            }
    
            // Footer (off-limits positions will throw an IndexOutOfBoundsException)
            return mFooterViewInfos.get(adjPosition - adapterCount).data;
        }
        public View getView(int position, View convertView, ViewGroup parent) {
            // Header (negative positions will throw an IndexOutOfBoundsException)
            int numHeaders = getHeadersCount();
            if (position < numHeaders) {
                return mHeaderViewInfos.get(position).view;
            }
    
            // Adapter
            final int adjPosition = position - numHeaders;
            int adapterCount = 0;
            if (mAdapter != null) {
                adapterCount = mAdapter.getCount();
                if (adjPosition < adapterCount) {
                    return mAdapter.getView(adjPosition, convertView, parent);
                }
            }
    
            // Footer (off-limits positions will throw an IndexOutOfBoundsException)
            return mFooterViewInfos.get(adjPosition - adapterCount).view;
        }
    
        public int getItemViewType(int position) {
            int numHeaders = getHeadersCount();
            if (mAdapter != null && position >= numHeaders) {
                int adjPosition = position - numHeaders;
                int adapterCount = mAdapter.getCount();
                if (adjPosition < adapterCount) {
                    return mAdapter.getItemViewType(adjPosition);
                }
            }
    
            return AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER;
        }
    }
    

    我们在分析下header是否会被多次创建,是否会被丢到scrap里去

    首先看,滑动的时候会不会回收header,这里明显可以看到position的限制,header和footer是不会被回收的。既然不会回收,那下次再滑到header的时候还是找adapter要,看上边的adapte的getView代码,mHeaderViewInfos.get(position).view,只是从mHeaderViewInfos.get内取,所以header是不会被回收的,永远存在mHeaderViewInfos里面。这里可以得到启发,Recyclerview是不支持header,footer的,那我们是不是可以针对Recyclerview来一次类似的包装,让他支持header,footer

    //trackMotionScroll
                        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);
                        }
    

    notifyDataSetChanged

    之前一直没有说过,数据发生变化的情况会怎么样,我们都知道,数据发生变化调用adpater的notifyDataSetChanged就会刷新界面。这里面的原理是什么? 这里面有个观察者模式,BaseAdapter内有个mDataSetObservable,AbsListView在onAttachedToWindow的时候会注册观察者,代码如下,这样就注册了一个观察者mDataSetObserver

    //AbsListView#onAttachedToWindow
       if (mAdapter != null && mDataSetObserver == null) {
                mDataSetObserver = new AdapterDataSetObserver();
                mAdapter.registerDataSetObserver(mDataSetObserver);
    
                // Data may have changed while we were detached. Refresh.
                mDataChanged = true;
                mOldItemCount = mItemCount;
                mItemCount = mAdapter.getCount();
            }
    

    notifyDataSetChanged会调用mDataSetObserver.onChanged,里面更新了mItemCount,然后调用了rememberSyncState和requestLayout。

            @Override
            public void onChanged() {
                mDataChanged = true;
                mOldItemCount = mItemCount;
                mItemCount = getAdapter().getCount();
    
                // Detect the case where a cursor that was previously invalidated has
                // been repopulated with new data.
                if (AdapterView.this.getAdapter().hasStableIds() && mInstanceState != null
                        && mOldItemCount == 0 && mItemCount > 0) {
                    AdapterView.this.onRestoreInstanceState(mInstanceState);
                    mInstanceState = null;
                } else {
                    rememberSyncState();
                }
                checkFocus();
                requestLayout();
            }
    

    这里rememberSyncState比较陌生,实际上他做的事情也很少,主要就2行代码,设置mSyncMode和mSyncPosition。重新布局的时候默认有个原则,之前谁在第一个,那么这次谁还是在第一个.

          mSyncPosition = mFirstPosition;
          mSyncMode = SYNC_FIRST_POSITION;
    

    然后我们看又一次layout的过程
    首先,handleDataChanged会定下mSyncPosition,然后把
    mLayoutMode = LAYOUT_SYNC;,这个mLayoutMode后边会用到
    第二步,因为dataChanged所以这里直接把所有的界面上的view丢到scrap里,不像以前放在activeViews里
    第三步,detachAllViewsFromParent
    第四步,fillSpecific是因为mLayoutMode是LAYOUT_SYNC所以直接调用fillSpecific。
    里面的getView是adapter的getView一般在这里设置实际view的内容(比如文本图片)。所以view一般都会设置为PFLAG_FORCE_LAYOUT,所以会重新measure、layout。(这里可以再思考下,其实大部分情况下,重用view,并不用重新measure,而layout的时候只要把item往listview的框里丢就可以了,item内部也不需要layout,这样应该能够提供效率,但是看了代码后发现setupChild内是根据needToMeasure来决定是否measure、layout的,不能分别对待,哎。)

    onLayout -> layoutChildren -> 
    1、handleDataChanged:定个mSyncPosition、mLayoutMode = LAYOUT_SYNC;
    2、for() addScrapView
    3、detachAllViewsFromParent  
    4、fillSpecific-> fillDown->while() makeAndAddView
                         
            makeAndAddView -> obtainView->getView
                              ->setupChild -> attachViewToParent    
                                           ->child.measure
                                           ->child.layout  
    
    

    这次layout跟之前的区别主要是第二步和第四步。

    listview动画错乱

    listview的item如果在执行动画的同时,listview在滑动,我们知道listview滑动过程中,是会重用view的,所以可能本来针对position 为1的动画,跑到position为11的地方去了,所以我们得禁止这个view进入scrap,如何禁止?
    setHasTransientState(true),让view进入暂态
    setHasTransientState是API16引入的函数,在View里,下边是对他的介绍,主要是用于动画开始和结束,在开始的时候setHasTransientState(true),结束的时候setHasTransientState(false),在这之间就是暂态的。

    常见用法如下,在动画开始的时候进入暂态,动画结束退出暂态。我们再对比listview的代码可以发现,进入暂态的view不会进入scrap,而是进入mTransientStateViewsById这个LongSparseArray内,这样就不会被重用而导致动画错乱了。

    //Listview 的 OnItemClickListener 的内容
    //本范例点了 item 后会淡出并删除该 item
    public void onItemClick(AdapterView
     
      parent, final View view, int position, long id) {
        final String item = (String) parent.getItemAtPosition(position);
        ObjectAnimator anim = ObjectAnimator.ofFloat(view, View.ALPHA, 0);
        anim.setDuration(500);
        view.setHasTransientState(true); //设为 true 宣告 item 要被追踪
        anim.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                myListview.remove(item);
                adapter.notifyDataSetChanged(); //重新整理 listview
                view.setAlpha(1);
                view.setHasTransientState(false); //完成后设定回 false
            }
        });
        anim.start();
    }
    

    不可否认这是一种解决方法,但并不是好的解决方法,因为item都滑出去了,还在搞动画,没啥意义,真正好的方法是什么,如下所示,在onMovedToScrapHeap里面停止动画,这才是最合适的。

    listView.setRecyclerListener(new RecyclerListener() {
            @Override
            public void onMovedToScrapHeap(View view) {
                // Stop animation on this view
            }
    });
    

    hasStableIds

    adapter有个接口叫hasStableIds,这个有什么意义,我查了资料和代码。发现hasStableIds一般情况下都是false,只有2个情况是true。
    什么样的adapter是stable的,个人以为是里面的数据的id不变化,数据可以变化,但是id不能变化。
    首先,如果要用到listview的选中功能时,只有hasStableIds返回true,才能通过getCheckedItemIds方法才能正常获取用户选中的选项的id(当然adapter内必须复写getItemId)。
    还有个地方就是CursorAdapter,因为cursor是sql查询的结果,所以说是stable的无可厚非。CursorAdapter里面的hasStableIds就是返回true的。
    总的来说hasStableIds没啥用,我也没看到改为true能优化什么。

    问题

    addViewInLayout和attachViewToParent有什么区别呢

    addViewInLayout和attachViewToParent两者接收的参数是一样的,主要功能也相似,也就是往ViewGroup的view数组里添加View, 但是调用addViewInLayout会使被添加的View在界面上添加时会有动画效果呈现。两者的使用场景差别也很明显了:一般来说某一个View第一次添加进ViewGroup时比较适合调用addViewInLayout,而以后同一个View再次被添加时则适合使用attachViewToParent。因为一般情况想我们会希望进入的动画效果执行一次就够了,而不需要多次执行。
    具体可参考http://www.itdadao.com/articles/c15a444236p0.html

    scap有数量限制吗

    在滑动过程中scrap是没限制的,但是在layout的过程中调用scrapActiveViews->pruneScrapViews,在这里会把mScrapViews内的每组缓存,都限制在mActiveViews.length大小。

    scrap里的view会被去掉吗

    为什么要考虑这个问题呢?因为有的view创建成本很高,我们不希望重复创建,什么情况下会重复创建呢?那就是view离屏,进scrap,scrap裁剪,被裁剪的view就没有了,下次必须重新inflate出来。
    我看了下要想remove scrap里的view,只有pruneScrapViews和clear方法,clear在设置setAdapter(ListAdapter)和onDetachedFromWindow()时会被调用。而pruneScrapViews是在layout过程中被调的。所以主要看pruneScrapViews。这里可以看到其实处理scrap长度的方法是比较粗暴的,查一下mActiveViews.length,任何一个scrap堆都不准超过这个长度,否则直接截尾。于是我又看了下mActiveViews,每次显示在界面上的view都会丢到mActiveViews里,又发现mActiveViews只会变长不会变短。这就有意思了,比如当前页面有5个item,那mActiveViews.length就是5,待会当前页面有10个item了,那mActiveViews.length就是10,再过一块又只有3个item了,那mActiveViews.length还是10(只增不减)。要注意一点只有在layout的时候才会更新mActiveViews.length,如果只是滑来滑去是不会触发layout的。所以如果,mActiveViews.length值比较小,而scrap的item又很多的话,会进入到L10,进行裁剪(一般会发生在header高度比较大的情况下),这种裁剪方式其实是比较奇怪的,凭什么根据mActiveViews.length来裁剪。
    所以scrap里的view是有可能被丢弃的,但是如果某个scap堆里只有一个view,那放心,他绝不会被丢弃。
    另外如果我们希望缓存的view数量多一些的话,我们可以在view比较多的时候掉一遍requestLayout,这样让他更新mActiveViews.length

         final int maxViews = mActiveViews.length;
                final int viewTypeCount = mViewTypeCount;
                final ArrayList<View>[] scrapViews = mScrapViews;
                for (int i = 0; i < viewTypeCount; ++i) {
                    final ArrayList<View> scrapPile = scrapViews[i];
                    int size = scrapPile.size();
                    final int extras = size - maxViews;
                    size--;
                    for (int j = 0; j < extras; j++) {
                        removeDetachedView(scrapPile.remove(size--), false);
                    }
                }
    

    其他

    1、layoutChildren必定调用invalidate
    2、initAbsListView内设置ListView本身可以点击即可以消耗父View分发的事件: setClickable(true);
    3、我们常常用的convertView实际上来自scrapView

    ref

    http://blog.csdn.net/sinyu890807/article/details/44996879
    https://github.com/CharonChui/AndroidNote/blob/master/Android%E5%8A%A0%E5%BC%BA/ListView%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90.md
    http://www.itdadao.com/articles/c15a444236p0.html
    http://www.cnblogs.com/qiengo/p/3628235.html

    http://www.eoeandroid.com/thread-303373-1-1.html?_dsign=6a0c274f
    http://edscb.blogspot.com/2013/09/animation-listview-animations.html

    ListView单选和多选模式完全解析

    相关文章

      网友评论

        本文标题: listview源码学习

        本文链接:https://www.haomeiwen.com/subject/acbluttx.html