1. 程式人生 > >RecyclerView資料更新神器

RecyclerView資料更新神器

概述

DiffUtil是support-v7:24.2.0新增的工具類,它主要是用來計算兩個資料集之間的差異,計算出舊資料集->新資料集的最小變化量,並將其返回。

演算法

DiffUtil內部採用ugene W. Myers’s difference 演算法。該演算法對空間做了優化,並使用O(N)空間來計算兩個列表新增和刪除的最小運算元,演算法的時間複雜度為O(N + D ^ 2)。由於該演算法不支援移動的Item,因而Google大牛在此基礎上改進支援計算移動的Item。造成的後果就是,DiffUtil需要對結果進行第二遍運算,以便於計算移動的Item,從而更加耗費效能。此時,時間的複雜度為O(N ^ 2), 其中N是新增和刪除操作的總數。對於根據約束條件排序的資料集,可以禁用移動Item的檢測以提高效能。

用途

DiffUtil主要是與RecyclerView配合使用。其中,由DiffUtil找出每個Item的變化,由RecyclerView.Adapter更新UI。這樣的好處就是,在資料集變化時,RecyclerView.Adapter不用無腦的呼叫notifyDataSetChanged()方法。

核心類

DiffUtil.Callback

DiffUtil.Callback是一個抽象類,在計算兩個列表之間的差異時,由DiffUtil回撥此類。在該類中,定義了5個抽象方法:

  • int getOldListSize(): 獲取舊資料集的長度
  • int getNewListSize(): 獲取新資料集的長度
  • boolean areItemsTheSame(int oldItemPosition, int newItemPosition):用來判斷 兩個物件是否是相同的Item
  • boolean areContentsTheSame(int oldItemPosition, int newItemPosition):用來檢查 兩個item是否含有相同的資料
  • Object getChangePayload(int oldItemPosition, int newItemPosition):後續再說

DiffUtil.DiffResult

DiffUtil.DiffResult用於儲存DiffUtil計算出的資料集之間的差異資訊,其可以將差異資訊分配給RecyclerView.Adapter,以便更新UI。

核心方法

  • calculateDiff(DiffUtil.Callback cb)
  • calculateDiff(DiffUtil.Callback cb, boolean detectMoves)

這兩個方法都是用來計算舊資料集->新資料集的最小變化量,並起將其返回。其中,第一個方法是第二個方法的特例,預設開啟移動Item的檢測:

public static DiffResult calculateDiff(Callback cb) {

    return calculateDiff(cb, true);

}

如果禁用移動Item的檢測,可以呼叫第二個方法,並將detectMoves引數設定為false。

簡單使用

前文,已經提到DiffUtil主要是與RecyclerView配合使用,以便高效的更新資料集。

  1. 建立Bean

    data class DiffBean(var name: String, var desc: String) {
    
        override fun equals(o: Any?): Boolean {
            if (this === o) return true
            if (o == null || javaClass != o.javaClass) return false
    
            val diff = o as DiffBean?
    
            return diff!!.name == name
        }
    
        override fun hashCode(): Int {
            var result = name?.hashCode() ?: 0
            return result
        }
    }
    
  2. 建立DiffUtil.Callback

    class DiffCallback(private val oldList: List<DiffBean>, private val newList: List<DiffBean>) : DiffUtil.Callback() {
        /**
         * 被DiffUtil呼叫,用來判斷 兩個物件是否是相同的Item。
         * 例如,如果你的Item有唯一的id欄位,這個方法就 判斷id是否相等,或者重寫equals方法
         */
        override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
            return oldList[oldItemPosition].name == newList[newItemPosition].name
        }
    
        /**
         * 老資料集size
         */
        override fun getOldListSize(): Int {
            return oldList.size
        }
    
        /**
         * 新資料集size
         */
        override fun getNewListSize(): Int {
            return newList.size
        }
    
        /**
         * 被DiffUtil呼叫,用來檢查 兩個item是否含有相同的資料
         * DiffUtil用返回的資訊(true false)來檢測當前item的內容是否發生了變化
         */
        override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
            val oldBean = oldList[oldItemPosition]
            val newBean = newList[newItemPosition]
    
            // 如果有內容不相同就返回false
    //            if (oldBean.name != newBean.name) {
    //                return false
    //            }
    
            if (!TextUtils.equals(oldBean.desc, newBean.desc)) {
                return false
            }
    
            //  //預設兩個data內容是相同的
            return true
        }
    }
    

    在自定義的DiffCallback中,尤其要注意這兩個方法:

    • areItemsTheSame()方法用來判斷Item是否相同。如果Item有唯一的欄位,即主鍵,可以判斷兩個主鍵是否相等,就像例子中 name欄位作為主鍵,這麼做: oldList[oldItemPosition].name == newList[newItemPosition].name。當然,也可以重寫equals方法(),即 TextUtils.equals(oldList[oldItemPosition], newList[newItemPosition]).其核心點就是 當兩個Item相同時,返回true,否則 返回false。
    • areContentsTheSame()用來判斷Item是否相同的內容。如果Item的欄位非常多,是否需要全部都需要比較呢?個人覺得,只需要對UI顯示有影響的欄位做比較即可,並不需要所有的欄位都做出判斷,不僅影響效率,也沒啥用。
  3. 建立Apdater

    class DiffAdapter : RecyclerView.Adapter<DiffAdapter.ViewHolder>() {
        val mList: MutableList<DiffBean> = mutableListOf()
    
        ***         
    
        ***
    
        fun setData(list: List<DiffBean>) {
    
            //利用DiffUtil.calculateDiff()方法,傳入一個規則DiffUtil.Callback物件,和是否檢測移動item的 boolean變數,得到DiffUtil.DiffResult 的物件
            val result: DiffUtil.DiffResult = DiffUtil.calculateDiff(DiffCallback(mList, list), true)
    
            //利用DiffUtil.DiffResult物件的dispatchUpdatesTo()方法,傳入RecyclerView的Adapter,輕鬆成為文藝青年
            result.dispatchUpdatesTo(this)
            // 更新資料集,必須放在dispatchUpdatesTo之後,否則getChangePayload()將無效
            // 因為在getChangePayload()還需要對新舊資料集中的Item比較
            mList.clear()
            mList.addAll(list)
        }
    
        *** 
    }
    

    在setData()方法中,DiffUtil在呼叫calculateDiff()計算新舊資料集差異時,傳遞了兩個引數,第一個引數為DiffUtil.Callback物件,第二個引數用來設定在計算時是否禁用檢測移動的Item。當改為false時,將禁用檢測移動的Item,此時效率更高。如果資料集已經根據給定條件進行排序,第二個引數可以設定為false,以提高計算的效率。

  4. 更新資料集

    mList.apply {
        add(DiffBean("A", "這是A"))
        add(DiffBean("B", "這是B"))
        add(DiffBean("C", "這是C"))
        add(DiffBean("D", "這是D"))
        add(DiffBean("E", "這是E"))
    }
    mAdapter.setData(mList)
    

可以看來,當使用DiffUtill和RecyclerView使用,再也不用無腦的呼叫notifyDataSetChanged()方法來更新UI。而,所看不見的是,更新UI的效率更高了,那就是源自DiffUtil內部的ugene W. Myers’s difference 演算法。

UI是如何更新的?

當資料集更新時,RecyclerView.Adapter並沒有呼叫notifyDataSetChanged()方法, UI確更新了?這裡有點疑惑。在使用DiffUtil的過程中,與RecyclerView.Adapter有交集的只有DiffUtil.DiffResult的dispatchUpdatesTo()方法。這個方法是用來將DiffUtil計算出的由舊資料集->新資料集的最小量分配給RecyclerView.Adapter。接下來,跟蹤下它的原始碼:

public void dispatchUpdatesTo(final RecyclerView.Adapter adapter) {
    dispatchUpdatesTo(new AdapterListUpdateCallback(adapter));
}

在dispatchUpdatesTo(RecyclerView.Adapter)方法中又呼叫了dispatchUpdatesTo(ListUpdateCallback)方法,此時,將我們傳遞過去的Adapter建立了AdapterListUpdateCallback物件:

public final class AdapterListUpdateCallback implements ListUpdateCallback {

    private final RecyclerView.Adapter mAdapter;


    public AdapterListUpdateCallback(@NonNull RecyclerView.Adapter adapter) {
        mAdapter = adapter;
    }

    public void onInserted(int position, int count) {
        mAdapter.notifyItemRangeInserted(position, count);
    }
    @Override
    public void onRemoved(int position, int count) {
        mAdapter.notifyItemRangeRemoved(position, count);
    }
    @Override
    public void onMoved(int fromPosition, int toPosition) {
        mAdapter.notifyItemMoved(fromPosition, toPosition);
    }

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

AdapterListUpdateCallback是ListUpdateCallback的實現類,其內呼叫了Adapter一些列更新UI的方法。而在dispatchUpdatesTo(ListUpdateCallback)方法中,根據更新操作,即舊資料集->新資料集的操作,分配給指定的回撥,從而更新UI。

這也就是說,DiffUtil不僅可以跟RecyclerView使用,還可以與ListView,或者是其他的列表,一起使用。我們只需自定義ListUpdateCallback,實現它的4個方法,然後呼叫dispatchUpdatesTo(ListUpdateCallback)方法,將更新操作分發出去即可。至於更新操作分發的細節,有興趣的可以檢視dispatchUpdatesTo(ListUpdateCallback)方法的原始碼。

getChangePayload

暫且不談getChangePayload()方法,先來看RecyclerView中的一個方法:

public void onBindViewHolder(@NonNull VH holder, int position,
        @NonNull List<Object> payloads) {
    onBindViewHolder(holder, position);
}

對於該方法,官方是這麼介紹的:

由RecyclerView呼叫以在指定位置顯示資料. 此方法 更新ViewHolder的itemView的內容以反映給指定位置的Item的變化。

請注意: 與ListView不同的是,如果給定位置的item的資料集變化了,RecyclerView不會再次呼叫這個方法,除非item本身失效了invalidated ) 或者新的位置不能確定。 由於這個原因,在這個方法裡,你應該只使用 postion引數 去獲取相關的資料item,而且不應該>去保持 這個資料item的副本。如果稍後需要Item的 postion,比如,在點選事件監聽中,使用 ViewHolder.getAdapterPosition(),它>能提供 更新後的位置。

部分繫結 vs完整繫結

payloads 引數 是一個從(notifyItemChanged(int, Object)或notifyItemRangeChanged(int, int, Object))裡得到的合併list。 如果payloads list不為空,ViewHolder當前與舊資料繫結,而Adapter可以使用payload 資訊執行高效的區域性更新

如果payload為null,Adapter必須執行完整繫結。Adapter不應該認為onBindViewHolder()會接收到notify方法傳遞的有效資訊。例如,當View沒有attached 在螢幕上時,這個來自notifyItemChange()的payload 就簡單的丟掉好了。

到這裡可以明白,Adapter呼叫這個方法可以根據指定位置Item的變化以高效地執行區域性更新。接下來,在來看getChangePayload()方法:

@Nullable
public Object getChangePayload(int oldItemPosition, int newItemPosition) {
    return null;
}

官方文件是這麼介紹的:

areItemsTheSame(int, int)返回true並且areContentsTheSame(int, int)方法返回false時,DiffUtil將呼叫此方法,獲取內容變化的payload。
例如,如果將DiffUtil與RecyclerView一起使用,則可以返回Item更改的特定的欄位以及RecyclerView.ItemAnimator ItemAnimator可以使用這些資訊執行正確的動畫。

預設返回null

簡單的來說,getChangePayload()方法返回的Object物件,其包括Item更改的內容,或者其他UI更新更新相關的資訊,比如RecyclerView.ItemAnimator。

也就是說,當DiffUtil與RecyclerView一起使用時,如果Adapter想執行高效地區域性更行,首先應重寫DiffUtil.Callback的getChangePayload()方法,並將指定位置Item的變化資訊返回:

override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
    val oldBean = oldList[oldItemPosition]
    val newBean = newList[newItemPosition]

    val bundle = Bundle()

    if (!TextUtils.equals(oldBean.desc, newBean.desc)) {
        bundle.putString("desc", "getChangePayLoad: " + newBean.desc)
    } else { // 如果沒有資料變化,返回null
        return null
    }

    return bundle
}

然後,在自定義的RecyclerView.Adapter中重寫onBindViewHolder(@NonNull VH holder, int position,@NonNull List<Object> payloads)方法,以便在Item更新內容時,Adapter是執行區域性繫結還是完整繫結:

override fun onBindViewHolder(holder: DiffViewHolder, position: Int, payloads: MutableList<Any>) {
    // 如果payload為null,Adapter必須執行完整繫結
    if (payloads.isEmpty()) {
        onBindViewHolder(holder, position)
    } else {
        val bundle = payloads[0] as Bundle
        holder.tvDesc.text = bundle.getString(KEY_DESC)
    }
}

當payloads為空時,Adapter必須執行完整繫結。至於原因看上文。

總結

  1. DiffUtil不僅可以配合RecyclerView,還可以與ListView等其他List配合使用,只需要自定義ListUpdateCallback即可,也就是更新操作的實現。
  2. 由於更新UI要在主執行緒,而DiffUtil又是耗時操作,當資料量大時,DiffUtil耗時也是漫長的,如果在主執行緒呼叫DiffUtil.calculateDiff,可能造成ANR。所以,應當開啟執行緒執行DiffUtil.calculateDiff而在主執行緒呼叫result.dispatchUpdatesTo(this)分發更新操作。可以這麼做:

    • 執行緒+Handler
    • RxJava

對於DiffUtil不能在主執行緒計算差異的問題,還可以使用DiffUtil的封裝類AsyncListDiffer或者ListAdapter。
3. RecyclerView.Adapter更新資料集必須放在分配更新操作之後,也就是DiffResutl呼叫dispatchUpdatesTo()以後。因為在此之前更新資料集,getChangePayload()將無效,因為在getChangePayload()還需要對新舊資料集中的Item比較。

Demo地址

參考文件