当谈到RecyclerView的时候,复用机制是我们能脱口而出的优点之一。系统内置的ViewHolder避免了我们使用ListView时手动去创建ViewHolder的麻烦。但是关于何时回收View,何时复用View,我们真的能做到胸有成竹吗?当我们滑动一个RecyclerView时,是先回收View,再复用View?还是先复用View,再回收View呢?答案是都有可能。具体分析且看下面分析。
本文大纲
- 滑动RV的两个场景
- DEMO验证答案
- 滑动原理讲解
- 源码分析
01
—
滑动RV的两个场景
场景一 RV中每个Item高度都为100px,最后一个Item超出屏幕50px。RV初始状态如下图
Q1 假设向上滑动40px
请问是否有View发生回收和复用?如果有,先复用还是先回收?答 回收和复用都没有发生
Q2 假设向上滑动60px请问是否有View发生回收和复用?如果有,先复用还是先回收?答 没有发生回收,发生了复用
Q3 假设向上滑动120px请问是否有View发生回收和复用?如果有,先复用还是先回收?答 发生了回收和复用。先复用后回收
场景二 RV中第一个Item高度为50px,其它都为100px,最后一个Item超出屏幕95px。RV初始状态如下
Q1 假设向上滑动40px请问是否有View发生回收和复用?如果有,先复用还是先回收?答 回收和复用都没有发生
Q2 假设向上滑动60px请问是否有View发生回收和复用?如果有,先复用还是先回收?答 发生了回收,没有发生复用
Q3 假设向上滑动120px请问是否有View发生回收和复用?如果有,先复用还是先回收?答 发生了回收和复用。先回收后复用
从答案可以看出。回收和复用并没有固定的答案。它因场景而异。此时你会提出质疑了,上面的答案只是你片面之词。我凭什么相信你的答案就是正确的。那我们进入第二个环节,验证答案真伪。
02
—
DEMO验证答案
我们来验证场景一
程序运行图
程序代码
class RecyclerViewActivity1 : AppCompatActivity() { private lateinit var mRecyclerView: RecyclerView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_recycler_view1) mRecyclerView = findViewById(R.id.recyclerview) mRecyclerView.setHasFixedSize(true) mRecyclerView.setItemViewCacheSize(0) mRecyclerView.layoutManager = LinearLayoutManager(this).apply { orientation = LinearLayoutManager.VERTICAL isItemPrefetchEnabled = false } val list: MutableList<String> = ArrayList() repeat(100) { list.add("item $it") } mRecyclerView.adapter = MyAdapter(list) } inner class MyAdapter(val mStrings: MutableList<String>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { println("RecyclerView 场景一 onCreateViewHolder ") val view = LayoutInflater.from(parent.context) .inflate(R.layout.view_item, parent, false) return object : RecyclerView.ViewHolder(view) {} } override fun getItemCount(): Int { return mStrings.size } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { println("RecyclerView 场景一 onBindViewHolder $position ") val textView = holder.itemView as TextView textView.layoutParams.height = (resources.displayMetrics.density * 100).toInt() textView.text = mStrings[position] } override fun onViewRecycled(holder: RecyclerView.ViewHolder) { println("RecyclerView 场景一 发生回收 " + (holder.itemView as TextView).text) super.onViewRecycled(holder) } } fun scroll120(view: View) { mRecyclerView.scrollBy(0, (resources.displayMetrics.density * 120).toInt()) } fun scroll60(view: View) { mRecyclerView.scrollBy(0, (resources.displayMetrics.density * 60).toInt()) } fun scroll40(view: View) { mRecyclerView.scrollBy(0, (resources.displayMetrics.density * 40).toInt()) } }
日志输出
首先进入初始状态
点击上滑40px。打印日志不变。证明 回收和复用都没有发生
点击上滑60px。打印日志如下。证明 没有发生回收,发生了复用
点击上滑动120px。打印日志如下。证明 发生了回收和复用。先复用后回收
我们来验证场景二
程序运行图
程序代码
class RecyclerViewActivity2 : AppCompatActivity() { private lateinit var mRecyclerView: RecyclerView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_recycler_view2) mRecyclerView = findViewById(R.id.recyclerview) mRecyclerView.setHasFixedSize(true) mRecyclerView.setItemViewCacheSize(0) mRecyclerView.layoutManager = LinearLayoutManager(this).apply { orientation = LinearLayoutManager.VERTICAL isItemPrefetchEnabled = false } val list: MutableList<String> = ArrayList() repeat(100) { list.add("item $it") } mRecyclerView.adapter = MyAdapter(list) } inner class MyAdapter(val mStrings: MutableList<String>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { println("RecyclerView 场景二 onCreateViewHolder ") val view = LayoutInflater.from(parent.context) .inflate(R.layout.view_item, parent, false) return object : RecyclerView.ViewHolder(view) {} } override fun getItemCount(): Int { return mStrings.size } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { println("RecyclerView 场景二 onBindViewHolder $position ") val textView = holder.itemView as TextView textView.layoutParams.height = if (position == 0) (resources.displayMetrics.density * 50).toInt() else (resources.displayMetrics.density * 100).toInt() textView.text = mStrings[position] } override fun onViewRecycled(holder: RecyclerView.ViewHolder) { println("RecyclerView 场景二 发生回收 " + (holder.itemView as TextView).text) super.onViewRecycled(holder) } } fun scroll120(view: View) { mRecyclerView.scrollBy(0, (resources.displayMetrics.density * 120).toInt()) } fun scroll60(view: View) { mRecyclerView.scrollBy(0, (resources.displayMetrics.density * 60).toInt()) } fun scroll40(view: View) { mRecyclerView.scrollBy(0, (resources.displayMetrics.density * 40).toInt()) } }
日志输出
首先进入初始状态
点击上滑40px。打印日志不变。证明 回收和复用都没有发生
点击上滑60px。打印日志如下。证明 发生回收,没有发生复用
点击上滑动120px。打印日志如下。证明 发生了回收和复用。先回收后复用
03
—
滑动原理分析
如图所示,介绍几个关于坐标的参数
- delta:手指滑动的距离120px。
- mOffset:RV最后一个子View的Bottom在屏幕坐标系的Y坐标600px。RV的下一个View(Item7)从mOffset处布局。
- mScrollingOffset:RV最后一个子view的Bottom距离RV Bottom的距离50px。向上滑动不超过该距离。如需创建新的View。
- mVailable:delta-mScrollingOffset。可以填充View的空间。如果大于0表示有空间填充新的View
如果delta<mScrollingOffset,mScrollingOffset=delta,mVailable<0
滑动逻辑如下
- 从RecyclerView的第0个View开始遍历,直到View的Bottom>mScrollingOffset,并记录该View的下标index,回收[0,index)区间的View,index为开区间,如果index=0则不发生回收,会调用RV的removeView方法。具体回收算法先按下不表。
- 如果mVailable>0,则从mOffset处,用新的View填充。mOffset+=新View的高度,mVailable-=新View的高度,mScrollingOffset+=新View的高度,如果mVailable<0,mScrollingOffset+=mVailable。布局完成后用步骤1的算法按需回收上面的View。
- 重复步骤2
- 将RV整体,向上移动delta或者consumed距离
根据此滑动逻辑,我们分析场景一中的向上滑动120px
mOffset = 600px
mScrollingOffset = 50px
mAvailable = 70px
item1高度100px
- 首先从第0个View遍历Bottom>50px。找到item1.bottom=100px,记录index=0。因为0<1。所以不发生回收
- mAvailable>0,从Item6的底部,增加View Item7(此处发生复用逻辑)高度为100px,mOffset=700px,mOffset=-30,mAvailable=-30,mScrollingOffset=mScrollingOffset+100-30=120px。然后检查回收。首先从第0个View遍历Bottom>120px。找到item2.bottom=200px,记录index=1。回收[0,1)区间的View。即回收Item1
- mAvailable=-30<0,推出填充逻辑
- 整体向上移动120px
我们看到先创建Item7 然后回收Item1。跟日志相符合
同样的逻辑我们也可以分析场景二中的向上滑动120px的情况。场景二会先发生回收,再发生复用。读者可以自己去求证。
03
—
源码分析
RV的滑动,最终会调用LayoutManager的scrollBy方法。我们使用的是LinearLayoutManager。
//LinearLayoutManager.java int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) { if (getChildCount() == 0 || delta == 0) { return 0; } ensureLayoutState(); mLayoutState.mRecycle = true; final int layoutDirection = delta > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START; final int absDelta = Math.abs(delta); updateLayoutState(layoutDirection, absDelta, true, state); final int consumed = mLayoutState.mScrollingOffset + (recycler, mLayoutState, state, false); if (consumed < 0) { if (DEBUG) { Log.d(TAG, "Don't have any more elements to scroll"); } return 0; } final int scrolled = absDelta > consumed ? layoutDirection * consumed : delta; mOrientationHelper.offsetChildren(-scrolled); if (DEBUG) { Log.d(TAG, "scroll req: " + delta + " scrolled: " + scrolled); } mLayoutState.mLastScrollDelta = scrolled; return scrolled; }
第10行updateLayoutState方法,主要是计算mOffset等参数。
第11行fill方法,根据剩余空间,填充View
第21行offsetChildren,整体移动RV的子View
//主要是计算 private void updateLayoutState(int layoutDirection, int requiredSpace, boolean canUseExistingSpace, RecyclerView.State state) { // If parent provides a hint, don't measure unlimited. mLayoutState.mInfinite = resolveIsInfinite(); mLayoutState.mLayoutDirection = layoutDirection; mReusableIntPair[0] = 0; mReusableIntPair[1] = 0; calculateExtraLayoutSpace(state, mReusableIntPair); int extraForStart = Math.max(0, mReusableIntPair[0]); int extraForEnd = Math.max(0, mReusableIntPair[1]); boolean layoutToEnd = layoutDirection == LayoutState.LAYOUT_END; mLayoutState.mExtraFillSpace = layoutToEnd ? extraForEnd : extraForStart; mLayoutState.mNoRecycleSpace = layoutToEnd ? extraForStart : extraForEnd; int scrollingOffset; if (layoutToEnd) { mLayoutState.mExtraFillSpace += mOrientationHelper.getEndPadding(); // get the first child in the direction we are going final View child = getChildClosestToEnd(); // the direction in which we are traversing children mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_HEAD : LayoutState.ITEM_DIRECTION_TAIL; mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection; mLayoutState.mOffset = mOrientationHelper.getDecoratedEnd(child); // calculate how much we can scroll without adding new children (independent of layout) scrollingOffset = mOrientationHelper.getDecoratedEnd(child) - mOrientationHelper.getEndAfterPadding(); } else { final View child = getChildClosestToStart(); mLayoutState.mExtraFillSpace += mOrientationHelper.getStartAfterPadding(); mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_TAIL : LayoutState.ITEM_DIRECTION_HEAD; mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection; mLayoutState.mOffset = mOrientationHelper.getDecoratedStart(child); scrollingOffset = -mOrientationHelper.getDecoratedStart(child) + mOrientationHelper.getStartAfterPadding(); } mLayoutState.mAvailable = requiredSpace; if (canUseExistingSpace) { mLayoutState.mAvailable -= scrollingOffset; } mLayoutState.mScrollingOffset = scrollingOffset; }
int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) { // max offset we should set is mFastScroll + available final int start = layoutState.mAvailable; if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) { // TODO ugly bug fix. should not happen if (layoutState.mAvailable < 0) { layoutState.mScrollingOffset += layoutState.mAvailable; } recycleByLayoutState(recycler, layoutState); } int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace; LayoutChunkResult layoutChunkResult = mLayoutChunkResult; while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) { layoutChunkResult.resetInternal(); if (RecyclerView.VERBOSE_TRACING) { TraceCompat.beginSection("LLM LayoutChunk"); } layoutChunk(recycler, state, layoutState, layoutChunkResult); if (RecyclerView.VERBOSE_TRACING) { TraceCompat.endSection(); } if (layoutChunkResult.mFinished) { break; } layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection; /** * Consume the available space if: * * layoutChunk did not request to be ignored * * OR we are laying out scrap children * * OR we are not doing pre-layout */ if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null || !state.isPreLayout()) { layoutState.mAvailable -= layoutChunkResult.mConsumed; // we keep a separate remaining space because mAvailable is important for recycling remainingSpace -= layoutChunkResult.mConsumed; } if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) { layoutState.mScrollingOffset += layoutChunkResult.mConsumed; if (layoutState.mAvailable < 0) { layoutState.mScrollingOffset += layoutState.mAvailable; } recycleByLayoutState(recycler, layoutState); } if (stopOnFocusable && layoutChunkResult.mFocusable) { break; } } if (DEBUG) { validateChildOrder(); } return start - layoutState.mAvailable; }
第10行,首先判断是否需要回收View。
第14行,根据剩余空间,判断是否需要,第19行是具体的layout方法,45行是layout完成后判断是否需要回收View。
本文主要讲解了滑动时的回收和复用的逻辑。具体如何如何回收,如何复用。RecyclerView的三级缓存是如何实现的。且听下回分解。