RecyclerView中DiffUtil的一些注意事項

節能刷新

移動設備屏幕大小有限(不得不說我是頑固的小屏愛好者,大於5.5寸難以接受,時代已經拋棄我了哈哈),列表(List)可以說是一個出現非常高頻的交互設計。大多數情況下我們的列表不僅僅是一次性加載本地數據,而要應付來自網絡的各種動態內容,可能是增加、刪除等操作。

在Android開發中,一個耳熟能詳的方法就是 notifyDataSetChanged ,在適配器(Adapter)的設計模式下,每當我們的列表數據發生變更時,就需要調用此方法來更新UI。然而,這個方法並不“節能”,它會同時刷新列表中的所有item,包括那些並沒有變化的數據,這樣就帶來很多計算資源的浪費。要知道,從你的一個 setText 或者 setImageResource 方法調用到最終呈現到屏幕上,軟件到硬件,中間經歷了非常複雜的過程。基於能省則省的移動開發原則,有沒有更好的辦法呢?

DiffUtil用起來

谷歌確實也考慮到了這個問題,所以不知道在什麼時候(暫時沒有去查閱)推出了DiffUtil這個解決方案。在RecyclerView的依賴包下面,可以看到,除了DiffUtil,還有異步處理數據等一系列有趣的工具。
在這裏插入圖片描述
DiffUtil的運用邏輯非常簡單,大致如下:

  • 實現對比新舊數據的方法(類似比較器),這樣DiffUtil便知道當新數據來臨時,該不該更新某個item。
  • 更新數據時,把新舊數據丟給DiffUtil,底層會根據你實現的對比方法,利用一種差分算法自動計算出差異,最後局部更新到UI。

這樣做的好處就是避免了不必要的UI更新,DiffUtil計算出差異之後,只刷新產生變動的item。具體地,我們可以在Adapter的 onBindViewHolder 方法打斷點或者日誌觀察,或者調用 registerAdapterDataObserver 方法監聽item的各種操作情況。其次,以前的 notifyDataSetChanged 方法由於會刷新整個列表所以沒有原生的動畫效果,而DiffUtil內部最終調用了各種 notifyItemXXX 方法。

DiffUtil的使用也很簡單:

1、先實現比較新舊數據的回調,可以是一個獨立的類,也可以寫成Adapter的內部類:

public class BaseXXXAdapter<T> extends RecyclerView.Adapter {
    // ...

    private class DiffCallback extends DiffUtil.Callback {
        private List<T> oldData, newData;

        DiffCallback(List<T> oldData, List<T> newData) {
            this.oldData = oldData;
            this.newData = newData;
        }

        @Override
        public int getOldListSize() {
            return oldData.size();
        }

        @Override
        public int getNewListSize() {
            return newData.size();
        }

        @Override
        public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
            T oldT = oldData.get(oldItemPosition);
            T newT = newData.get(newItemPosition);
            // 實際情況最好是在此處對比新舊數據的id(比如用戶uid),這裏爲了方便示例直接equals對象了
            // 若此處返回true,則DiffUtil會再調用下面的areContentsTheSame方法,進一步對比UI是否有變化
            // 若此處返回false,則說明id都不同,肯定不是一個item
            return Objects.equals(oldT, newT);
        }

        @Override
        public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
            // TODO 比較新舊數據(主要是UI展示內容)是否相同,這裏爲了方便示例直接返回true
            return true;
        }
    }
}

2、然後在Adapter內部實現一個update數據的方法:

    @Override
    public void updateData(List<T> newData) {
        DiffUtil.DiffResult result = DiffUtil.calculateDiff(new DiffCallback(getData(), newData));
        // 這裏的getData即表示獲取整個列表的數據,自行實現即可
        getData().clear();
        getData().addAll(newData);
        result.dispatchUpdatesTo(this);
    }

注意這裏的 dispatchUpdatesTo 可以在clear之前,也可以在addAll之後,實際效果暫未發現什麼區別,之前查閱資料包括官方示例也都是最後執行dispatch,姑且認爲這樣算標準吧。

3、……咦,怎麼才兩步,確實就這麼簡單。重點還是 areItemsTheSameareContentsTheSame 方法,後者大部分時候只需要對比每個item上UI展示出來的數據即可,因爲用戶只關心眼見的內容。

解決使用後產生的問題

我們會發現在上面的使用示例中,updateData 方法內部對原數據進行了清除和添加的操作,這會導致一個問題便是:列表數據集合中的對象已經變了,即使其某項對應的UI內容沒有發生變化

舉個例子,一個通訊錄列表裏面有 [小明, 小紅] 兩個人,對應內存地址爲 [a1, a2],現在通過上述 updateData 方法更新了通訊錄列表,UI內容變成了 [小王, 小紅],對應內存地址爲 [b1, b2]。對用戶來說小紅這個item看上去沒有發生變化,但其實對應的數據類對象已經不同。而且此時 onBindViewHolder 方法只會觸發一次,將小明更新成小王,而不會觸發小紅那個position對應的 onBindViewHolder

上述細節很關鍵,如果開發過程中綁定(bind)數據不恰當的話,就容易造成各種奇異問題,比如網上資料最多的DiffUtil導致item點擊事件數據錯位問題、數組越界崩潰問題等等。

這裏的“不恰當”,絕大部分情況下,總結出來:其實指的就是在 onBindViewHolder 方法中持有了某個位置(position)對應數據的不可變對象。最常見的誤用示例就是在 onBindViewHolder 中設置某些控件的點擊事件並引用數據對象:

    // 此處假設item的數據類爲User
    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
        MyItemViewHolder h = (MyItemViewHolder) holder;
        User user = getData().get(position);
        h.mNameTextView.setOnClickListener(v -> {
            // 第2種寫法:User user = getData().get(position);
            // 假設這裏是點擊item跳轉到該User對應的個人主頁界面
            startWebView(user.getHomePageUrl());
        });
    }

在不接入DiffUtil之前,上面這段代碼沒有任何問題,因爲我們都是使用 notifyDataSetChanged 方法來更新UI,每次更新調用到 onBindViewHolder 時,點擊事件都會重新設置,get出來的user對象自然也是最新的。一旦我們使用了DiffUtil,就會出問題了。

回到上面小王綠了小明的例子,在我們的 updateData 方法執行後,如果我們只對比了user的名字這個屬性(其實也只需要對比這個屬性),那麼小紅那一個item就不會觸發對應的 onBindViewHolder ,即小紅的點擊事件回調裏,仍然持有着舊數據集的user對象(對應那個內存地址a2)。但實際上小紅應該對應 b2 那個內存了,這就造成 a2 內存無法釋放,問題是不是顯得有點嚴重了。

有同學說無所謂呀,反正點擊事件依然有效。那如果我說網絡數據刷新下來小紅的 homePageUrl 變了呢?是不是還得把這個屬性加入DiffUtil的對比方法中?這樣最終會導致小紅的 onBindViewHolder 方法也執行,跟 notifyDataSetChanged 豈不是沒什麼兩樣了?

此外,若get對象寫成註釋中的第2種寫法,且列表第0個位置的item被刪了呢?小紅頂上去變成了第0個,此時由於小紅的UI內容沒變,只是位置變了,所以 onBindViewHolder 依然不會執行。以上面的示例代碼來看,當再次點擊小紅時,就會直接出現數組越界的異常。因爲position還是之前的1,而此時小紅的position已經爲0。

顯然上述出現的這些問題不符合谷歌的設計初衷,也不符合我們使用DiffUtil的初衷。其實解決辦法很簡單,就是要對 onBindViewHolder 方法有一個正確的認知,其原則就是:

  • onBindViewHolder 只做UI內容的更新,如 setTextsetImageXXX 等方法。做到數據對象一次性使用。
  • 不要跨作用域持有與位置(position)相關的數據,比如每個item的數據對象。尤其就是避免在 onBindViewHolder 中設置點擊事件監聽。

正確的點擊事件監聽還是參照如下形式比較好:

// 比如這是某個Base適配器類
public class BaseXXXAdapter<T> extends RecyclerView.Adapter {
    // ...
    private View.OnClickListener mOnClickListener;
    private View.OnLongClickListener mOnLongClickListener;
    private OnItemClickListener mOnItemClickListener;

    public interface OnItemClickListener {
        void onItemClick(View view, RecyclerView.ViewHolder holder, int position);

        void onItemLongClick(View view, RecyclerView.ViewHolder holder, int position);
    }

    public BaseXXXAdapter(Context context) {
        // ...
        mOnClickListener = v -> {
            RecyclerView.ViewHolder h = (RecyclerView.ViewHolder) v.getTag();
            int pos = h.getAdapterPosition();
            if (mOnItemClickListener != null) {
                mOnItemClickListener.onItemClick(v, h, pos);
            }
        };
        mOnLongClickListener = v -> {
            RecyclerView.ViewHolder h = (RecyclerView.ViewHolder) v.getTag();
            int pos = h.getAdapterPosition();
            if (mOnItemClickListener != null) {
                mOnItemClickListener.onItemLongClick(v, h, pos);
            }
            return true;
        };
    }

    public void setOnItemClickListener(OnItemClickListener clickListener) {
        this.mOnItemClickListener = clickListener;
    }

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        // ...省略holder實例化
        holder.itemView.setTag(holder); // 把holder當tag存
        holder.itemView.setOnClickListener(mOnClickListener);
        holder.itemView.setOnLongClickListener(mOnLongClickListener);
        return holder;
    }
}

// 繼承實現的實際業務Adapter
public class XXXAdapter extends BaseXXXAdapter<User> {
    public XXXAdapter(Context context) {
        setOnItemClickListener(new OnItemClickListener() {
            @Override
            public void onItemClick(View view, RecyclerView.ViewHolder holder, int position) {
                MyItemViewHolder h = (MyItemViewHolder) holder;
                // 每次點擊都保證了爲對應位置的數據,再也不用擔心數據錯位問題了
                User user = getData().get(position);
            }

            @Override
            public void onItemLongClick(View view, RecyclerView.ViewHolder holder, int position) {
                // ...
            }
        });
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章