深入理解RecyclerView

14年Google發(fā)布了萬眾期待的Android 5.0 。隨之而來的還有新的設(shè)計方案 Material Design。為了在5.0以下的版本中也兼容這種設(shè)計方案, Google在新的support包中放出了大量控件,這其中就包括我們今天要講的RecyclerView。

這篇文章并不會講RecyclerView怎么用,而且通過分析RecyclerView內(nèi)部的運行機制,以便于能恰當(dāng)、正確的使用RecyclerView。


先看看RecyclerView都包含什么

RecyclerView 類圖

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

  • 可以顯示列表、網(wǎng)格、瀑布流等布局
  • 可以添加Item的動畫
  • Item 可以回收再利用
  • 可以顯示多種類型的 Item
  • Item 支持拖拽操作
  • 等等

那這么厲害的RecyclerView代碼都有多復(fù)雜啊。
錯了,一個好漢三個幫,弱蜀還有五虎上將呢,RecyclerView這么好用的控件怎么可能單槍匹馬逞英雄?

** 那我們看看RecyclerView都有那些猛將:**

  • LayoutManager
    LayoutManager是用來管理RecyclerView的布局。RecyclerView的onMeasure 和 onLayout都會被LayoutManager全權(quán)代理。不同的LayoutManager展示的布局樣式也不一樣。android默認(rèn)提供三種布局樣式,我們也可以自定義特殊的布局樣式。
  • ItemAnimator
    ItemAnimator是處理在Item的add、remove、change的時候,展示相應(yīng)的動畫。RecyclerView是會帶著一個默認(rèn)動畫的。
  • Adapter
    Adapter 不用多說。根據(jù)數(shù)據(jù)適配不用的View。
  • Recycler
    Recycler相當(dāng)于Item的緩存池。
  • ChildHelper
    由于RecyclerView在處理Item的操作時,會有動畫。比如我們移除一個View.對于ItemAnimator來說,需要讓這個View動起來。而對于RecyclerView來說,它希望可以移除View并且回收這個View。ChildHelper就是用來處理這個沖突。
  • AdapterHelper
    AdapterHelper 維護一組UpdateOp。判斷那些操作需要預(yù)處理,那些不需要,分別給出他們相應(yīng)的動畫執(zhí)行順序。由于移動會打斷整體的連續(xù)性,所以把移動操作放在執(zhí)行隊列的最后面。
  • SnapHelper
    SnapHelper 是控制RecyclerView滑動的。你控制他的滑動范圍,可以控制Fling,也就是慣性停止的地方。
  • ItemTouchHelper
    ItemTouchHelper 控制Item的手勢,比如側(cè)滑刪除,比如拖動排序之類。
  • DiffUtil
    DiffUtil通過對比兩組數(shù)據(jù)前后的差異,提供RecyclerView局部刷新的能力。

接下來我會一一介紹它們

LayoutManager

LayoutManager作為布局先鋒,負(fù)責(zé)RecyclerView內(nèi)部Item的測量和布局。說白了就是,RecyclerView自己不再負(fù)責(zé)Measure、Layout,全權(quán)委托給LayoutManager來處理。這樣做的好處就是職責(zé)清晰,開發(fā)者不但可以自由的使用列表、網(wǎng)格、瀑布流等常規(guī)的布局,還可以自定義LayoutManager來滿足特殊的列表需求。比如:


復(fù)雜列表

上圖中的這個復(fù)雜列表,在阿里系的APP上比較常見,比如優(yōu)酷、天貓。大致結(jié)構(gòu)是的:

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的緩存機制。在之后的系列文章中,我會詳細(xì)解釋。
阿里爸爸開源的復(fù)雜列表VLayout
這個是阿里開發(fā)的一個用于顯示復(fù)雜列表的LayoutManager,有興趣的可以看一眼。

** 總結(jié)來說,整個LayoutManager需要處理的任務(wù)如下:**

  • 是否需要支持 wrap_content

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

  • 預(yù)判動畫

item 添加、刪除、大小變化都可能觸發(fā)動畫,舉個例子,如果RecyclerView使用默認(rèn)的動畫,刪除Position為0的Item,其余的Item就會整體向上移動。這個時候就需要知道,item的偏移量,只之后真正的Layout做準(zhǔn)備。

  • 開始真正的Layout

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

  • 處理一些滾動

如果想讓我們RecyclerView滾動起來,就需要在LayoutManager來做特殊的處理。

自定義LayoutManger相對比較復(fù)雜。也不是我短短幾句話就能講清楚的,需要開發(fā)者不斷的寫Demo,查閱源碼或者相關(guān)文章,才能完成一個完整的LayoutManger。下面介紹自定義LayoutManger必須知道的幾個知識點:

public class DemoLayoutManager extends RecyclerView.LayoutManager {

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

    /**
     * 處理Item布局的問題
     */
    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {

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

        int xOffset = 0;
        int yOffset = 0;

        // 第二步:把所有的Item放在他們應(yīng)該放的位置
        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的回收和復(fù)用都是由Recycler來控制的。那他是如何來處理的呢?

回收示意圖

我們來看上面這張圖。圖中展示來RecyclerView復(fù)用ItemView的機制。其中 #、1、2、3、4 分別代表:

  • "#":代表LayoutManager在處理布局時,需要從RecyclerView中獲得每一個Item的View。getViewForPosition。
  • "1": Scap 直譯是廢料 暫且理解為廢品回收站。在Layout過程中暫時處于detached狀態(tài)的Views。屬于一級緩存。
  • "2": Cache 代表當(dāng)前已經(jīng)不可見但并沒有從布局中移除的View。
    屬于二級緩存。
  • "3":ViewCacheExtension 是留給開發(fā)者自定義的緩存池。屬于第三級緩存。官方并沒有給出默認(rèn)示例。我在目前的開發(fā)中,也沒有遇到使用這種緩存池的場景,如果大家有使用這種緩存池的場景,可以在留言中告訴我。
  • "4":RecycledViewPool 最終的緩存池。也就是第四級緩存。RecycledViewPool提供按照不同Type緩存不用View的能力。
三級緩存

上面這個圖,我稍微說明一下。
1.當(dāng)RecyclerView需要更新數(shù)據(jù)的時候,包括 add、move、remove、change操作,如果當(dāng)前數(shù)據(jù)在可視范圍之內(nèi),就會直接從Scrap中獲取。
2.上下小幅度的滑動的時候,就需要用到cache中緩存的View了。
3.如果大幅度滾動,cache中緩存數(shù)據(jù)不夠用,或者調(diào)用了notifyDataSetChanged后,需要重新布局時,這時候就會調(diào)用Pool中的緩存。
4.值得注意的是,Scrap 和 cache 的數(shù)據(jù),是不需要重新綁定的。除了ChangedScarp這個特例。

ItemAnimator

ItemAnimator主要是處理動畫。這個動畫主要添加、刪除、移動的動畫。國內(nèi)開發(fā)者,需要特別絢麗多彩的動畫并不多。同樣我也沒有遇到這種需求。我認(rèn)為默認(rèn)的動畫就還不錯。
如果你真的想自定義ItemAnimator。我推薦Github的一個開源庫,大家可以參考參考。我之后有時間,也會詳細(xì)介紹這方面的知識。
https://github.com/wasabeef/recyclerview-animators
但是需要注意,一個Item的操作,可能會觸發(fā)多個動畫,比如,在中間位置插入一條數(shù)據(jù),這個時候,就會觸發(fā)插入的動畫,和原來這個位置以后的Item都向下移動的動畫。

DiffUtil

DiffUtil是最近版本中推出的一個工具。主要是幫助RecyclerView提升刷新效率的問題。我們舉一個例子來說這個問題。

搜索

這樣的搜索功能是很常見的。每次輸入不同的文字,都要給出該文字相對應(yīng)的搜索熱詞推薦。如果直接使用notifyDataSetChanged(),就會導(dǎo)致整個RecyclerView發(fā)生RequestLayout。我們都知道RequsetLayout會引起整個View樹重新遍歷一邊Measure和Layout。這樣非常消耗性能。而且,RecyclerView重新加載時,只會從RecyclerViewPool中拿緩存的Item。RecyclerViewPool默認(rèn)只會緩存5個Item。剩下的Item都需要重新走Create和inflate。之后他們還要重寫計算寬高,重新計算布局。這個過程非常耗時。

notifyDataSetChanged

為了提供性能,我們就可以使用DiffUtil來對比兩組數(shù)據(jù),得到數(shù)組A切換到數(shù)組B的最少移動步驟。

解釋Myers算法

“尋找diff”這件事,被抽象成了“尋找圖的路徑”了。那么,“最短的直觀的”diff對應(yīng)的路徑有什么特點呢?

路徑長度最短(對角線不算長度)
先向右,再向下(先刪除,后新增)

其實Myers算法是一個典型的”動態(tài)規(guī)劃“算法,也就是說,父問題的求解歸結(jié)為子問題的求解。要知道d=5時所有k對應(yīng)的最優(yōu)坐標(biāo),必須先要知道d=4時所有k對應(yīng)的最優(yōu)坐標(biāo),要知道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得到變化之后,我們可以調(diào)用RecyclerView的局部刷新機制。這樣不需要RequestLayout。刷新效率非常高。
那DiffUtil的對比數(shù)據(jù)的效率怎么樣呢。
這里有一組官方的數(shù)據(jù):

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以內(nèi),看來完成可以在主線程執(zhí)行。

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) {
            // 設(shè)置邊距
            outRect.set(left, top, right, bottom);
        }
ItemDecoration

** 上面這種圖中,分別體現(xiàn)了上面的三個方法。**

  • 設(shè)置邊距,上圖中的灰色區(qū)域就是變局,分別代表左、上、右、下的邊界。
  • 上圖中紅色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);
                // 獲取你的標(biāo)簽中顯示的數(shù)量
                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

有關(guān)SnapHelper的 請看這里。我之后會講。
https://github.com/rubensousa/RecyclerViewSnap

ItemTouchHelper

有關(guān)ItemTouchHelper的 請看這里。我之后會講。
https://github.com/iPaulPro/Android-ItemTouchHelper-Demo

AdapterHelper

以后補全

ChildHelper

在發(fā)生移除動畫時,對于ViewGroup來說,由于動畫還在發(fā)生,所以View并沒有被真正的從ViewGroup中移除。而對于LayoutManager來說,這個View已經(jīng)被移除,需要對他做回收處理。


不同的視角

這個時候,LayoutManager 操作View的時候,比如getChildAt()。這個方法并不是真正沖ViewGroup中獲取,而是從ChildHelper維護的View隊列中獲取。


ChildHelper解決沖突

使用RecyclerView的注意點

1.如何RecyclerView的寬高不隨著內(nèi)容的變化而變化,就可以使用如下方法來提高性能

mRecyclerView.setHasFixedSize(true);

2.如果想提高RecyclerView的滑動流暢性,可以適度增加Cache的大小,默認(rèn)大小是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上添加一些手勢處理,比如最常見的側(cè)滑刪除,Item拖拽等等。在有些特殊的手機上,你會發(fā)現(xiàn)拖拽不靈敏,這個時候就可以用

mRecyclerView.setScrollingTouchSlop(RecyclerView.TOUCH_SLOP_PAGING);

4.RecyclerView 默認(rèn)是帶Item動畫的。如果你不需要動畫,或者性能要求嚴(yán)格,可以關(guān)閉動畫。

mRecyclerView.setItemAnimator(null);

5.RecyclerView如果要現(xiàn)實圖片,可以在慣性滾動的時候暫停圖片加載,這樣可以提升流暢度。

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 48,621評論 2 380

推薦閱讀更多精彩內(nèi)容