深入理解RecyclerView与LayoutManager(二):自定义LayoutManager注意事项

上一篇主要介绍了RecyclerView的整体流程,最后也介绍了LayoutManager的作用——LayoutManager的工作实际上是帮助RecyclerView决定子View的位置。在正式实践之前,还得先梳理下自定义LayoutManager的流程以及一些需要注意的事项。

在使用RecyclerView的时候,必须要setLayoutManager才能正常显示页面,这是因为RecyclerView把Item都交给LayoutManager来layout了,没有layout,肯定是看不到的。

既然自定义LayoutManager也需要layout,那它跟自定义ViewGroup又有什么不同之处呢?

测量

ViewGroup是在onMeasure方法里面进行子View的测量,而在LayoutManager中,是主动调用以下两个方法来做测量:

measureChild(View child, int widthUsed, int heightUsed)
measureChildWithMargins(View child, int widthUsed, int heightUsed)

这两个方法都可以测量子View,不同的是第二个方法会把Item设置的Margin也考虑进去,所以如果LayoutManager需要支持Margin属性的话,就用第二个。

在Item测量完之后,就可以获取到Item的尺寸了,但这里并不推荐直接用getMeasuredWidth或getMeasuredHeight方法来获取,而是建议使用这两个:

getDecoratedMeasuredWidth(View child)
getDecoratedMeasuredHeight(View child)

这两个方法是LayoutManager提供的,其实它们内部也是会调用child的getMeasuredWidth或getMeasuredHeight的,只是在返回的时候,会考虑到Decorations的大小。看下源码:

public int getDecoratedMeasuredWidth(View child) {
    final Rect insets = ((RecyclerView.LayoutParams) child.getLayoutParams()).mDecorInsets;
    return child.getMeasuredWidth() + insets.left + insets.right;
}
public int getDecoratedMeasuredHeight(View child) {
    final Rect insets = ((RecyclerView.LayoutParams) child.getLayoutParams()).mDecorInsets;
    return child.getMeasuredHeight() + insets.top + insets.bottom;
}

布局

在自定义ViewGroup的时候,需要重写onLayout方法,并在里面去遍历子View,然后调用子View的layout方法来进行布局,
但在LayoutManager里对Item进行布局时,是不推荐直接使用layout方法,建议使用:

layoutDecorated(View child, int left, int top, int right, int bottom)
layoutDecoratedWithMargins(View child, int left, int top, int right, int bottom)

这两个方法也是LayoutManager提供的,使用layoutDecorated方法的话,它会给ItemDecorations腾出位置,如果要考虑Margin,就用第二个方法。来看下源码就明白了:

public void layoutDecorated(View child, int left, int top, int right, int bottom) {
    final Rect insets = ((LayoutParams) child.getLayoutParams()).mDecorInsets;
    child.layout(left + insets.left, top + insets.top, right - insets.right,
            bottom - insets.bottom);
}

public void layoutDecoratedWithMargins(View child, int left, int top, int right, int bottom) {
    final RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child.getLayoutParams();
    final Rect insets = lp.mDecorInsets;
    child.layout(left + insets.left + lp.leftMargin, top + insets.top + lp.topMargin,
            right - insets.right - lp.rightMargin,
            bottom - insets.bottom - lp.bottomMargin);
}

在layout的时候,的确是考虑到Decoration的大小,并把child的尺寸对应地缩小了一下。

基于以上测量和布局的方法就能很方便的添加子View了。

LayoutManager还提供了getDecoratedXXX等一系列方法,有了这些方法,就可以跟ItemDecorations无缝配合,打造出想要的任何效果。

自定义LayoutManager基本流程

在自定义ViewGroup中,想要显示子View,无非就三件事:

    1. 添加 通过addView方法把子View添加进ViewGroup或直接在xml中直接添加;
    2. 测量 重写onMeasure方法并在这里决定自身尺寸以及每一个子View大小;
    3. 布局 重写onLayout方法,在里面调用子View的layout方法来确定它的位置和尺寸;

其实在自定义LayoutManager中,在流程上也是差不多的,需要重写onLayoutChildren方法,这个方法会在初始化或者Adapter数据集更新时回调,在这方法里面,需要做以下事情:

    1. 进行布局之前,需要调用detachAndScrapAttachedViews方法把屏幕中的Items都分离出来,内部调整好位置和数据后,再把它添加回去(如果需要的话);
    2. 分离了之后,就要想办法把它们再添加回去了,所以需要通过addView方法来添加,那这些View在哪里得到呢? 需要调用 Recycler的getViewForPosition(int position) 方法来获取;
    3. 获取到Item并重新添加了之后,还需要对它进行测量,这时候可以调用measureChild或measureChildWithMargins方法;
    4. 在测量完之后就是布局了,也是根据需求来决定使用layoutDecorated还是layoutDecoratedWithMargins方法;
    5. 在自定义ViewGroup中,layout完就可以运行看效果了,但在LayoutManager还有一件非常重要的事情,就是回收了,在layout之后,还要把一些不再需要的Items回收,以保证滑动的流畅度;

回收

上一篇也有大量篇幅介绍回收的流程以及各级缓存的作用,这里再次做个回顾。

一般情况下(忽略ViewCacheExtension,因为这个需要自己实现),有4个存放回收Holder的集合,分别是:

可直接重用的临时缓存:mAttachedScrap、mChangedScrap。
可直接重用的缓存:mCachedViews。
需重新绑定数据的缓存:mRecyclerPool.mScrap。
 

因为每当RecyclerView的dispatchLayout方法结束之前(当调用RecyclerView的reuqestLayout方法或者调用Adapter的一系列notify方法会回调这个dispatchLayout),它们里面的Holder都会移动到mCachedViews或mRecyclerPool.mScrap中。

它们之间的区别就是:mChangedScrap只能在预布局状态下重用,因为它里面装的都是即将要放到mRecyclerPool中的Holder,而mAttachedScrap则可以在非预布局状态下重用。

顾名思义,就是在真正布局之前,事先布局一次。但在预布局状态下,应该把已经remove掉的Item也layout出来,我们可以通过ViewHolder的LayoutParams.isViewRemoved()方法来判断这个ViewHolder是否已经被remove掉。
只有在Adapter的数据集更新时,并且调用的是除notifyDataSetChanged以外的一系列notify方法,预布局才会生效。这也是为什么调用notifyDataSetChanged方法不会播放Item动画的原因了。
这个其实有点像我们加载Bitmap的操作:先设置只读边,等获取到图片尺寸后设置好缩放比例再真正把图片加载进来。
要开启预布局的话,需要重写LayoutManager中的supportsPredictiveItemAnimations方法并return true; 这样就能生效了(当然,自带的那三个LayoutManager已经是开启了这个效果的),当Adapter的数据集更新时,onLayoutChildren方法就会回调两次,第一次是预布局,第二次是真实的布局,我们也可以通过state.isPreLayout() 来判断当前是否为预布局状态,并根据这个状态来决定要layout的Item。

detachAndScrapView(View child, Recycler recycler)
detachAndScrapViewAt(int index, Recycler recycler)

detachAndScrapAttachedViews(Recycler recycler)

前面两个方法都是回收指定的View,而第三个方法会把RecyclerView中全部未分离的子View都回收,看源码可以发现,这三个方法最终调用scrapOrRecycleView方法,来看看它里面做了什么:

private void scrapOrRecycleView(Recycler recycler, int index, View view) {

    ......

    if (viewHolder.isInvalid() && !viewHolder.isRemoved()
            && !mRecyclerView.mAdapter.hasStableIds()) {
        removeViewAt(index);
        recycler.recycleViewHolderInternal(viewHolder);
    } else {
        detachViewAt(index);
        recycler.scrapView(view);
        mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
    }
}

emmm,果然就跟方法名字一样,它会根据viewHolder的状态来决定放哪里,如果这个viewHolder已经被标记无效,并且还没有移除,又没有设置StableId的话,就会把它从RecyclerView中移除并尝试放到mRecyclerPool.mScrap中,如果没有满足以上条件的话,就会先把它分离,然后放进临时缓存(mAttachedScrap或mChangedScrap),以便稍后直接重用。

其实就是这个Item的唯一标识。
这个是需要我们自己调用Adapter的setHasStableIds(true) 来开启,还需要在Adapter中重写getItemId(int position) 方法,根据position返回一个对应的唯一id
这样一来,当LayoutManager调用上面三个回收方法时,那些Holder就永远不会被放到mRecyclerPool.mScrap中,等到LayoutManager调用getViewForPosition方法时,如果没能根据position在mAttachedScrap和mCachedViews中找到合适的Holder的话,就会根据Adapter的getItemId方法返回的id来再次从上面两个集合中找(匹配id),如果能匹配到的话,就表示能直接重用了,所以,如果我们做了这个StableId的话,理论上是会提高滑动的流畅度的。

再来看看这三个方法:

removeAndRecycleView(View child, Recycler recycler)
removeAndRecycleViewAt(int index, Recycler recycler)

removeAndRecycleAllViews(Recycler recycler)

通过看名字可以大概知道,这几个方法会把holder放进mRecyclerPool.mScrap中,但不一定每次都直接放进去的,如果这个holder未被标记为无效的话,会经过我们上面说的mCachedViews缓冲一下(它默认能装2个,当然我们也可以根据需求来设置合适的大小),这个mCachedViews就好像一个队列,当有新的holder要被添加进来,而这个时候它又装满了的话,就会把最先存进去的holder拿出来,扔进mRecyclerPool.mScrap里面,这样新的holder就有空间放进来了。

所以,在mCachedViews中取出来的holder,也是能直接重用而不需重新绑定数据的。

好了,现在相信大家对RecyclerView的回收机制都有比较深入的理解了,在自定义LayoutManager的过程中,想要做出流畅的滑动效果,就必须要重视并认真对待回收这个环节。

好,现在到了基本流程中最后一步了,来看看如何使LayoutManager的Item能够跟随手指滚动。

当RecyclerView接收到触摸事件时,会根据:

boolean canScrollHorizontally()
boolean canScrollVertically()

这两个方法的返回值来判断是否可以接受水平或垂直触摸事件,如果返回的是true的话,就会回调:

int scrollHorizontallyBy(int dx, Recycler recycler, State state)
int scrollVerticallyBy(int dy, Recycler recycler, State state)

这两个方法,一个是水平滑动时的回调,一个是垂直滑动。

我们来看看参数:

    1. dx(dy) 表示本次较于上一次的偏移量,<0为 向右(下) 滚动,>0为向左(上) 滚动;
    2. recycler 就是我们刚刚说到的,处理回收和获取Items的对象;
    3. state 看名字就能大概知道,我们可以借助它来获取到一些很有用的信息,比如说isPreLayout,itemCount之类的;

可以看到这两个方法还需要返回一个int,就是要告诉RecyclerView,本次我们实际消费(偏移)的距离,比如说当滚动到最底部时,不能继续往下滚动,这时候就应该返回0了。
我们在重写这两个方法时,就要根据当前偏移量来对Items做出相应的偏移,这样列表就能随手指滚动起来了,当然了,别忘了回收这一重要环节。

在下一章的实战中,经常会跟以下几个api打交道,其实上面都已经介绍过,这里再回顾下。

布局API:

//找recycler要一个childItemView,我们不管它是从scrap里取,还是从RecyclerViewPool里取,亦或是onCreateViewHolder里拿。
View view = recycler.getViewForPosition(xxx);  //获取postion为xxx的View
//将View添加至RecyclerView中
addView(view);
//将View添加至RecyclerView中,childIndex为0,但是View的位置还是由layout的位置决定,该方法在逆序layout子View时有大用
addView(child, 0);
//测量View,这个方法会考虑到View的ItemDecoration以及Margin
measureChildWithMargins(scrap, 0, 0);
//将ViewLayout出来,显示在屏幕上,内部会自动追加上该View的ItemDecoration和Margin。此时我们的View已经可见了
layoutDecoratedWithMargins(view, leftOffset, topOffset,
                        leftOffset + getDecoratedMeasuredWidth(view),
                        topOffset + getDecoratedMeasuredHeight(view));

回收API:

//detach轻量回收所有View
detachAndScrapAttachedViews(recycler);
//detach轻量回收指定View
detachAndScrapView(view, recycler);

//recycle真的回收一个View ,该View再次回来需要执行onBindViewHolder方法
removeAndRecycleView(View child, Recycler recycler)
removeAndRecycleAllViews(Recycler recycler);

//超级轻量回收一个View,马上就要添加回来
detachView(view);
//将上个方法detach的View attach回来
attachView(view);
//detachView 后 没有attachView的话 就要真的回收掉他们
recycler.recycleView(viewCache.valueAt(i));

移动子View API:

//竖直平移容器内的item
offsetChildrenVertical(-dy); 
//水平平移容器内的item
offsetChildrenHorizontal(-dx);

获取子View大小API:

getDecoratedMeasuredWidth(View child)
getDecoratedMeasuredHeight(View child)

本篇主要介绍了自定义LayoutManager需要注意的事项,如何布局和回收View,下一篇中,将从零开始实现一个简单的自定义LayoutManager。

本文大部分转自:https://blog.csdn.net/u011387817/article/details/81875021

部分参考:https://blog.csdn.net/zxt0601/article/details/52948009

编写不易,转载请注明出处:https://www.longdw.com