RecyclerView 體驗優化及入坑總結

weixin_33751566發表於2018-01-15

     本文所講RecyclerView 是來自support 庫 26 版本,本文主要來源於自身開發及組內同事遇到問題的經驗總結,作為知識沉澱記錄一下,以備日後檢視。

本文主要講解以下幾部分:

(1)RecyclerView 滑動體驗篇

       1)橫向ViewPager 與內嵌橫向RecyclerView 之間的滑動衝突;

       2)縱向RecycleView/ListView 與 橫向RecycleView 之間的滑動衝突;

       3)橫向RecyclerView  ItemView 滑動不停留在中間態;

       4)記錄、恢復RecyclerView 滾動偏移位置;

(2)RecyclerView 入坑篇

       1)RecyclerView 導致的記憶體洩漏(support 26 + 7.0以下機型);

       2)RecyclerView呼叫notifyDataSetChanged 會閃爍;

       3)RecycleView /ListView 設定itemView 為View.GONE 效果等同於View.Invisible;

(3)RecyclerView 效能提升篇

一、RecyclerView 滑動體驗篇

(1)ViewPager 與 橫向RecyclerView 之間的滑動衝突

       目前,企鵝FM專案中,很多頁面使用ViewPager+ TabLayout (如首頁、詳情頁、搜尋結果頁等),而對應頁面很多時候會巢狀一個橫向RecycleView,用來展現更多的資訊,如下,在RecycleView中滑動到最後一個元素時,會同時帶動ViewPager滑動,這種體驗極差。

3315418-358c3746e98f9494.png
圖 1  ViewPager 內巢狀RecyclerView 示例

原因分析:

       作為子View 的RecyclerView在滑到最後一個或第一個ItemView到導致ViewPager滑動,這一定是ViewPager在此刻對滑動事件進行了攔截,解決的最簡單辦法就是不讓ViewPager攔截橫向RecyclerView的滑動事件(即 ViewPager::onInterceptTouchEvent方法返回false),ViewPager::onInterceptTouchEvent中的Move 事件如下:

3315418-8ea22b3315222dd8.png
                     圖 2 ViewPager::onInterceptTouchEvent()                                           

目前,有以下兩種方式使ViewPager 不去攔截橫向RecyclerView 滑動事件:

1)在RecyclerView 對應滑動事件分發中呼叫        

      getParent().requestDisallowInterceptTouchEvent(true); 阻止ViewPager對其MOVE或者UP事件進行攔截,但是考慮的因素比較多,而且效果不是太好,故放棄這種方式。

2)修改某些方法,進入到上圖if判斷中

      在滑動橫向RecyclerView 到兩端時,dx != 0 && !isGutterDrag(mLastMotionX, dx) 肯定滿足條件,那說明canScroll() (用來判斷一個View以及它的子View是否可以滑動)一定返回了false, 複寫canScroll()方法,打log,發現返回果然為false,驗證了自己的判斷。

解決辦法:複寫canScroll,當View 是橫向RecyclerView(LinearLayoutManager 包含GridLayoutManager)時,直接返回true即可解決問題,解決程式碼如下:

3315418-df50b49084b39532.png
圖3 複寫canScroll()方法

      類似的衝突還有ViewPager 和HorizontalScrollView 等等,解決方式與上面類似。 

(2)縱向RecyclerView/ListView 與 橫向RecyclerView 之間的滑動衝突

     在有些時候因為產品需求,需要在縱向的RecyclerView/ListView內巢狀一個橫向的RecyclerView,當這個橫向RecyclerView的item 比高度較大的時候(企鵝FM書城排行榜模組),在橫向滑動時,容易導致整體向上滑,體驗效果較差,如下圖所示(網路盜圖) :

3315418-784668a1a12dba6b.jpg
圖3  右滑橫向RecyclerView

       造成上述現象的原因是:外層縱向滑動的RecyclerView對 橫向滑動的RecyclerView 的滑動事件進行了攔截,如下圖2 所示,canScrollVertically 此刻為true,因此這裡僅僅只判斷了Math.abs(dy)>mTouchSlop(可以認為是一個滑動閥值,是一個定值8dp) ,並未判斷方向或角度,從而決定是否攔截。

3315418-368019ccca6c0d95.png
圖4  RecyclerView::onInterceptTouchEvent()

       解決辦法 :既然RecyclerView::onInterceptTouchEvent 內部沒有判斷滑動角度或方向,那我們就人為去判斷,在上面判讀的基礎上繼續判斷 Math.abs(dy) 和Math.abs(dx) 的大小,從而決定是否攔截:具體分析細節可參照 , 修復垂直滑動RecyclerView巢狀水平滑動RecyclerView水平滑動不靈敏問題

        使用上述方法,可以很快解決上述滑動體驗問題,那是不是隻有上述一種解決方式了,答案是否定的,作為一名Android 開發者我們知道,除了上述方式攔截滑動事件外,我們還可以通過getParent().requestDisallowInterceptTouchEvent(true); 讓父RecyclerView不去攔截橫向滑動,如下是RecyclerView::onTouchEvent() ,內部已經實現了requestDisallowInterceptTouchEvent(true) 。

       我們需要考慮的是,當我們橫向上或橫向下滑動時,需要 進入上圖中1的判斷 ,2的判斷還未滿足,此時內部橫向RecyclerView 會攔截內部itemView的滑動事件,進而執行自己的onTouchEvent事件,從而呼叫requestDisallowInterceptTouchEvent(true) ,讓外層RecyclerView不去攔截內部RecyclerView的橫向滑動事件,至此需要解決如何保證先進入1判斷而不進入2判斷。

3315418-2f4ab7823520d381.png
圖5 RecyclerView::onTouchEvent()

解決辦法:通過調整TouchSlop值的大小 

      在開始我們已介紹RecyclerView 的預設TouchSlop 值是8dp,如果要先保證進入1判斷條件,必須調大TouchSlop值(反射獲取),經過調整TouchSlop (按倍數調整比較簡單,可以先知道一個大致範圍)驗證,當TouchSlop擴大1倍時就能滿足條件。

總結:上述兩種方式各有優缺點,方法1,對原生RecyclerView 侵入性較強(特別是對RecyclerView 進行多層封裝的情況下,影響比較大),優點是TouchSlop 值保持與系統一致,不會帶來其他未知問題;方法 2 ,修改方式簡單,入侵性小,缺點,需要調整TouchSlop 值,可能還會帶來其他問題。

(3)橫向RecyclerView  ItemView 滑動不停留在中間態

           如下圖所示,正在滑動的模組是書城——排行榜模組,排行榜模組主要由橫向RecyclerView 構成,內部包含兩個榜單形式,列舉前top3的內容,在(2)的基礎上解決了縱向RecyclerView 巢狀橫向RecyclerView 滑動問題外,還有有個小問題那就是,RecyclerView  ItemView 滑動多少就停在那裡,這種效果不是我們想要的,我們想要的是滑到左邊就顯示第一個榜單,滑到右邊就顯示第二個榜單。

3315418-dfdcededc4b885ca.gif
圖6 書城——排行榜模組

那有沒有好的辦法做到這一點了,官方考慮到這一點,針對RecyclerView 滑動情況,專門提供了SnapHelper類(PagerSnapHelper 和LinearSnapHelper  ,詳細介紹介紹可參看Android中使用RecyclerView + SnapHelper實現類似ViewPager效果), 使用其他相當簡單,針對上述問題解決方式如下:

3315418-e25f1015bc577ae9.png
圖 7 LinearSnapHelper 使用

(4)記錄、恢復RecyclerView 滾動偏移位置

        熟悉RecyclerView 快取的同學應該知道(後面在也會介紹RecyclerView快取機制),當RecyclerView中的itemView 滑出螢幕後會快取在mCacheView 中(預設快取最大數是2),因此當滑出螢幕超過2後,再滑回來,原來的位置資訊都會被重置,對於一般的RecyclerView 沒有什麼影響,但是如果內嵌了一個橫向RecyclerView (如下圖中分類模組位置) ,起初”懸疑推理“ 在一排第一個位置,向左滑動到其他位置後,再縱向滑動外層RecyclerView ,發現分類模組第一個又變成了”懸疑推理“ ,這個是產品不能接受的。

3315418-90a5a29c53fb6b57.gif
圖 8 未記錄RecyclerView 滾動偏移位置

     那如何修正上述問題了,RecyclerView 佈局 及位置相關資訊都是由對應LayoutManager決定,因此檢視對應LayoutManager::onSaveInstanceState() 如下所示,內部確實記錄了position及offset 值。

3315418-fdbcce031162ad60.png
圖 9  LinearLayoutManger::onSaveInstanceState

解決辦法步驟:

(1)在Adapter::onViewRecycled 中儲存對應LayoutManager的onSaveInstanceState ,同時記錄儲存下來

3315418-a1deb4acb0488da3.png
圖 9 Adapter::onViewRecycled()

(2)在setData()資料給Adapter 時,恢復對應LayoutManager 之前儲存在資料資訊

3315418-63a830d52a9f73cb.png
圖 10 setData()

(3)儲存記錄RecyclerView 後的效果

3315418-78989cc407f3d5e2.gif
圖 11 已記錄RecyclerView 滾動偏移位置

二、RecyclerView 入坑篇

(1)RecyclerView 導致的記憶體洩漏(support 26 + 7.0以下機型)

         在進行4.0 版本迭代時,發現在之前的廣播聚合頁存在RecyclerView導致的記憶體洩漏,下圖為記憶體洩漏的引用鏈,引用物件可以追到GapWorker。這裡的RecyclerView是一個橫向的RecyclerView ,作為廣播聚合頁(ListView)的HeaderView。

3315418-5e852da6d2c48f58.png
圖12 RecyclerView 記憶體洩漏 引用鏈

       由於廣播頁面是比較老的頁面,最近幾個版本也未發現此類洩漏,細細想一下,可能與RecyclerView 版本有關(4.0版本直接將support 庫由23.1升級到26.1版本),剛好這幾個版本,support 庫 修復了修復很多RecyclerView 的bug 及新增了許多新功能。通過AndroidXRef 查詢知(查詢結果如下),GapWorker 果然是在support 26 新增的。

3315418-51bf2ac7a583b816.png
圖13 GapWorker 出現版本

        檢視GapWorker ,裡面sGapWorker 是一個ThreadLocal 帶GapWorker 的物件,同時維持了一個RecyclerView 的List物件(通過add  和remove 方法進行)。

3315418-db539e69c994d22a.png
圖14 GapWorker 內重要物件

      而GapWorker的add 和remove 方法分別在RecyclerView::onAttachedToWindow 和RecyclerView::onDetachedFromWindow 中呼叫,如下圖所示:

3315418-4ba62d8a4072cdc3.png
圖15 RecyclerView::onAttachedToWindow 和 RecyclerView::onDetachedFromWindow

   根據上面的引用鏈知,RecyclerView::onDetachedFromWindow 方法 沒有被主動呼叫,斷點驗證,在退出廣播頁面的時候也沒有呼叫(導致洩漏),按理說在滑動離屏的時候就應該呼叫的,難道和RecylerView 做為ListView 的HeaderView 有關,順著這條思路發現果然和上述使用方式有關。

       之前組內同事chunyu遇到過:ListView 巢狀GridView時,GridView資料錯亂問題(7.0及其以上有問題),裡面剛好說明了7.0及其以上版本,官方修正了RecylerView 做為ListView 的HeaderView 情況,滑出螢幕,不呼叫onDetachedFromWindow()的原因,具體如下:

3315418-fd2428d4d10e6b0f.png
圖16  ListView::scrollListItemsBy()
3315418-8139379453a476a8.png
圖17  整個呼叫流程

       從分析中,可以獲取到兩個重要的資訊:1)GapWorker 是在support 26 以上才有的,且SDK_INT>=21,才會進行對應add 和remove 操作 ;2)在SDK_INT< 24(7.0) 時,不會主動呼叫View::dispatchDetachedFromWindow()。

        因此,上述問題的解決辦是:在對應Fragment 的onDetach() 或 其他場景主要去呼叫上圖中的ViewGroup::removeDetachedView()  (這裡需要使用反射),具體如下:

3315418-e2a2c07e4b6a45ed.png
圖18 在定製ListView中利用反射實現removeDetachedView()

(2)RecyclerView呼叫notifyDataSetChanged 會閃爍

詳見我的另一篇文章:RecyclerView notifyDataSetChanged 導致圖片閃爍的真凶

(3)RecycleView /ListView 設定itemView 為View.GONE 效果等同於View.Invisible

  解決辦法:將itemView 的寬高設定成 0 ,重新設定一下LayoutParams

3315418-207b820e2a5e7332.png

三、RecyclerView 效能提升篇

   說是RecyclerView效能提升篇有點誇大 ,這裡主要講講RecyclerView 使用小技巧

(1)setHasFixedSize(true)優化思想

(2)DiffUtil ()

(3)......

    限於篇幅內容有點多,後續再補充.....  ,有分析不對的地方歡迎指出 ,謝謝 ^_^!


相關引用資料

(1)修復垂直滑動RecyclerView巢狀水平滑動RecyclerView水平滑動不靈敏問題

(2)Android中使用RecyclerView + SnapHelper實現類似ViewPager效果

  (3)  關於RecyclerView的快取機制的理解

相關文章