Advanced RecyclerView

本文源自 Yig?it Boyar360|AnDev 的演講,建議有空看下,絕對會有收獲:Yotube | Article | Slide,這里 Boyar 從整體架構(gòu)講述了 RecyclerView : 它是如何工作,有哪些優(yōu)先注意事項,以及你該如何充分利用它們。

Introduction

RecylerView 有三種主要組件:

  • LayoutManager 負(fù)責(zé)定位 views
  • Item Animator 負(fù)責(zé)管理動畫
  • Adapter 負(fù)責(zé)處理 views

在后續(xù)優(yōu)化 RecyclerView 的過程中,我們并沒有添加新的 API,而是加入新的組件,比如控制 Item drag,drop 和 swipe 動作的 ItemTouchHelper;以及控制 snap 動作的 SnapHelper。

I. 視圖更新 View Updates

首先要討論 View::requestLayout。這與 RecyclerView 無關(guān),它是 Android 視圖系統(tǒng)的一部分。

request_layput.png

當(dāng)你對某個 view 做了一些修改,這個 view 會向上層的 viewGroup 說:“我現(xiàn)在需要 request layout,因為我發(fā)生了變化?!比缓筮@句話會向上冒泡直到 根布局( root layout ) 回復(fù):“?? ,我在下個 布局幀( layout frame) 的時候通知你。”
下個幀開始時,根布局會通知所有的子 view:“重新 measure 自己,這是你們重新獲得 layout 的時刻?!泵總€子 view 將會遞歸地 measure 它們自身。現(xiàn)在,假如沒有 view 請求布局,所有的這些 measure 的尺寸將會被緩存。
現(xiàn)在所有的 view 的層次結(jié)構(gòu)趨于穩(wěn)定,這對于 RecyclerView 來說意味什么呢。
假如 Adapter::onBindViewHolder 有如下的邏輯:

onBindViewHolder(ViewHolder holder, int position) {
  ...
  imageLoader.loadImage(holder.imageView, ImgUrl, R.drawable.placeHolder);
}

ImageLoader 異步地從網(wǎng)絡(luò)上下載圖片資源,將其轉(zhuǎn)化為 bitmap,然后調(diào)用 ImageView,設(shè)置 image bitmap。
上述步驟發(fā)生時,ImageView 會說:“我原來的數(shù)據(jù)已經(jīng)無效了,讓我請求一次布局吧”。消息被傳遞到 imageView 的父布局 itemView,itemView 會說:“?? ,看來我的子 view 已經(jīng)無效了,我來請求布局吧”。最終消息被傳遞到 RecyclerView,RecyclerView 會復(fù)位所有的子 view ,這是一個很 expensive 的操作。
還記得 RecyclerView::setHasFixedSize 嗎,可以在這里使用。如果 RecyclerView 擁有固定的尺寸,它知道不需要復(fù)位子 view,也就不會請求布局。

has_fixed_size.png

然而 RecyclerView::setHasFixedSize 僅會使 RecyclerView 不會調(diào)用 requestLayout();對于其子 view ,還是會經(jīng)歷完整的 requestLayout() 過程,這同樣是個 expensive 的操作。

好消息是從 2011 年開始,ImageView 的繪制邏輯做了一些優(yōu)化:

// ImageView.java Since 2011
void setImageDrawable(Drawable drawable) {
  if (mDrawable != drawable) {
    int oldWidth = mDrawableWidth;
    int oldHeight = mDrawableHeight;
    updateDrawable(drawable);
    // 只有在 Drawable 尺寸變化時才請求布局
    if (oldWidth != mDrawableWidth || oldHeight != mDrawableHeight) {
      requestLayout();
    }
    invalidate();
  }
}

更新 Drawable 時,它會檢查 Drawable 大小是否有變化,如果不變,則不會調(diào)用 requestLayout(),只調(diào)用 invalidate(),invalidate 會在原位置上重新繪制視圖,這是一個 cheap 的操作。
對于 TextView 則不是這樣,即使你兩次設(shè)置相同的字符串,TextView 依然會調(diào)用 requestLayout()。

miss_cached.png

我們加載一些圖片,假如其中某張圖片不在內(nèi)存中會發(fā)生什么?在圖片加載完成之前,你添加了 placeholder。布局看起來很穩(wěn)健,但實際上并不是這樣。

watch_desire_reality.jpg

理想狀態(tài)下 The Tall Bird 未加載完,placeholder 空白展示,The Watch 始終位于左側(cè),但實際上 StaggeredGridLayoutManager 會將 The Watch 置于右側(cè),因為 item 檢測到其 ImageView 的 height 為 0,其余的 item 都會依據(jù)上層的 item 安置自己 ImageView 。

解決這種問題可以通過自定義 ImageView:

// AspectRatioImageView.java
private float mAspectRatio;
@Override
protected void onMeasure(int wSpec, int hSpec) {
  int width = MeasureSpec.getSize(wSpec);
  int height = (int) (width * mAspectRatio);
  setMeasureDimension(width, height);
}

自定義的 AspectRatioImageView 可以根據(jù)已知的 寬高比(aspect ratio) 調(diào)整自己的 size,然后復(fù)寫 onMeasure 方法,設(shè)置 measure 后的尺寸。依據(jù)之前提到的 setImageDrawable 機制,當(dāng)我們獲取到實際的圖片后,它并不會調(diào)用 requestLayout,而是直接更新 placeholder。
那么問題來了,如何設(shè)置寬高比呢?假設(shè)后端的 API 是這樣:

{
  "user" : {
      "name" : "Michael",
      "photoUrl" : "https://..."
  }
}

這無法解決我們的問題,如果 API 能提供圖片的 metadata 這樣會不會更好:

{
  "user" : {
      "name" : "Michael",
      "photoUrl" : {
          "width" : 300,
          "height" : 500,
          "url" : "https://...",
          "palette" : {}
  }
}

API 提供了寬和高,這樣我們方便計算寬高比,同時我們可以在下載實際圖片之前根據(jù)寬高比及 palette 設(shè)置好合適的 placeholder。

II. 數(shù)據(jù)更新 Data Updates

假設(shè)我們從服務(wù)端獲取到一組新的新聞數(shù)據(jù),然后刷新列表:

update_list.PNG

直接使用 Adapter::notifyDataSetChanged 會存在如下問題:

  • RecyclerView 更新數(shù)據(jù)集時并不知道到底數(shù)據(jù)集的哪里發(fā)生了改變,于是只能非常低效地從位置 0 開始,重新綁定一遍數(shù)據(jù)。

解決這種問題的方法也比較簡單,使用 Adapter::getItemId:

long getItemId(int position) {
  news.get(position).getId();
}

現(xiàn)在 RecyclerView 知道每個位置對應(yīng)的 ID,可以根據(jù) ID 判斷哪個位置該去綁定數(shù)據(jù),然而當(dāng)你使用 Adapter::notifyDataSetChanged 時,它同樣需要對其他的 item 進行多余的 measure 和 layout。針對這種場景,我們提供了 SortedList

list_rebind.PNG

SortedList

通過簡單的幾個方法實現(xiàn)了所有元素的排序,并且有內(nèi)置的邏輯提供 RecyclerView 的數(shù)據(jù)更新。

SortedList<Item> mSortedList = new SortedList<>(Item.class, new SortedListAdapterCallback<Item>(mAdapter)) {
  @Override
  public int compare(Item oldItem, Item newItem) {
    return oldItem.id - newItem.id;
  }
  @Override
  public boolean areItemsTheSame(Item oldItem, Item newItem) {
    return oldItem.id == newItem.id;
  }
  @Override
  public boolean areContentsTheSame(Item oldItem, Item newItem) {
    return oldItem.text.equals(newItem.text);
  }
});

通過使用 SortedList,我們可以將服務(wù)端獲取新的數(shù)據(jù)直接添加到 SortedList 上,它會為我們通知 adapter 數(shù)據(jù)更新:

void onFetched(List<News> newsList) {
  mSortedList.addAll(newsList);
}

SortedList 為我們添加 Item 提供了便捷的方法,但是假如我們獲取的新的 Item 數(shù)據(jù),是舊的 Item 的某項數(shù)值發(fā)生了變化,比如(add("To Rx or Not To Rx", 9)):

item_diff_votes.PNG

我們修改 compare 方法,根據(jù) votes 數(shù)值大小排列數(shù)據(jù):

SortedList<Item> mSortedList = new SortedList<>(Item.class, new SortedListAdapterCallback<Item>(mAdapter)) {
  @Override
  public int compare(Item oldItem, Item newItem) {
    return newItem.votes - oldItem.votes;
  }
  ...
});

得到的排序是這樣的:

item_dup_name.PNG

當(dāng)你插入某項數(shù)據(jù)到 SortedList 中時,它會簡單的做二分查找。從中間的 item 開始,判斷是向上還是向下比較,因此無法判斷原先的 item 是否已經(jīng)存在【數(shù)據(jù)集】中。
如何解決這種問題呢?SortedList 提供了 SortedList::updateItemAt 方法:

Map<Integer, Item> items;
void inset(Item) {
  Item existing = items.put(item.id, item);
  if (existing == null) {
    mSortedList.add(item);
  } else {
    int index = mSortedList.indexOf(existing);
    mSortedList.updateItemAt(index, item);
  }
}

SortedList 會對插入的 Item 進行判斷,假如存在數(shù)據(jù)集中,進行更新;假如不存在,直接添加。這個效果不錯,不過我們提供了更便捷的工具: DiffUtil。

DiffUtil

先看看使用的 API:

DiffResult result = DiffUtil.calculateDiff(new MyDiffCallback(oldList, newList));
mAdapter.setItems(newList);
result.dispatchUpdateTo(mAdapter);

DiffUtil::calculateDiff 接收 DiffUtil.Callback 回調(diào)方法,返回 DiffResult,設(shè)置新的數(shù)據(jù)集,然后調(diào)用DiffResult::dispatchUpdateTo 方法更新數(shù)據(jù)。

DiffUtil.CallbackSortedListAdapterCallback 實現(xiàn)的方法比較類似:

class MyCallback extends DiffUtil.Callback {
  @Override
  public int getOldListSize() {
    return mOld.size();
  }
  @Override
  public int getNewListSize() {
    return mNew.size();
  }
  // 判斷兩個數(shù)據(jù)集是否相同
  @Override
  public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
    return mOld.get(oldItemPosition).id == mNew.get(newItemPosition).id;
  }
  // 判斷兩個數(shù)據(jù)集中的元素是否相同
  @Override
  public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
    return mOld.get(oldItemPosition).equals(mNew.get(newItemPosition));
  }
}

III. 資源管理 Resource management

RecyclerView 的生命周期:

viewholder_lifecycle.PNG

ViewHolder 通過 onCreate 方法創(chuàng)建,然后綁定到某個位置,綁定后可以認(rèn)為 ViewHolder 快速 attach 到視圖中,接收到 onViewAttachedToWindow 回調(diào)后,ViewHolder 出現(xiàn)在屏幕中,對用戶可見。
當(dāng)用戶滑動或其他操作時,item 也許被 detach,比如當(dāng)你把某個 item 滑動到屏幕之外時,Layout Manager 決定移除這個條目,這個時候會收到 onViewDetachedFromWindow 回調(diào)。
例如我們有一個視頻訂閱列表,滑動列表,當(dāng)視頻滑出屏幕時停止播放,滑入屏幕時開始播放,這是一個很好的方式去改善視頻隱藏和出現(xiàn)時的用戶體驗。

RecyclerView 是異步的

RecyclerView 是異步的是什么意思?
這里并不是表示 RecyclerView 是多線程的,這里表示的是它處理事務(wù)是異步的。

pending_changes.PNG

當(dāng)新幀出現(xiàn)時,RecyclerView 會向 View 應(yīng)用所有待處理的修改并進行視圖更新。
例如你調(diào)用 scrollToPosition(15)。當(dāng)下個幀出現(xiàn)時,RecyclerView 會應(yīng)用這些修改:

recyclerView.scrollToPosition(15);
int x = layoutManager.getFirstVisibleItemPosition();

然后你通過 LayoutManager 獲取第一個可見的位置,它會返回15嗎?實際上不會,RecyclerView 并不會立即執(zhí)行滑動,而是等待下一幀開始才去執(zhí)行。

void onCreate(SavedInstanceState state) {
  ...
  // 兩個都是在下一幀執(zhí)行,所以它們看上去像是同步的。
  mRecyclerView.scrollToPosition(selectedPosition);
  mRecyclerView.setAdapter(mAdapter);
}

下面的方法也會執(zhí)行:

void onCreate(SavedInstanceState state) {
  ...
  mRecyclerView.scrollToPosition(selectedPosition);
  model.loadItems(items -> 
    mRecyclerView.setAdapter(
      new ItemAdapter(items));
  );
}

為什么在下一幀開始時,我們還沒有 adapter,設(shè)置的滑動還能奏效呢?
原因其實很簡單,當(dāng)你有一個 RecyclerView,在沒設(shè)置 adapter 和 Layout Manager 之前,它將忽略所有的 layout 調(diào)用。

ViewHolder艸

填充數(shù)據(jù)的最佳實踐:

class ViewHolder {
  ...
  // ViewHolder 代表一個 item,更加抽象,便于移植到 Presenter 等位置。
  public bindTo(Item item, ImageLoader loader) {
    titleView.setText(item.getTitle());
    bodyView.setText(item.getBody());
    loader.loadImage(iconView, item.getIconUrl());
  }
}
  void onBindViewHolder(ViewHolder holder, int position) {
    holder.bindTo(items.get(position), mImageLoader);
  }

ViewType 的最佳實踐:
RecyclerView::getItemViewType 方法直接返回 layout:

@Override
public int getItemViewType(int position) {
  User user = mItems.get(position);
  if (user.isPremium()) {
    return R.layout.premium;
  }
  return R.layout.basic;
}

生成 RecyclerView::ViewHolder:

public RecyclerView.ViewHoder onCreateViewHolder(ViewGroup parent, int viewType) {
  View view = mLayoutInflater.inflate(viewType, parent, false);
  return new ViewHolder(view);
}

Item Click Listener 的最佳實踐:

class MyAdapter {
  interface ItemClickListener {
    void onClick(Item item);
  }
  ItemClickListener itemClickListener;
  void setItemClickListener(ItemClickListener itemClickListener) {
   this.itemClickListener = itemClickListener;
  }

  public onCreateViewHolder(...) {
    final ViewHolder holder = ...;
    holder.itemView.setOnClickListener({
      // 獲取 view holder 在 adapter 中的位置
      int position = holder.getAdapterPosition();
      if (position != NO_POSITION) {
        itemClickListener.onClick(items[position]);
      }
    });
  }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容