从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是很不值得的行为。
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其实已经够用了。