深入理解RecyclerView

14年Google发布了万众期待的Android 5.0 。随之而来的还有新的设计方案 Material Design。为了在5.0以下的版本中也兼容这种设计方案, Google在新的support包中放出了大量控件,这其中就包括我们今天要讲的RecyclerView。

这篇文章并不会讲RecyclerView怎么用,而且通过分析RecyclerView内部的运行机制,以便于能恰当、正确的使用RecyclerView。


先看看RecyclerView都包含什么

RecyclerView 类图

** 先想想RecyclerView有什么特点:**

  • 可以显示列表、网格、瀑布流等布局
  • 可以添加Item的动画
  • Item 可以回收再利用
  • 可以显示多种类型的 Item
  • Item 支持拖拽操作
  • 等等

那这么厉害的RecyclerView代码都有多复杂啊。
错了,一个好汉三个帮,弱蜀还有五虎上将呢,RecyclerView这么好用的控件怎么可能单枪匹马逞英雄?

** 那我们看看RecyclerView都有那些猛将:**

  • LayoutManager
    LayoutManager是用来管理RecyclerView的布局。RecyclerView的onMeasure 和 onLayout都会被LayoutManager全权代理。不同的LayoutManager展示的布局样式也不一样。android默认提供三种布局样式,我们也可以自定义特殊的布局样式。
  • ItemAnimator
    ItemAnimator是处理在Item的add、remove、change的时候,展示相应的动画。RecyclerView是会带着一个默认动画的。
  • Adapter
    Adapter 不用多说。根据数据适配不用的View。
  • Recycler
    Recycler相当于Item的缓存池。
  • ChildHelper
    由于RecyclerView在处理Item的操作时,会有动画。比如我们移除一个View.对于ItemAnimator来说,需要让这个View动起来。而对于RecyclerView来说,它希望可以移除View并且回收这个View。ChildHelper就是用来处理这个冲突。
  • AdapterHelper
    AdapterHelper 维护一组UpdateOp。判断那些操作需要预处理,那些不需要,分别给出他们相应的动画执行顺序。由于移动会打断整体的连续性,所以把移动操作放在执行队列的最后面。
  • SnapHelper
    SnapHelper 是控制RecyclerView滑动的。你控制他的滑动范围,可以控制Fling,也就是惯性停止的地方。
  • ItemTouchHelper
    ItemTouchHelper 控制Item的手势,比如侧滑删除,比如拖动排序之类。
  • DiffUtil
    DiffUtil通过对比两组数据前后的差异,提供RecyclerView局部刷新的能力。

接下来我会一一介绍它们

LayoutManager

LayoutManager作为布局先锋,负责RecyclerView内部Item的测量和布局。说白了就是,RecyclerView自己不再负责Measure、Layout,全权委托给LayoutManager来处理。这样做的好处就是职责清晰,开发者不但可以自由的使用列表、网格、瀑布流等常规的布局,还可以自定义LayoutManager来满足特殊的列表需求。比如:


复杂列表

上图中的这个复杂列表,在阿里系的APP上比较常见,比如优酷、天猫。大致结构是的:

public class Pager {
    List<Card> mCards;
    class Card {
        List<Item> mItems;
        class Item {
            public int mId;
            public String mName;
        }
    }
}

后端会返回List<Card> mCards 。其中每一个卡片,又是一个列表List<Item> mItems。这个时候LayoutManager就派上用场了,我们可以继承LinearLayoutManager,来处理每一个卡片如何布局,同时,我们需要卡片重的Item打平,这样就可以有效利用RecyclerView的缓存机制。在之后的系列文章中,我会详细解释。
阿里爸爸开源的复杂列表VLayout
这个是阿里开发的一个用于显示复杂列表的LayoutManager,有兴趣的可以看一眼。

** 总结来说,整个LayoutManager需要处理的任务如下:**

  • 是否需要支持 wrap_content

如果支持,就需要先计算Adapter中所有item的大小,然后在计算RecyclerView自己的大小。整个过程比较消耗性能,迫不得已,不要使用。

  • 预判动画

item 添加、删除、大小变化都可能触发动画,举个例子,如果RecyclerView使用默认的动画,删除Position为0的Item,其余的Item就会整体向上移动。这个时候就需要知道,item的偏移量,只之后真正的Layout做准备。

  • 开始真正的Layout

真正的布局需要RecyclerView的大小,Item的起始位置,布局方向,每一次布局之后的偏移量。

  • 处理一些滚动

如果想让我们RecyclerView滚动起来,就需要在LayoutManager来做特殊的处理。

自定义LayoutManger相对比较复杂。也不是我短短几句话就能讲清楚的,需要开发者不断的写Demo,查阅源码或者相关文章,才能完成一个完整的LayoutManger。下面介绍自定义LayoutManger必须知道的几个知识点:

public class DemoLayoutManager extends RecyclerView.LayoutManager {

    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        //返回ItemView的默认大小
        return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT);
    }

    /**
     * 处理Item布局的问题
     */
    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {

        // 第一步:移除当前界面中的item,并添加到回收站中
        detachAndScrapAttachedViews(recycler);

        int xOffset = 0;
        int yOffset = 0;

        // 第二步:把所有的Item放在他们应该放的位置
        for (int i = 0; i < getItemCount(); i++) {
            View view = recycler.getViewForPosition(i); // 从回收站中取出View
            addView(view);
            measureChildWithMargins(view, 0, 0); // 计算View的大小

            int width = getDecoratedMeasuredWidth(view);
            int height = getDecoratedMeasuredHeight(view);

            // 将view放置在正确的位置 (这个位置会收到ItemDecoration的影响)
            layoutDecorated(view, xOffset, yOffset, xOffset + width, yOffset + height);

            if (i % 6 == 5) {
                xOffset = 0;
                yOffset += height;
            } else {
                xOffset += width;
            }

        }

    }


    @Override
    public boolean canScrollVertically() {
        // 控制能否上下滚动
        return true;
    }

    @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        // 在滚动的时候移动view
        offsetChildrenVertical(-dy);
        return dy;
    }

Recycler

Recycler作为回收站,Item的回收和复用都是由Recycler来控制的。那他是如何来处理的呢?

回收示意图

我们来看上面这张图。图中展示来RecyclerView复用ItemView的机制。其中 #、1、2、3、4 分别代表:

  • "#":代表LayoutManager在处理布局时,需要从RecyclerView中获得每一个Item的View。getViewForPosition。
  • "1": Scap 直译是废料 暂且理解为废品回收站。在Layout过程中暂时处于detached状态的Views。属于一级缓存。
  • "2": Cache 代表当前已经不可见但并没有从布局中移除的View。
    属于二级缓存。
  • "3":ViewCacheExtension 是留给开发者自定义的缓存池。属于第三级缓存。官方并没有给出默认示例。我在目前的开发中,也没有遇到使用这种缓存池的场景,如果大家有使用这种缓存池的场景,可以在留言中告诉我。
  • "4":RecycledViewPool 最终的缓存池。也就是第四级缓存。RecycledViewPool提供按照不同Type缓存不用View的能力。
三级缓存

上面这个图,我稍微说明一下。
1.当RecyclerView需要更新数据的时候,包括 add、move、remove、change操作,如果当前数据在可视范围之内,就会直接从Scrap中获取。
2.上下小幅度的滑动的时候,就需要用到cache中缓存的View了。
3.如果大幅度滚动,cache中缓存数据不够用,或者调用了notifyDataSetChanged后,需要重新布局时,这时候就会调用Pool中的缓存。
4.值得注意的是,Scrap 和 cache 的数据,是不需要重新绑定的。除了ChangedScarp这个特例。

ItemAnimator

ItemAnimator主要是处理动画。这个动画主要添加、删除、移动的动画。国内开发者,需要特别绚丽多彩的动画并不多。同样我也没有遇到这种需求。我认为默认的动画就还不错。
如果你真的想自定义ItemAnimator。我推荐Github的一个开源库,大家可以参考参考。我之后有时间,也会详细介绍这方面的知识。
https://github.com/wasabeef/recyclerview-animators
但是需要注意,一个Item的操作,可能会触发多个动画,比如,在中间位置插入一条数据,这个时候,就会触发插入的动画,和原来这个位置以后的Item都向下移动的动画。

DiffUtil

DiffUtil是最近版本中推出的一个工具。主要是帮助RecyclerView提升刷新效率的问题。我们举一个例子来说这个问题。

搜索

这样的搜索功能是很常见的。每次输入不同的文字,都要给出该文字相对应的搜索热词推荐。如果直接使用notifyDataSetChanged(),就会导致整个RecyclerView发生RequestLayout。我们都知道RequsetLayout会引起整个View树重新遍历一边Measure和Layout。这样非常消耗性能。而且,RecyclerView重新加载时,只会从RecyclerViewPool中拿缓存的Item。RecyclerViewPool默认只会缓存5个Item。剩下的Item都需要重新走Create和inflate。之后他们还要重写计算宽高,重新计算布局。这个过程非常耗时。

notifyDataSetChanged

为了提供性能,我们就可以使用DiffUtil来对比两组数据,得到数组A切换到数组B的最少移动步骤。

解释Myers算法

“寻找diff”这件事,被抽象成了“寻找图的路径”了。那么,“最短的直观的”diff对应的路径有什么特点呢?

路径长度最短(对角线不算长度)
先向右,再向下(先删除,后新增)

其实Myers算法是一个典型的”动态规划“算法,也就是说,父问题的求解归结为子问题的求解。要知道d=5时所有k对应的最优坐标,必须先要知道d=4时所有k对应的最优坐标,要知道d=4时的答案,必须先求解d=3,以此类推,和01背包问题很是相似。

        public void dispatchUpdatesTo(final RecyclerView.Adapter adapter) {
            dispatchUpdatesTo(new ListUpdateCallback() {
                @Override
                public void onInserted(int position, int count) {
                    adapter.notifyItemRangeInserted(position, count);
                }

                @Override
                public void onRemoved(int position, int count) {
                    adapter.notifyItemRangeRemoved(position, count);
                }

                @Override
                public void onMoved(int fromPosition, int toPosition) {
                    adapter.notifyItemMoved(fromPosition, toPosition);
                }

                @Override
                public void onChanged(int position, int count, Object payload) {
                    adapter.notifyItemRangeChanged(position, count, payload);
                }
            });
        }

在使用DiffUtil得到变化之后,我们可以调用RecyclerView的局部刷新机制。这样不需要RequestLayout。刷新效率非常高。
那DiffUtil的对比数据的效率怎么样呢。
这里有一组官方的数据:

100 items and 10 modifications: avg: 0.39 ms, median: 0.35 ms
100 items and 100 modifications: 3.82 ms, median: 3.75 ms
100 items and 100 modifications without moves: 2.09 ms, median: 2.06 ms
1000 items and 50 modifications: avg: 4.67 ms, median: 4.59 ms
1000 items and 50 modifications without moves: avg: 3.59 ms, median: 3.50 ms
1000 items and 200 modifications: 27.07 ms, median: 26.92 ms
1000 items and 200 modifications without moves: 13.54 ms, median: 13.36 ms
都在16ms以内,看来完成可以在主线程执行。

ItemDecoration

ItemDecoration 很简单。就是RecyclerView的装饰品。你可以想象RecyclerView是个小姑娘。ItemDecoration就是小姑娘的化妆品。

        public void onDraw(Canvas c, RecyclerView parent, State state) {
           //在画Item之前。
        }
        public void onDrawOver(Canvas c, RecyclerView parent, State state) {
            //在画Item之后
        }

public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
            // 设置边距
            outRect.set(left, top, right, bottom);
        }
ItemDecoration

** 上面这种图中,分别体现了上面的三个方法。**

  • 设置边距,上图中的灰色区域就是变局,分别代表左、上、右、下的边界。
  • 上图中红色999的便签,就是在onDrawOver中画动。
  • 上图中红色五角星背景,则是onDraw画的。

值得注意的事,在给Item画装饰品的时候,一定要注意Item本身的位置。

       @Override
        public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
            super.onDrawOver(c, parent, state);
            if (state.isMeasuring()) return;
   
            c.save();
            int childCount = parent.getChildCount();
            for (int i = 0; i < childCount; i++) {
                View child = parent.getChildAt(i);
                int position = parent.getChildAdapterPosition(child);
                // 获取你的标签中显示的数量
                int count = parent.getAdapter().getItemTag();
                if (count == 0) continue;

                String strCount = String.valueOf(count);
                float textWidth = 0;
                if (count >= 10) {
                    textWidth = mTextPaint.measureText(strCount) - mRadius;
                }
                mRoundRectF.left = child.getRight() - textWidth - mRadius - mLeft;
                mRoundRectF.top = child.getTop() - mRadius + mTop;
                mRoundRectF.right = child.getRight() + mRadius - mLeft;
                mRoundRectF.bottom = child.getTop() + mRadius + mTop;
                c.drawRoundRect(mRoundRectF, mRadius, mRadius, mPopPaint);
                Paint.FontMetrics fontMetrics = mTextPaint.getFontMetrics();
                float baseline = child.getTop() - ((fontMetrics.descent + fontMetrics.ascent) / 2);
                c.drawText(strCount, mRoundRectF.left + mRoundRectF.width() / 2, baseline + mTop, mTextPaint);
            }
            c.restore();
        }

SnapHelper

有关SnapHelper的 请看这里。我之后会讲。
https://github.com/rubensousa/RecyclerViewSnap

ItemTouchHelper

有关ItemTouchHelper的 请看这里。我之后会讲。
https://github.com/iPaulPro/Android-ItemTouchHelper-Demo

AdapterHelper

以后补全

ChildHelper

在发生移除动画时,对于ViewGroup来说,由于动画还在发生,所以View并没有被真正的从ViewGroup中移除。而对于LayoutManager来说,这个View已经被移除,需要对他做回收处理。


不同的视角

这个时候,LayoutManager 操作View的时候,比如getChildAt()。这个方法并不是真正冲ViewGroup中获取,而是从ChildHelper维护的View队列中获取。


ChildHelper解决冲突

使用RecyclerView的注意点

1.如何RecyclerView的宽高不随着内容的变化而变化,就可以使用如下方法来提高性能

mRecyclerView.setHasFixedSize(true);

2.如果想提高RecyclerView的滑动流畅性,可以适度增加Cache的大小,默认大小是2。但是也不能太大,如果太大,会影响初始化的效率。

mRecyclerView. setItemViewCacheSize(5);

3.如果多个RecyclerView显示的Item一样。比如:

公共缓存池

比如这种情况,就可以使用公告缓存池。

int type0 = 0;
int type1 = 1;
int type2 = 2;

RecyclerViewPool mPool = new RecyclerViewPool();
mPool.setMaxRecycledViews(type0, 10);
mPool.setMaxRecycledViews(type1, 10);
mPool.setMaxRecycledViews(type2, 10);

3.有时候我们会在Item上添加一些手势处理,比如最常见的侧滑删除,Item拖拽等等。在有些特殊的手机上,你会发现拖拽不灵敏,这个时候就可以用

mRecyclerView.setScrollingTouchSlop(RecyclerView.TOUCH_SLOP_PAGING);

4.RecyclerView 默认是带Item动画的。如果你不需要动画,或者性能要求严格,可以关闭动画。

mRecyclerView.setItemAnimator(null);

5.RecyclerView如果要现实图片,可以在惯性滚动的时候暂停图片加载,这样可以提升流畅度。

mRecyclerView.setOnFlingListener();

参考资料
1.http://v.youku.com/v_show/id_XMTU4MTQ1ODg2NA==.html?f=27314446&debug=flv
2.https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/
3.com.android.support:recyclerview-v7:25.3.1

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容