RecyclerView的使用總結以及常見問題解決方案

susion發表於2018-12-21

本文是RecyclerView原始碼分析系列最後一篇文章, 主要講一下我個人對於RecycleView的使用的一些思考以及一些常見的問題怎麼解決。先來看一下使用RecycleView時常見的問題以及一些需求。

RecyclerView使用常見的問題和需求

RecycleView設定了資料不顯示

這個往往是因為你沒有設定LayoutManger。 沒有LayoutManger的話RecycleView是無法佈局的,即是無法展示資料,下面是RecycleView佈局的原始碼:

void dispatchLayout() {  //沒有設定 Adapter 和 LayoutManager, 都不可能有內容
    if (mAdapter == null) {
            Log.e(TAG, "No adapter attached; skipping layout");
            // leave the state in START
            return;
    }
    if (mLayout == null) {
            Log.e(TAG, "No layout manager attached; skipping layout");
            // leave the state in START
            return;
    }
}
複製程式碼

AdapterLayout任意一個為null,就不會執行佈局操作。

RecyclerView資料多次滾動後出現混亂

RecycleView在滾動過程中ViewHolder是會不斷複用的,因此就會帶著上一次展示的UI資訊(也包含滾動狀態), 所以在設定一個ViewHolder的UI時,儘量要做resetUi()操作:

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        holder.itemView.resetUi()
        ...設定資訊UI 
}
複製程式碼

resetUi()這個方法就是用來把Ui還原為最初的操作。當然如果你的每一次bindData操作會對每一個UI物件重新賦值的話就不需要有這個操作。就不會出現itemView的UI混亂問題。

如何獲取當前 ItemView展示的位置

我們可能會有這樣的需求: 當RecycleView中的特定Item滾動到某個位置時做一些操作。比如某個Item滾動到頂部時,展示搜尋框。那怎麼實現呢?

首先要獲取的Item肯定處於資料來源的某個位置並且肯定要展示在螢幕。因此我們可以直接獲取這個ItemViewHolder:

    val holder = recyclerView.findViewHolderForAdapterPosition(speicalItemPos) ?: return

    val offsetWithScreenTop = holder.itemview.top

    if(offsetWithScreenTop <= 0){  //這個ItemView已經滾動到螢幕頂部
        //do something
    }
複製程式碼

如何在固定時間內滾動一款距離

smoothScrollToPosition()大家應該都用過,如果滾動2、3個Item。那麼整體的使用者體驗還是非常棒的。

但是,如果你滾動20個Item,那這個體驗可能就會就很差了,因為使用者看到的可能是下面這樣子:

RecyclerView的使用總結以及常見問題解決方案

恩,滾動的時間有點長。因此對於這種case其實我推薦直接使用scrollToPosition(20),效果要比這個好。 可是如果你就是想在200ms內從Item 1滾到Item 20怎麼辦呢?

可以參考StackOverflow上的一個答案。大致寫法是這樣的:

//自定義 LayoutManager, Hook smoothScrollToPosition 方法
recyclerView.layoutManager = object : LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) {
    override fun smoothScrollToPosition(recyclerView: RecyclerView?, state: RecyclerView.State?, position: Int) {
        if (recyclerView == null) return
        val scroller = get200MsScroller(recyclerView.context, position * 500)
        scroller.targetPosition = position
        startSmoothScroll(scroller)
    }
}

private fun get200MsScroller(context: Context, distance: Int): RecyclerView.SmoothScroller = object : LinearSmoothScroller(context) {
    override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float {
        return (200.0f / distance) //表示滾動 distance 花費200ms
    }
}
複製程式碼

比如上面我把時間改為10000,那麼就是用10s的時間完成這個滾動操作。

如何測量當前RecyclerView的高度

先描述一下這個需求: RecyclerView中的每個ItemView的高度都是不固定的。我資料來源中有20條資料,在沒有渲染的情況下我想知道這個20條資料被RecycleView渲染後的總共高度, 比如下面這個圖片:

RecyclerView的使用總結以及常見問題解決方案

怎麼做呢?我的思路是利用LayoutManager來測量,因為RecycleView在對子View進行佈局時就是用LayoutManager來測量子View來計算還有多少剩餘空間可用,原始碼如下:

   void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
        View view = layoutState.next(recycler);   //這個方法會向 recycler要一個View
        ...
        measureChildWithMargins(view, 0, 0);  //測量這個View的尺寸,方便佈局, 這個方法是public
        ...
    }
複製程式碼

所以我們也可以利用layoutManager.measureChildWithMargins方法來測量,程式碼如下:

    private fun measureAllItemHeight():Int {
        val measureTemplateView = SimpleStringView(this)
        var totalItemHeight =
        dataSource.forEach {  //dataSource當前中的所有資料
            measureTemplateView.bindData(it, 0) //設定好UI資料
            recyclerView.layoutManager.measureChild(measureTemplateView, 0, 0) //呼叫原始碼中的子View的測量方法
            currentHeight += measureTemplateView.measuredHeight
        }
        return totalItemHeight
    }
複製程式碼

但要注意的是,這個方法要等佈局穩定的時候才可以用,如果你在Activity.onCreate中呼叫,那麼應該post一下, 即:

recyclerView.post{
    val totalHeight = measureAllItemHeight()
}
複製程式碼

異常:IndexOutOfBoundsException: Inconsistency detected. Invalid item position 5(offset:5).state:9

這個異常通常是由於Adapter的資料來源大小改變沒有及時通知RecycleView做UI重新整理導致的,或者通知的方式有問題。 比如如果資料來源變化了(比如數量變少了),而沒有呼叫notifyXXX, 那麼此時滾動RecycleView就會產生這個異常。

解決辦法很簡單 : Adapter的資料來源改變時應立即呼叫adapter.notifyXXX來重新整理RecycleView

分析一下這個異常為什麼會產生:

RecycleView重新整理機制一文介紹過,RecycleView的滾動操作是不會走RecycleView的正常佈局過程的,它直接根據滾動的距離來擺放新的子View。 想象一下這種場景,原來資料來源集合中 有8個Item,然後刪除了4個後沒有呼叫adapter.notifyXXX(),這時直接滾動RecycleView,比如滾動將要出現的是第6個Item,LinearLayoutManager就會向Recycler要第6個Item的View:

Recycler.tryGetViewHolderForPositionByDeadline():

final int offsetPosition = mAdapterHelper.findPositionOffset(position);  //position是6 
if (offsetPosition < 0 || offsetPosition >= mAdapter.getItemCount()) {    //但此時  mAdapter.getItemCount() = 5
        throw new IndexOutOfBoundsException("Inconsistency detected. Invalid item "
                + "position " + position + "(offset:" + offsetPosition + ")."
                + "state:" + mState.getItemCount() + exceptionLabel());
}
複製程式碼

即這時就會丟擲異常。如果呼叫了adapter.notifyXXX的話,RecycleView就會進行一次完全的佈局操作,就不會有這個異常的產生。

其實還有很多異常和這個原因差不多,比如:IllegalArgumentException: Scrapped or attached views may not be recycled. isScrap:false(很多情況也是由於沒有及時同步UI和資料)

所以在使用RecycleView時一定要注意保證資料和UI的同步,資料變化,及時重新整理RecyclerView, 這樣就能避免很多crash。

如何對RecyclerView進行封裝

現在很多app都會使用RecyclerView來構建一個頁面,這個頁面中有各種卡片型別。為了支援快速開發我們通常會對RecycleViewAdapter做一層封裝來方便我們寫各種型別的卡片,下面這種封裝是我認為一種比較好的封裝:

/**
 * 對 RecyclerView.Adapter 的封裝。方便業務書寫。 業務只需要處理 (UI Bean) -> (UI View) 的對映邏輯即可
 */
abstract class CommonRvAdapter<T>(private val dataSource: List<T>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val item = createItem(viewType)
        return CommonViewHolder(parent.context, parent, item)
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val commonViewHolder = holder as CommonViewHolder<T>
        commonViewHolder.adapterItemView.bindData(dataSource[position], position)
    }

    override fun getItemCount() = dataSource.size

    override fun getItemViewType(position: Int): Int {
        return getItemType(dataSource[position])
    }

    /**
     * @param viewType 需要建立的ItemView的viewType, 由 {@link getItemType(item: T)} 根據資料產生
     * @return 返回這個 viewType 對應的 AdapterItemView
     * */
    abstract fun createItem(viewType: Int): AdapterItemView<T>

    /**
     * @param T 代表dataSource中的一個data
     *
     * @return 返回 顯示 T 型別的data的 ItemView的 型別
     * */
    abstract fun getItemType(item: T): Int

    /**
     * Wrapper 的ViewHolder。 業務不必理會RecyclerView的ViewHolder
     * */
    private class CommonViewHolder<T>(context: Context?, parent: ViewGroup, val adapterItemView: AdapterItemView<T>)
    //這一點做了特殊處理,如果業務的AdapterItemView本身就是一個View,那麼直接當做ViewHolder的itemView。 否則inflate出一個view來當做ViewHolder的itemView
        : RecyclerView.ViewHolder(if (adapterItemView is View) adapterItemView else LayoutInflater.from(context).inflate(adapterItemView.getLayoutResId(), parent, false)) {
        init {
            adapterItemView.initViews(itemView)
        }
    }
}

/**
 * 能被 CommonRvAdapter 識別的一個 ItemView 。 業務寫一個RecyclerView中的ItemView,只需要實現這個介面即可。
 * */
interface AdapterItemView<T> {

    fun getLayoutResId(): Int

    fun initViews(var1: View)

    fun bindData(data: T, post: Int)
}
複製程式碼

為什麼我認為這是一個不錯的封裝?

業務如果寫一個新的Adapter的話只需要實現兩個方法:

abstract fun createItem(viewType: Int): AdapterItemView<T>

abstract fun getItemType(item: T): Int
複製程式碼

即業務寫一個Adapter只需要對 UI 資料 -> UI View 做對映即可, 不需要關心RecycleView.ViewHolder的邏輯。

因為抽象了AdapterItemView, ItemView足夠靈活

由於封裝了RecycleView.ViewHolder的邏輯,因此對於UI item view業務方只需要返回一個實現了AdapterItemView的物件即可。可以是一個View,也可以不是一個View, 這是因為CommonViewHolder在構造的時候對它做了相容:

val view : View = if (adapterItemView is View) adapterItemView else LayoutInflater.from(context).inflate(adapterItemView.getLayoutResId(), parent, false)
複製程式碼

即如果實現了AdapterItemView的物件本身就是一個View,那麼直接把它當做ViewHolderitemview,否則就inflate出一個View作為ViewHolderitemview

其實這裡我比較推薦實現AdapterItemView的同時直接實現一個View,即不要把inflate的工作交給底層框架。比如這樣:

private class SimpleStringView(context: Context) : FrameLayout(context), AdapterItemView<String> {

    init {
        LayoutInflater.from(context).inflate(getLayoutResId, this)  //自己去負責inflate工作
    }

    override fun getLayoutResId() = R.layout.view_test

    override fun initViews(var1: View) {}

    override fun bindData(data: String, post: Int) { simpleTextView.text = data }
}
複製程式碼

為什麼呢?原因有兩點 :

  1. 繼承自一個View可複用性很高,封裝性很高。即這個SimpleStringView不僅可以在RecycleView中當一個itemView,也可以在任何地方使用。
  2. 方便單元測試,直接new這個View就好了。

但其實直接繼承自一個View是有坑的,即上面那行inflate程式碼LayoutInflater.from(context).inflate(getLayoutResId, this)

它其實是把xml檔案inflate成一個View。然後add到你ViewGroup中。因為SimpleStringView就是一個FrameLayout,所有相當於add到這個FrameLayout中。這其實就有問題了。比如你的佈局檔案是下面這種:

<FrameLayout>
.....
</FrameLayout>
複製程式碼

這就相當於你可能多加了一層無用的父View

所有如果是直接繼承自一個View的話,我推薦這樣寫:

  1. 佈局檔案中儘可能使用<merge>標籤來消除這層無用的父View, 即上面的<FrameLayout>改為<merge>
  2. 很簡單的佈局的可以直接在程式碼中寫,不要inflate。這樣其實也可以減少inflate的耗時,稍微提高了一點效能吧。

當然,如果你不需要對這個View做複用的話你可以不用直接繼承自View,只實現AdapterItemView介面, inflate的工作交給底層框架即可。這樣是不會產生上面這個問題的。

這篇文章就先說這麼多吧。歡迎關注我的Android進階計劃。看更多幹貨。

另外歡迎瀏覽我的RecyclerView原始碼分析系列的其他文章:

RecyclerView原始碼分析系列

相關文章