🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
[TOC] ## 缓存机制 ![](https://img.kancloud.cn/9b/15/9b15bf378610a84bc625c0bba2d74bcf_640x596.png) ![](https://img.kancloud.cn/1c/18/1c18fffd53c5111de8622127a985cff4_551x1041.png) **RecyclerView VS Listview** RecyclerView中mCacheViews(屏幕外)获取缓存时,是通过匹配pos获取目标位置的 ## 局部刷新 RecyclerView的缓存机制确实更加完善,但还不算质的变化,RecyclerView更大的亮点在于提供了局部刷新的接口,通过局部刷新,就能避免调用许多无用的bindView.结合RecyclerView的缓存机制,看看局部刷新是如何实现的: 以RecyclerView中notifyItemRemoved(1)为例,最终会调用requestLayout(),使整个RecyclerView重新绘制,过程为: ``` onMeasure()-->onLayout()-->onDraw() ``` 其中,onLayout()为重点,分为三步: 1. dispathLayoutStep1():记录RecyclerView刷新前列表项ItemView的各种信息,如Top,Left,Bottom,Right,用于动画的相关计算; 2. dispathLayoutStep2():真正测量布局大小,位置,核心函数为layoutChildren(); 3. dispathLayoutStep3():计算布局前后各个ItemView的状态,如Remove,Add,Move,Update等,如有必要执行相应的动画. 其中,layoutChildren()流程图: ![](https://img.kancloud.cn/9a/13/9a13d56403285e3a3bb001a55ebe9e19_573x812.png) ![](https://img.kancloud.cn/b9/c9/b9c9466266fbc77623cf18846b3a894d_354x523.png) 当调用notifyItemRemoved时,会对屏幕内ItemView做预处理,修改ItemView相应的pos以及flag(流程图中红色部分): ![](https://img.kancloud.cn/fd/c2/fdc2b13d2c23d2538701603f85e223cf_640x329.png) 当调用fill()中RecyclerView.getViewForPosition(pos)时,RecyclerView通过对pos和flag的预处理,使得bindview只调用一次. 需要指出,ListView和RecyclerView最大的区别在于数据源改变时的缓存的处理逻辑,ListView是"一锅端",将所有的mActiveViews都移入了二级缓存mScrapViews,而RecyclerView则是更加灵活地对每个View修改标志位,区分是否重新bindView。 ## 预取 虽说预取是默认开启不需要我们开发者操心的事情,但是明白原理还是能加深该功能的理解。下面就说下自己在看预取源码时的一点理解。实现预取功能的一个关键类就是gapworker,可以直接在rv源码中找到该类 ~~~undefined GapWorker mGapWorker; ~~~ rv通过在ontouchevent中触发预取的判断逻辑,在手指执行move操作的代码末尾有这么段代码 ~~~kotlin case MotionEvent.ACTION_MOVE: { ...... if (mGapWorker != null && (dx != 0 || dy != 0)) { mGapWorker.postFromTraversal(this, dx, dy); } } } break; ~~~ 通过每次move操作来判断是否预取下一个可能要显示的item数据,判断的依据就是通过传入的dx和dy得到手指接下来可能要移动的方向,如果dx或者dy的偏移量会导致下一个item要被显示出来则预取出来,但是并不是说预取下一个可能要显示的item一定都是成功的,其实每次rv取出要显示的一个item本质上就是取出一个viewholder,根据viewholder上关联的itemview来展示这个item。而取出viewholder最核心的方法就是 ~~~java tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) ~~~ 名字是不是有点长,在rv源码中你会时不时见到这种巨长的方法名,看方法的参数也能找到和预取有关的信息,deadlineNs的一般取值有两种,一种是为了兼容版本25之前没有预取机制的情况,兼容25之前的参数为 ~~~java static final long FOREVER_NS = Long.MAX_VALUE; ~~~ ,另一种就是实际的deadline数值,超过这个deadline则表示预取失败,这个其实也好理解,预取机制的主要目的就是提高rv整体滑动的流畅性,如果要预取的viewholder会造成下一帧显示卡顿强行预取的话那就有点本末倒置了。 关于预取成功的条件通过调用 ~~~java boolean willCreateInTime(int viewType, long approxCurrentNs, long deadlineNs) { long expectedDurationNs = getScrapDataForType(viewType).mCreateRunningAverageNs; return expectedDurationNs == 0 || (approxCurrentNs + expectedDurationNs < deadlineNs); } ~~~ 来进行判断,approxCurrentNs的值为 ~~~csharp long start = getNanoTime(); if (deadlineNs != FOREVER_NS && !mRecyclerPool.willCreateInTime(type, start, deadlineNs)) { // abort - we have a deadline we can't meet return null; } ~~~ 而mCreateRunningAverageNs就是创建同type的holder的平均时间,感兴趣的可以去看下这个值如何得到,不难理解就不贴代码了。关于预取就说到这里,感兴趣的可以自己去看下其余代码的实现方式,可以说google对于rv还是相当重视的,煞费苦心提高rv的各种性能,据说最近推出的viewpager2控件就是通过rv来实现的,大有rv控件一统天下的感觉。 ## 业务优化 ### 降低item的布局层次 其实这个优化不光适用于rv,activity的布局优化也同样适用,降低页面层次可以一定程度降低cpu渲染数据的时间成本,反应到rv中就是降低mCreateRunningAverageNs的时间,不光目前显示的页面能加快速度,预取的成功率也能提高,关于如何降低布局层次还是要推荐下google的强大控件ConstraintLayout,具体使用就自行百度吧,比较容易上手,这里吐槽下另一个控件CoordinatorLayout的上手难度确实是有点大啊,不了解CoordinatorLayout源码可能会遇到一些奇葩问题。降低item的布局层次可以说是rv优化中一个对于rv源码不需要了解也能完全掌握的有效方式。 ### 去除冗余的setitemclick事件 rv和listview一个比较大的不同之处在于rv居然没有提供setitemclicklistener方法,这是当初自己在使用rv时一个非常不理解的地方,其实现在也不是太理解,但是好在我们可以很方便的实现该功能,一种最简单的方式就是直接在onbindview方法中设置,这其实是一种不太可取的方式,onbindview在item进入屏幕的时候都会被调用到(cached缓存着的除外),而一般情况下都会创建一个匿名内部类来实现setitemclick,这就会导致在rv快速滑动时创建很多对象,从这点考虑的话setitemclick应该放置到其他地方更为合适 自己的做法就是将setitemclick事件的绑定和viewholder对应的rootview进行绑定,viewholer由于缓存机制的存在它创建的个数是一定的,所以和它绑定的setitemclick对象也是一定的。还有另一种做法可以通过rv自带的addOnItemTouchListener来实现点击事件,原理就是rv在触摸事件中会使用到addOnItemTouchListener中设置的对象,然后配合GestureDetectorCompat实现点击item,示例代码如下: ~~~java recyclerView.addOnItemTouchListener(this); gestureDetectorCompat = new GestureDetectorCompat(recyclerView.getContext(), new SingleClick()); @Override public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { if (gestureDetectorCompat != null) { gestureDetectorCompat.onTouchEvent(e); } return false; } private class SingleClick extends GestureDetector.SimpleOnGestureListener { @Override public boolean onSingleTapConfirmed(MotionEvent e) { View view = recyclerView.findChildViewUnder(e.getX(), e.getY()); if (view == null) { return false; } final RecyclerView.ViewHolder viewHolder = recyclerView.getChildViewHolder(view); if (!(viewHolder instanceof ViewHolderForRecyclerView)) { return false; } final int position = getAdjustPosition(viewHolder); if (position == invalidPosition()) { return false; } /****************/ 点击事件设置可以考虑放在这里 /****************/ return true; } } ~~~ 相对来说这是一个比较优雅点的实现,但是有一点局限在于这种点击只能设置整个item的点击,如果item内部有两个textview都需要实现点击的话就可能不太适用了,所以具体使用哪种看大家的实际应用场景,可以考虑将这两种方式都封装到adapter库中,目前项目中使用的adapter库就是采用两种结合的形式。 ### 复用pool缓存 四级缓存中我已经介绍过了,复用本身并不难,调用rv的setRecycledViewPool方法设置一个pool进去就可以,但是并不是说每次使用rv场景的情况下都需要设置一个pool,这个复用pool是针对item中包含rv的情况才适用,如果rv中的item都是普通的布局就不需要复用pool ![](https://img.kancloud.cn/d4/1d/d41d08de307d69367f761f92d279e7b4_396x712.png) 如上图所示红框就是一个item中嵌套rv的例子,这种场景还是比较常见,如果有多个item都是这种类型那么复用pool就非常有必要了,在封装adapter库时需要考虑的一个点就是如何找到item中包含rv,可以考虑的做法就是遍历item的根布局如果找到包含rv的,那么将对该rv设置pool,所有item中的嵌套rv都使用同一个pool即可,查找item中rv代码可以如下 ~~~php private List<RecyclerView> findNestedRecyclerView(View rootView) { List<RecyclerView> list = new ArrayList<>(); if (rootView instanceof RecyclerView) { list.add((RecyclerView) rootView); return list; } if (!(rootView instanceof ViewGroup)) { return list; } final ViewGroup parent = (ViewGroup) rootView; final int count = parent.getChildCount(); for (int i = 0; i < count; i++) { View child = parent.getChildAt(i); list.addAll(findNestedRecyclerView(child)); } return list; } ~~~ 得到该list之后接下来要做的就是给里面的rv绑定pool了,可以将该pool设置为adapter库中的成员变量,每次找到嵌套rv的item时直接将该pool设置给对应的rv即可。 关于使用pool源码上有一点需要在意的是,当最外层的rv滑动导致item被移除屏幕时,rv其实最终是通过调用 removeview(view)完成的,里面的参数view就是和holder绑定的rootview,如果rootview中包含了rv,也就是上图所示的情况,会最终调用到嵌套rv的onDetachedFromWindow方法 ~~~java @Override public void onDetachedFromWindow(RecyclerView view, RecyclerView.Recycler recycler) { super.onDetachedFromWindow(view, recycler); if (mRecycleChildrenOnDetach) { removeAndRecycleAllViews(recycler); recycler.clear(); } } ~~~ 注意里面的if分支,如果进入该分支里面的主要逻辑就是会清除掉scrap和cached缓存上的holder并将它们放置到pool中,但是默认情况下mRecycleChildrenOnDetach是为false的,这么设计的目的就在于放置到pool中的holder要想被拿来使用还必须调用onbindview来进行重新绑定数据,所以google默认将该参数设置为了false,这样即使rv会移除屏幕也不会使里面的holder失效,下次再次进入屏幕的时候就可以直接使用避免了onbindview的操作。 但是google还是提供了setRecycleChildrenOnDetach方法允许我们改变它的值,如果要想充分使用pool的功能,最好将其置为true,因为按照一般的用户习惯滑出屏幕的item一般不会回滚查看,这样接下来要被滑入的item如果存在rv的情况下就可以快速复用pool中的holder,这是使用pool复用的时候一个需要注意点的地方。 ### 保存嵌套rv的滑动状态 原来开发的时候产品就提出过这种需求,需要将滑动位置进行保存,否则每次位置被重置开起来非常奇怪,具体是个什么问题呢,还是以上图嵌套rv为例,红框中的rv可以看出来是滑动到中间位置的,如果这时将该rv移出屏幕,然后再移动回屏幕会发生什么事情,这里要分两种情况,一种是移出屏幕一点后就直接重新移回屏幕,另一种是移出屏幕一段距离再移回来,你会发现一个比较神奇的事就是移出一点回来的rv会保留原先的滑动状态,而移出一大段距离后回来的rv会丢失掉原先的滑动状态,造成这个原因的本质是在于rv的缓存机制,简单来说就是刚滑动屏幕的会被放到cache中而滑出一段距离的会被放到pool中,而从pool中取出的holder会重新进行数据绑定,没有保存滑动状态的话rv就会被重置掉,那么如何才能做到即使放在pool中的holder也能保存滑动状态。 其实这个问题google也替我们考虑到了,linearlayoutmanager中有对应的onSaveInstanceState和onRestoreInstanceState方法来分别处理保存状态和恢复状态,它的机制其实和activity的状态恢复非常类似,我们需要做的就是当rv被移除屏幕调用onSaveInstanceState,移回来时调用onRestoreInstanceState即可。 需要注意点的是onRestoreInstanceState需要传入一个参数parcelable,这个是onSaveInstanceState提供给我们的,parcelable里面就保存了当前的滑动位置信息,如果自己在封装adapter库的时候就需要将这个parcelable保存起来 ~~~cpp private Map<Integer, SparseArrayCompat<Parcelable>> states; ~~~ map中的key为item对应的position,考虑到一个item中可能嵌套多个rv所以value为SparseArrayCompat,最终的效果 ![](https://img.kancloud.cn/8d/a5/8da58665a1a9a419f14d83e52ae28cfa_360x584.png) 可以看到几个rv在被移出屏幕后再移回来能够正确保存滑动的位置信息,并且在删除其中一个item后states中的信息也能得到同步的更新,更新的实现就是利用rv的registerAdapterDataObserver方法,在adapter调用完notify系列方法后会在对应的回调中响应,对于map的更新操作可以放置到这些回调中进行处理。 ### 视情况设置itemanimator动画 使用过listview的都知道listview是没有item改变动画效果的,而rv默认就是支持动画效果的,之前说过rv内部源码有1万多行,其实除了rv内部做了大量优化之外,为了支持item的动画效果google也没少下苦功夫,也正是因为这样才使得rv源码看起来非常复杂。默认在开启item动画的情况下会使rv额外处理很多的逻辑判断,notify的增删改操作都会对应相应的item动画效果,所以如果你的应用不需要这些动画效果的话可以直接关闭掉,这样可以在处理增删改操作时大大简化rv的内部逻辑处理,关闭的方法直接调用setItemAnimator(null)即可。 ### diffutil一个神奇的工具类 diffutil是配合rv进行差异化比较的工具类,通过对比前后两个data数据集合,diffutil会自动给出一系列的notify操作,避免我们手动调用notifiy的繁琐,看一个简单的使用示例 ~~~csharp data = new ArrayList<>(); data.add(new MultiTypeItem(R.layout.testlayout1, "hello1")); data.add(new MultiTypeItem(R.layout.testlayout1, "hello2")); data.add(new MultiTypeItem(R.layout.testlayout1, "hello3")); data.add(new MultiTypeItem(R.layout.testlayout1, "hello4")); data.add(new MultiTypeItem(R.layout.testlayout1, "hello5")); data.add(new MultiTypeItem(R.layout.testlayout1, "hello6")); data.add(new MultiTypeItem(R.layout.testlayout1, "hello7")); newData = new ArrayList<>(); //改 newData.add(new MultiTypeItem(R.layout.testlayout1, "new one")); newData.add(new MultiTypeItem(R.layout.testlayout1, "hello2")); newData.add(new MultiTypeItem(R.layout.testlayout1, "hello3")); newData.add(new MultiTypeItem(R.layout.testlayout1, "hello4")); //增 newData.add(new MultiTypeItem(R.layout.testlayout1, "add one")); newData.add(new MultiTypeItem(R.layout.testlayout1, "hello5")); newData.add(new MultiTypeItem(R.layout.testlayout1, "hello6")); newData.add(new MultiTypeItem(R.layout.testlayout1, "hello7")); ~~~ 先准备两个数据集合分别代表原数据集和最新的数据集,然后实现下Callback接口 ~~~java private class DiffCallBack extends DiffUtil.Callback { @Override public int getOldListSize() { return data.size(); } @Override public int getNewListSize() { return newData.size(); } @Override public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { return data.get(oldItemPosition).getType() == newData.get(newItemPosition).getType(); } @Override public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { String oldStr = (String) DiffUtilDemoActivity.this.data.get(oldItemPosition).getData(); String newStr = (String) DiffUtilDemoActivity.this.newData.get(newItemPosition).getData(); return oldStr.equals(newStr); } } ~~~ 实现的方法比较容易看懂,diffutil之所以能判断两个数据集的差距就是通过调用上述方法实现,areItemsTheSame表示的就是两个数据集对应position上的itemtype是否一样,areContentsTheSame就是比较在itemtype一致的情况下item中内容是否相同,可以理解成是否需要对item进行局部刷新。实现完callback之后接下来就是如何调用了。 ~~~cpp DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffCallBack(), true); diffResult.dispatchUpdatesTo(adapter); adapter.setData(newData); ~~~ 上述就是diffutil一个简单的代码范例,其实最开始的时候自己想将diffutil封装到adapter库,但实际在使用后发现了几个自认为的弊端,所以放弃使用该工具类,这也可能是自己没有完全掌握diffutil精髓所导致的吧,这里就直接说下我对diffutil使用的看法。 弊端一: 看示例代码应该也能察觉到,要想使用diffutil必须准备两个数据集,这就是一个比较蛋疼的事情,原先我们只需要维护一个数据集就可以,现在就需要我们同时维护两个数据集,两个数据集都需要有一份自己的数据,如果只是简单将数据从一个集合copy到另一个集合是可能会导致问题的,会涉及到对象的深拷贝和浅拷贝问题,你必须保证两份数据集都有各自独立的内存,否则当你修改其中一个数据集可能会造成另一个数据集同时被修改掉的情况。 弊端二: 为了实现callback接口必须实现四个方法,其中areContentsTheSame是最难实现的一个方法,因为这里涉及到对比同type的item内容是否一致,这就需要将该item对应的数据bean进行比较,怎么比较效率会高点,目前能想到的方法就是将bean转换成string通过调用equals方法进行比较,如果item的数据bean对应的成员变量很少如示例所示那倒还好,这也是网上很多推荐diffutil文章避开的问题。但是如果bean对应的成员很多,或者成员变量含有list,里面又包含各种对象元素,想想就知道areContentsTheSame很难去实现,为了引入一个diffutil额外增加这么多的逻辑判断有点得不偿失。 弊端三: diffutil看起来让人捉摸不透的item动画行为,以上面代码为例 ~~~csharp newData.add(new MultiTypeItem(R.layout.testlayout1, "hello1")); newData.add(new MultiTypeItem(R.layout.testlayout1, "hello2")); // newData.add(new MultiTypeItem(R.layout.testlayout1, "hello3")); newData.add(new MultiTypeItem(R.layout.testlayout1, "hello4")); newData.add(new MultiTypeItem(R.layout.testlayout1, "hello5")); newData.add(new MultiTypeItem(R.layout.testlayout1, "hello6")); newData.add(new MultiTypeItem(R.layout.testlayout1, "hello7")); ~~~ 新的数据集和原有数据集唯一的不同点就在于中间删除了一条数据,按照原先我们对于rv的理解,执行的表现形式应该是hello3被删除掉,然后hello3下面的所有item整体上移才对,但在使用diffutil后你会发现并不是这样的,它的表现比较怪异会移除第一条数据,这种怪异的行为应该和diffutil内部复杂的算法有关。 基于上述几个弊端所以最终自己并没有在adapter库去使用diffutil,比较有意思的是之前在看关于diffutil文章的时候特意留言问过其中一个作者在实际开发中是否有使用过diffutil,得到的答案是并没有在实际项目使用过,所以对于一些工具类是否真的好用还需要实际项目来检验,当然上面所说的都只是我的理解,不排除有人能透彻理解diffutil活用它的开发者,只是我没有在网上找到这种文章。 ### setHasFixedSize **设置setHasFixedSize,这么做的一个最大的好处就是嵌套的rv不会触发requestLayout,从而不会导致外层的rv进行重绘。** 又是一个google提供给我们的方法,主要作用就是设置固定高度的rv,避免rv重复measure调用。这个方法可以配合rv的wrap\_content属性来使用,比如一个垂直滚动的rv,它的height属性设置为wrap\_content,最初的时候数据集data只有3条数据,全部展示出来也不能使rv撑满整个屏幕,如果这时我们通过调用notifyItemRangeInserted增加一条数据,在设置setHasFixedSize和没有设置setHasFixedSize你会发现rv的高度是不一样的,设置过setHasFixedSize属性的rv高度不会改变,而没有设置过则rv会重新measure它的高度,这是setHasFixedSize表现出来的外在形式,我们可以从代码层来找到其中的原因。 notifiy的一系列方法除了notifyDataSetChanged这种万金油的方式,还有一系列进行局部刷新的方法可供调用,而这些方法最终都会执行到一个方法 ~~~cpp void triggerUpdateProcessor() { if (POST_UPDATES_ON_ANIMATION && mHasFixedSize && mIsAttached) { ViewCompat.postOnAnimation(RecyclerView.this, mUpdateChildViewsRunnable); } else { mAdapterUpdateDuringMeasure = true; requestLayout(); } } ~~~ 区别就在于当设置过setHasFixedSize会走if分支,而没有设置则进入到else分支,else分支直接会调用到requestLayout方法,该方法会导致视图树进行重新绘制,onmeasure,onlayout最终都会被执行到,结合这点再来看为什么rv的高度属性为wrap\_content时会受到setHasFixedSize影响就很清楚了,根据上述源码可以得到一个优化的地方在于,当item嵌套了rv并且rv没有设置wrap\_content属性时,我们可以对该rv设置setHasFixedSize,这么做的一个最大的好处就是嵌套的rv不会触发requestLayout,从而不会导致外层的rv进行重绘。 # 参考资料 [RecyclerView 最深最全剖析,所有面试点都get到了](https://blog.csdn.net/xJ032w2j4cCjhOW8s8/article/details/89007962) [RecyclerView剖析](https://blog.csdn.net/qq_23012315/article/details/50807224) [RecyclerView剖析——续一](https://blog.csdn.net/qq_23012315/article/details/51096696) [Android RecyclerView 局部刷新分析](https://blog.csdn.net/fei20121106/article/details/108121169) [RecyclerView一些你可能需要知道的优化技术](https://www.jianshu.com/p/1d2213f303fc) [Android ListView与RecyclerView对比浅析--缓存机制](https://mp.weixin.qq.com/s/_1-5REzMQibPLcK79Hz4gg)