从ListView逐步演变到RecyclerView

    ListView是咱们开发中最经常使用的组件之一,在以往的PC端组件开发中,列表控件也是至关重要的,可是从桌面端到移动端,状况又有新的变化。数据库

    移动端的屏幕并不像桌面端那么大,而且移动端不可能把全部的内容都一会儿展示出来,由于Android系统分配给一个应用的内存是有限的,而任何显示在组件上面的内容都是加载在内存中的,若是一个Item包含相似图片这样的占内存的内容,很容易就内存爆炸,也就是OOM,并且Android的界面渲染是在主线程中,若是耗时太长,用户在屏幕上有其余事件输入,如点击触摸,超过5秒没有响应就会ANR,渲染时间若是超过60毫秒,就会开始感受到卡顿了,也就是所谓的掉帧。设计模式

    解决卡顿的问题,减小布局的层级提升渲染速度,还有就是不要在主线程中进行耗时操做,竭力避免内存抖动,频繁的GC也会引发界面卡顿。缓存

    很遗憾,ListView都有可能要面对上面全部问题。网络

    ListView首先要面对一个严峻的问题:数据来源的加载。数据结构

    ListView绑定了一个数据源,而后根据数据源的个数,返回对应数目item的View进行渲染。数据来源多是数据库查询,也多是网络获取,这些耗时操做按理都不该该在主线程中进行,可是ListView绑定数据源的操做却必定要在主线程进行,任何和View有关的操做都要在主线程。并发

    这就涉及到跨线程通讯,咱们能够用异步任务或者EventBus等各类方式来完成这个绑定数据源的动做。框架

    解决这个问题后,咱们还得面对item在手机上显示的问题。异步

    咱们是不可能让全部的item一会儿在屏幕上显示出来,手机可以显示的item数目有限,而且用户是有很大的可能不会将全部的item都看完,所以在用户看不见的地方显示item是很不值得的行为。ide

    ListView对item进行了缓存,只缓存了一个屏幕可以显示的数目。布局

    这个机制的实如今原生中是很简单的,Adapter的getView中有一个convertView,它在一开始绘制的时候都是null,在绘制完第一屏的item后,它就不会是空的,存放的是第一屏item的内容,就算这些item滑出后,它都不会是空的。

    ListView老是缓存一个屏幕可以显示的item + 1的数目的View。

    了解到这点后,咱们就没有必要每次都从新建立convertView,只要将新的内容显示在convertView缓存好的组件上,就能减小inflate的时间。

    inflate实际上是很耗时的,由于inflate会涉及到组件宽高的计算,还有内容的显示,一个item的infalte的时间可能不算多,但每次滑动都会inflate,尤为是item的内容涉及到图片加载等,就会形成在infalte的时候还要响应屏幕的滑动,这就会形成卡顿了,毕竟主线程就一个,屏幕滑动和控件绘制都是在主线程。

    因此咱们要找到办法来利用convertView的这个特性。

    首先解决convertView重复建立的问题。

    咱们能够先判断convertView是否为null,若是为null,再从新建立。

if(convertView == null){
   convertView = LayoutInflater.from(context).inflate(R.layout.item_pratice, null);
}

    这解决了convertView重复建立的问题。

    当咱们要使用布局中的组件时,会先经过findViewById来声明组件,这在通常的页面中没问题,但若是是一个列表,就有问题了。

    findViewById是很浪费时间的。

    findViewById要遍历View的树形结构来找到对应的id,并且这个遍历是从头至尾,因此若是该View的层级比价复杂,这个查询就比较耗时了。

    咱们在布局文件中采用@+id的形式指定控件id,就会在R文件中生成一个id,也能够采用@id的形式,经过在ids文件中声明一个id。

    这两种形式的区别到底在哪里呢?实际上,不管采用哪一种方式,findViewById的时间都是差很少的,可是@+id的形式,在代码中能够点击跳转到对应界面中的控件,而@id的形式只能跳转到对应的ids文件,这在查看控件时候是很不方便的。

    为了解决这个问题,谷歌推荐咱们使用ViewHolder的形式。

    ViewHolder自己并不神秘,它就是声明了一个存储了组件实例的类,而后要用的时候再取出来,这样就不用每次都findViewById。

    在Java中,对同一个引用进行操做,会修改该引用指向的对象,ViewHolder就是经过保存布局中组件的引用,达到重复利用的目的,由于convertView中的组件引用和ViewHolder是相同的。

   @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        ViewHolder viewHolder;
        if(convertView == null){
            convertView = LayoutInflater.from(context).inflate(R.layout.item_pratice, null);
            viewHolder = new ViewHolder();
            viewHolder.tvName = (TextView)convertView.findViewById(R.id.tv_name);
            convertView.setTag(viewHolder);
        }else{
            viewHolder = (ViewHolder)convertView.getTag();
        }

        String name = nameList.get(position);
        viewHolder.tvName.setText(name);
        return convertView;
    }

    class ViewHolder{
        TextView tvName;
    }

    这就是很经典的ViewHolder的使用。

    知道ViewHolder持有了布局中组件的引用后,咱们就会思考一件事:引用的持有传递问题。

    Android中的内存泄露问题,都是由于不应持有的引用被持有了,没有及时释放,Adapter并不保证是否会被传递给哪一个Activity,但Adapter中的ViewHolder却持有了布局中组件的引用,这就是内部类引发的内存泄露问题。

    Adapter在这个问题上并无其余状况须要咱们特别谨慎,就算须要传递Adapter执行某些行为,那也是相关业务的需求,一旦脱离这些场景,Adapter也就再也不被任何人持有,除非执意要把Adapter交给一个应用生命周期的类持有。

    谷歌自己的ViewHolder是静态修饰的,由于ViewHolder通常都是内部类的形式,而Java中,对于内部类的建议都是采用静态的修饰,若是没有必要持有外部类引用的话。

    静态内部类的闯将不依赖于外部类,所以在建立静态内部类的实例时,无需建立外部类的实例,而内部类则须要,由于静态内部类能够节省建立外部类的内存开销。而正如咱们上面所说的,ListView缓存的是一屏幕的item的View,所以convertView在开始的时候会初始化屡次,次数为一屏幕的item + 1次,而ViewHolder一样也会实例化屡次,而ViewHolder是内部类,持有外部类Adapter的引用,而Adapter自己又持有Activity的Context,虽然不至于形成内存泄露,只要咱们不对Adapter乱来的话,可是这部分的内存分配开销却会形成浪费,而静态的ViewHolder则解决了这个问题。

    ViewHolder是否静态,影响并非很大,但养成良好的编码习惯,倒是很重要的,内部类形成的隐藏内存泄露,是很难觉察到的。

    至此,咱们能够知道,ViewHolder其实并非多高深的技术,也不是什么复杂的设计模式,无非就是一个巧妙的编码策略,经过convertView的setTag将对应的组件缓存起来,咱们甚至能够不用这个ViewHolder也能够作到相似的,像是使用一个HashMap,记录每一个位置的组件。

    不过这里咱们得注意一个特别的组件:ImageView。

    ImageView是图片加载组件,而加载的图片资源若是过大,就会形成OOM,若是图片是异步加载的URL地址,不作处理还会形成图片错乱的问题,由于滑动的时候,图片还没下载下来,可是对应位置的item已经滑出屏幕了,这时就会加载到下一屏的item上,由于这两个位置的view实际上是同一个。

    网上关于解决这个问题,是在对应的ImageView再设置一个tag值,而后存储对应的URL,而后在每次加载的时候,都要将下载的URL和这个存储的URL进行对比,若是相同,才加载已经下载下来的图片。

    列表在滑动的时候,会大量启动异步任务来下载图片,快速滑动的时候,假设第一个item的图片还没下载完,已经滑到了对应的第10个item,而这个item和第一个item都是指向同一个组件,此时第一个item的图片已经下载完了,就会将图片加载到本身持有的组件上,而这个组件虽然仍是第一个item上的组件,但实际上此时已是第10个item了,因此这时加个判断,就能预防图片错乱。

    这时咱们就会意识到:列表的图片异步加载是一个多么蛋疼的地方。想一想看,在快速滑动的时候,会启动多少个异步线程在下载图片,哪怕这个item已经滑出了屏幕,并且若是没有作啥处理的话,就算滑回来,也是一样的开启一个新的异步下载线程。

    开启下载线程的开销是不小的,加上图片加载自己也占内存,所以大量图片的列表在滑动的时候,很是容易就OOM了。

    因此为了减小异步下载线程,咱们须要图片缓存。

    图片缓存的实现有不少,咱们尽可能选择成熟的图片加载框架,像是ImageLoader或者Glide,没有必要本身造轮子,但要知道对应框架的大概原理。

    ImageLoader是分配了固定的图片内存,这块的处理并不复杂,实质就是一个LinkedHashMap,以图片的url+图片的宽高为key值,存放对应的bitmap,每次put进来的时候,若是该key已经存在对应的bitmap,就会减去这个bitmap的size,而后再和设置好的最大图片内存进行比较,比较的方法就是计算LinkedHashMap的总大小,若是大于最大值,则会移除第一个元素,而后再从新对比。

    移除第一个元素看似影响很大,由于这个bitmap也是缓存下来的,也是可能须要用到的,可是想到缓存的图片大小都超过了内置的最大内存,并且第一个元素已是滑出屏幕很远的元素了,也就是使用频率最低的元素,优先处理使用频率最低的元素是符合常理的。

    这也是使用LinkedHashMap的缘由,它可以保证元素的先进先出,而HashMap就是乱序的。

    这部分的逻辑具体能够在ImageLoder的DefaultConfigurationFactory和LruMemoryCache中看相关的代码。

    咱们来看看ImageLoader是怎么解决上面开线程的问题。

    线程确定是不能随便乱开的,一个应用的内存空间有限,而线程开销并不小,所以必需要经过某种机制来解决这个线程调度的问题。

    Java自己提供了对应的库,不过咱们须要针对实际的状况本身作些处理。

    思路很简单:线程的总数是固定的,一个抽象负责这些线程的调度,包括建立和回收,这个抽象就是线程池。

    线程池的知识很是多,这里就只是大概说一下ImageLoader是如何避免多开线程同时又能知足多个图片下载的需求。

    线程池能够限制容许运行的线程的数量,通常状况是按照CPU的个数 * 2 + 1,ImageLoader刚出来的时候,双核手机仍是主流,因此以至于如今看到的不少有关ImageLoader的线程数量限制,都是5,不过通常5个线程就差很少了。

    为何是CPU的个数 * 2 + 1呢?

    咱们要理解并发和并行的意思。

    并发是指同一个时间间隔内多个事件进行,而并行是指同一个时刻进行。线程并发实际上的要求就是:同一个时间间隔内多个事件可以快速进行,因此事件的执行速度和结束完毕后另外一个事件的执行就显得很重要了,由于同一个时刻实际上只是一个事件在进行,因此若是这个事件一直在执行,其余事件就得排队,这就是堵塞。

    线程池经过队列来实现并发。咱们能够指定一个队列,这个队列用于存放还在排队的事件,假设线程池只容许5个线程的开销,那么同时进行的事件只有5个,这就是并行,而其余的事件就必须等待这些事件中某一个执行完毕,而这种吞吐能力,就是并发能力。

    这些只要交给ImageLoader本身自己就能够了。

    ImageLoader是使用HttpURLConnection来下载图片,下载完后,若是配置有指定磁盘缓存,就会放到磁盘缓存里面,而后下次取图片的时候,就会从磁盘缓存里面取。

    ImageLoader自己并无对图片是否错乱这个现象作处理,实际的使用咱们能够看到图片是有可能一开始是错的,不事后面又从新刷新变成对的,那是由于队列中该位置的任务已经执行完毕了,就会对这个item的控件从新渲染。

    因此ImageLoader自己的任务就是帮助咱们解决图片下载缓存的工做,可是至于滑动卡顿和图片错乱等上层业务的问题,它是没有必要去解决的。

    使用ImageLoader在快速滑动的时候,会形成内存抖动,由于它频繁的去计算当前的内存是否已经达到最大值,会常常的检查和释放,而内存抖动是会致使卡顿的,加上滑动时候任务不断执行,执行完毕的时候会对控件进行从新渲染,这时候从人的感官来看,也是有所谓的闪动现象,就是控件原有的图片被清除掉,短暂的出现白屏现象。

    咱们能够在滑动的时候中止加载图片,可是滑动结束后,图片的加载就会很慢,有可能它的任务那时候还没开始。

    影响到图片加载慢的因素除了咱们须要下载图片(虽然这个过程一般都是异步的,但正如上面说的,异步任务的数目不会是无限的,而且须要排队),还有一个因素就是控件自己的渲染。

    图片加载库帮咱们解决了图片下载的问题,可是控件自己的渲染,如宽高的计算,绘制图片等,一样也会影响到这个过程。

    在Android中,布局文件常见的宽高指定一般就是match_parent和wrap_content,由于Android手机的分辨率太多了。这两种方式都不指定宽高的具体指,所以控件在渲染的时候,会本身去计算,而这个计算就算已经完成,在同一个布局中的其余控件渲染的时候,也会从新计算,所以一个控件渲染屡次,是彻底正常的。

    若是咱们能在一开始就指定控件的宽高,就不会再渲染的时候从新计算,可是为了适配各类分辨率,指定宽高的作法通常比较少。

    前面的状况都是咱们在作有图片内容的列表需求时都会遇到的,没有什么方案是完美的,只有根据实际状况折衷的实现方案。

    前面咱们看到ViewHolder本质就是维护item上组件的引用,减小了findViewById的调用,可是须要声明一个ViewHolder的实例。

    有没有更加简单的方案?

    答案是有的,这个方案的原理就是咱们本身经过一个数据结构来维护组件id和组件之间的绑定关系。

    咱们能够尝试使用HashMap来维护这个关联。

Map<Integer, View> viewMap = (HashMap<Integer, View>) view.getTag();  
if (viewMap == null) {  
   viewMap = new HashMap<Integer, View>();  
   view.setTag(viewMap);  
}  
View childView = viewMap.get(id);  
if (childView == null) {  
    childView = view.findViewById(id);  
    viewMap.put(id, childView);  
}  

    这和ViewHolder是一样的原理,只不过一个是类来维护一组引用,一个是数据结构存储一组引用。

    咱们还能够将HashMap的数据结构替换成谷歌的SparseArray,这是谷歌优化过的HashMap,在速度上会更快。

    最后代码以下:

public class ViewHolder {  
    public static <T extends View> T get(View view, int id) {  
        SparseArray<View> viewHolder = (SparseArray<View>) view.getTag();  
        if (viewHolder == null) {  
            viewHolder = new SparseArray<View>();  
            view.setTag(viewHolder);  
        }  
        View childView = viewHolder.get(id);  
        if (childView == null) {  
            childView = view.findViewById(id);  
            viewHolder.put(id, childView);  
        }  
        return (T) childView;  
    }  
} 

...

public View getView(..){
    if(convertView == null){
        ...
    }

    TextView tvName = (TextView)ViewHolder.get(convertView, R.id.tvName);
    return convertView;
}

   这种方式会更加简洁,可是在效率上,由于多了HashMap的查找,是会慢一点,可是在这种数据量很是少的状况下,这种查找损耗是彻底能够忽略的。

   上面就是咱们使用ListView会遇到的基本场景,若是有更加复杂的需求,那得看具体的状况,而且不少时候单靠ListView也是很难解决的,像是一行两列的布局,用GridView会更好。

   列表的控件常常会有这样的需求,一行多列,因而咱们就得在ListView和GridView间切换,但实际上,在Android中,ListView和GridView其实都是同源的东西,都是AbsListView的子类,只不过GridView作了特殊处理而已。

   基于这样的事实,谷歌推出了一个全新的类:RecyclerView。

   RecyclerView并非AbsListView的子类,它是一个全新的ViewGroup,兼具ListView和GridView的切换,还增长了一些默认的动画,更重要的是,使用RecyclerView,就必须强制性的使用ViewHolder的机制。

   使用RecyclerView并不像ListView同样,基础库就有,必需要导入本身项目中对应的兼容库版本的RecyclerView。

   RecyclerView一样也须要一个Adapter,可是这个Adapter和ListView的Adapter不同。

   RecyclerView的Adapter须要继承自Recycler.Adapter<VH extends ViewHolder>,而此次ViewHolder和咱们本身写的不同,它不再是一个简单的控件引用的集合体,它远要复杂得多,可是咱们要作的工做却和之前没两样,不过此次须要明确的继承自ViewHolder,而且实现构造器,而构造器中正是findViewById的地方。

   构造器的参数正是要传入的convertView,传入的地方是咱们要实现的onCreateiewHolder方法,而onBindViewHolder是实现数据源和对应View绑定的地方。

   咱们能够发现,RecyclerView的Adapter其实是把BaseAdapter的getView的职责拆成了onCreateViewHolder和onBindViewHolder,onCreateViewHolder负责初始化View,而onBindViewHolder负责实现数据源和View的绑定,而以往这些工做都是在getView里面。

   这就是RecyclerView的Adapter要处理的工做,它甚至和ListView的BaseAdapter在功能上,并无实质的差别,惟一的区别就是咱们不再须要判断convertView是否为空,也不须要纠结是否要写ViewHolder,而是必需要写ViewHolder。

   这就是框架的力量,框架提供了约束,正是为了保证正确性,同时又帮咱们处理了不少实现细节,提供了便利性。

   只要写好Adapter,咱们就能够和以往的ListView同样,调用setAdapter就行。

   单是看这个,RecyclerView自己并无太多的好处去诱惑咱们替换ListView,无非就是省事了,所以谷歌就为RecyclerView提供了更增强大的特性:支持ListView和GridView的切换。

   ListView和GridView在本质上来讲,是AbsListView的两种布局形式,前者是线性布局,然后者是网格状布局,这些在RecyclerView里面,就是LayoutManager的配置,而RecyclerView的LayoutManager还提供了瀑布流的布局形式,咱们只要在声明的时候,指定声明的是LinearLayoutManager(线性布局),GridLayoutManager(网格布局)或者StaggeredGridLayoutManager(瀑布流布局)。

   LayoutManager还提供了横向滚动和垂直滚动等其余设置。

   RecyclerView还经过ItemTouchHelper类实现滑动或者拖曳删除。

ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback() {

            @Override
            public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
                //actionState : action状态类型,有三类 ACTION_STATE_DRAG (拖曳),ACTION_STATE_SWIPE(滑动),ACTION_STATE_IDLE(静止)
                int dragFlags = makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP | ItemTouchHelper.DOWN
                        | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT);//支持上下左右的拖曳
                int swipeFlags = makeMovementFlags(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT);//表示支持左右的滑动
                return makeMovementFlags(dragFlags, swipeFlags);//直接返回0表示不支持拖曳和滑动
            }

            /**
             * @param recyclerView attach的RecyclerView
             * @param viewHolder 拖动的Item
             * @param target 放置Item的目标位置
             * @return
             */
            @Override
            public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
                int fromPosition = viewHolder.getAdapterPosition();//要拖曳的位置
                int toPosition = target.getAdapterPosition();//要放置的目标位置
                Collections.swap(mData, fromPosition, toPosition);//作数据的交换
                notifyItemMoved(fromPosition, toPosition);
                return true;
            }

            /**
             * @param viewHolder 滑动移除的Item
             * @param direction
             */
            @Override
            public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
                int position = viewHolder.getAdapterPosition();//获取要滑动删除的Item位置
                mData.remove(position);//删除数据
                notifyItemRemoved(position);
            }

        });
itemTouchHelper.attachToRecyclerView(mRecyclerView);

    RecyclerView甚至还提供了notifyItemChanged来实现单个item的局部刷新。

    虽然RecyclerView提供了ListView不具有的不少实用功能,可是RecyclerView不能添加HeaderView和FooterView,也不能实现item的点击事件,也没有setEmptyView的方法,表面上来看,这彷佛很是难以想象,但若是认真看过ListView是如何添加HeaderView和FooterView,就会发现,ListView实际上是经过装饰器的方式实现添加的,至于为啥没有onItemClick方法,那是由于RecyclerView自己并无实现相似的回调,这样作的缘由是啥,就不得而知了。

    撇开RecyclerView那些强大的功能,它和ListView的差异更可能是内置了ViewHolder,所以才会在前文交代了ViewHolder的由来,而是否要使用RecyclerView,就看实际的需求了,若是只是简单的列表控件,ListView其实已经够用了。

相关文章
相关标签/搜索