適用於 Android 的 Epoxy

一個用於在 RecyclerView 中建構複雜畫面的 Android 函式庫
7,339
作者Eli Hart

Epoxy 是一個 Android 函式庫,用於在 RecyclerView 中建構複雜的畫面。它抽象化了 view holders、item types、item ids、span counts 等等的樣板程式碼,以簡化建構具有多種視圖類型的畫面。此外,Epoxy 還增加了對保存視圖狀態和自動比較項目變更的支援。

我們在 Airbnb 開發了 Epoxy,以簡化使用 RecyclerView 的流程,並加入我們需要的缺失功能。現在,我們在應用程式中的大多數主要畫面都使用 Epoxy,它大大提升了我們的開發人員體驗。

Sample app demo gif

下載

Gradle 是唯一支援的建置設定,因此只需將依賴項添加到專案的 build.gradle 檔案即可

dependencies {
  compile 'com.airbnb.android:epoxy:1.2.0'
}

(可選)如果您想使用 用於生成輔助類別的屬性,您還必須將註解處理器作為依賴項提供。

buildscript {
  dependencies {
    classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8'
  }
}

apply plugin: 'android-apt'

dependencies {
  compile 'com.airbnb.android:epoxy:1.2.0'
  apt 'com.airbnb.android:epoxy-processor:1.2.0'
}

基本用法

建立一個繼承自 EpoxyAdapter 的類別,並將您的 adapter 實例添加到 RecyclerView,就像您平常做的那樣。

建立 EpoxyModels 並將它們按照您希望顯示的順序添加到 adapter。基礎的 EpoxyAdapter 將處理膨脹您的視圖並將它們綁定到您的模型。

在這個範例中,我們的 PhotoAdapter 一開始只顯示一個標題頭和一個載入指示器。它有一個新增照片的方法,可能會在從網路請求載入照片時呼叫。

public class PhotoAdapter extends EpoxyAdapter {
  private final LoaderModel loaderModel = new LoaderModel();

  public PhotoAdapter() {
    addModels(new HeaderModel("My Photos"), loaderModel);
  }

  public void addPhotos(Collection<Photo> photos) {
    hideModel(loaderModel);
    for (Photo photo : photos) {
      insertModelBefore(new PhotoModel(photo), loaderModel);
    }
  }
}

Epoxy 模型

EpoxyAdapter 使用一個 EpoxyModels 的清單,以了解要顯示哪些視圖以及以何種順序顯示。您應該子類別化 EpoxyModel,以指定您的模型使用的版面配置以及如何將資料綁定到該視圖。

例如,上面範例中的 PhotoModel 可以像這樣建立

public class PhotoModel extends EpoxyModel<PhotoView> {
  private final Photo photo;

  public PhotoModel(Photo photo) {
    this.photo = photo;
    id(photo.getId());
  }

  @LayoutRes
  public int getDefaultLayout() {
    return R.layout.view_model_photo;
  }

  @Override
  public void bind(PhotoView photoView) {
    photoView.setUrl(photo.getUrl());
  }

  @Override
  public void unbind(PhotoView photoView) {
    photoView.clear();
  }
}

在這個情況下,PhotoModel 的類型是 PhotoView,因此 getDefaultLayout() 方法必須傳回一個版面配置資源,該資源會膨脹成一個 PhotoView。檔案 R.layout.view_model_photo 可能看起來像這樣

<?xml version="1.0" encoding="utf-8"?>
<PhotoView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="120dp"
    android:padding="16dp" />

Epoxy 可以很好地與自訂視圖搭配使用 - 在這種模式中,模型會保存資料並將其傳遞給視圖,版面配置檔案描述要使用哪個視圖以及如何設定其樣式,而視圖本身會處理資料的顯示。這與一般的 ViewHolder 模式略有不同,並允許將資料和視圖邏輯分離。

模型也允許您控制視圖的其他方面,例如 span size、id、保存狀態以及是否應顯示視圖。模型的這些方面將在下面詳細說明。

修改模型清單

EpoxyAdapter 的子類別可以存取 models 欄位,這是一個 List<EpoxyModel<?>>,指定要顯示哪些模型以及以何種順序顯示。此清單一開始是空的,子類別應該將模型添加到此清單,並在必要時對其進行修改,以建構其視圖。

每次修改清單時,您都必須使用標準的 RecyclerView 方法通知變更 - notifyDataSetChanged()notifyItemInserted() 等等。如同 RecyclerView 的慣例,應盡可能避免使用 notifyDataSetChanged(),而應使用更具體的方法(如 notifyItemInserted())。

存在輔助方法,例如 EpoxyAdapter#addModels(EpoxyModel<?>...),這些方法將修改清單並為您通知正確的變更。或者,您可以選擇利用 Epoxy 的 [自動比對差異](#diffing),以避免手動通知項目變更的負擔。

[基本用法](#basic-usage) 章節中的範例使用了這些輔助方法,但可以變更為直接存取模型清單,如下所示

public class PhotoAdapter extends EpoxyAdapter {
  private final LoaderModel loaderModel = new LoaderModel();

  public PhotoAdapter() {
    models.add(new HeaderModel("My Photos"));
    models.add(loaderModel);
    notifyItemRangeInserted(0, 2);
  }

  public void addPhotos(Collection<Photo> photos) {
    for (Photo photo : photos) {
      int loaderPosition = models.size() - 1;
      models.add(loaderPosition, photo);
      notifyItemInserted(loaderPosition);
    }
  }
}

直接存取模型清單可讓您在需要時完全靈活地安排和重新安排模型。

修改模型清單並通知變更後,EpoxyAdapter 將參考此清單,以便為每個模型建立和綁定適當的視圖。

自動比對差異

對於具有複雜資料結構支援的多種視圖類型的畫面,Epoxy 特別有用。在這些情況下,資料可能會透過網路請求、非同步 observable、使用者輸入或其他來源更新,這些來源會要求您更新模型並通知 adapter 正確的變更。

手動追蹤所有這些變更很困難,並且正確執行會增加很大的負擔。在這些情況下,您可以利用 Epoxy 的自動比對差異來減少負擔,同時也能有效地僅更新已變更的視圖。

若要啟用比對差異,請在 EpoxyAdapter 子類別的建構函式中呼叫 enableDiffing()。然後,在修改模型清單後,只需呼叫 notifyModelsChanged(),讓比對差異演算法找出變更的內容。這會將適當的呼叫分派至插入、移除、變更或移動您的模型,並在必要時進行批次處理。

為使此功能正常運作,您必須將 stable ids 設定為 true (這是[預設值](#model-ids)),並在您的模型上實作 hashCode(),以完全定義模型的狀態。此雜湊用於偵測模型上的資料何時變更。

您可以混合使用正常的通知呼叫(例如 notifyItemInserted())與 notifyModelsChanged(),如果您明確知道變更的內容,因為這樣會比依賴比對差異演算法更有效率。

一個常見的使用模式是在您的 adapter 上有一個方法,根據狀態物件更新模型。這是一個非常簡單的範例。在實務上,您可能會有很多模型、隱藏或顯示模型、插入新模型、涉及點擊監聽器等等。

public class MyAdapter extends EpoxyAdapter {
  private final HeaderModel headerModel = new HeaderModel();
  private final BodyModel bodyModel = new BodyModel();
  private final FooterModel footerModel = new FooterModel();

  public MyAdapter() {
    enableDiffing();

    addModels(
      headerModel,
      bodyModel,
      footerModel);
  }

  public void setData(MyDataClass data) {
    headerModel.setData(data.headerData());
    bodyModel.setData(data.bodyData());
    footerModel.setData(data.footerData());

    notifyModelsChanged();
  }
}

為了避免手動負擔和在所有模型上實作 hashCode() 的樣板程式碼,您可以在模型欄位上使用 [@ModelAttribute](#annotations) 註解來為您產生該程式碼。

使用比對差異時,請注意一些效能上的陷阱。

首先,比對差異必須處理您清單中的所有模型,因此可能會影響超過數百個模型的效能。在大多數情況下,比對差異演算法會以線性時間執行,但仍然必須處理您清單中的所有模型。然而,項目移動速度很慢,在最差的情況下,當打亂清單中的所有模型時,效能為 (n^2)/2。

其次,每個差異都必須重新計算每個模型的雜湊碼,才能判斷項目變更。請避免在您的雜湊碼中包含不必要的計算,因為這會顯著減慢差異比對的速度。

第三,請注意不要無意中變更模型狀態,例如使用點擊監聽器。例如,通常會在模型上設定點擊監聽器,然後在綁定時在視圖上設定。這裡的一個常見錯誤是使用匿名內部類別作為點擊監聽器,這會影響模型雜湊碼,並要求在更新或重新建立模型時重新綁定視圖。相反地,您可以將監聽器儲存為欄位,以便在每個模型中重複使用,使其不會變更模型的雜湊碼。另一個常見的錯誤是在模型的綁定呼叫期間修改會影響雜湊碼的模型狀態。

請記住這些注意事項,請避免不必要地呼叫 notifyModelsChanged(),並盡可能批次處理變更。對於非常長的模型清單,或對於有許多項目移動的情況,您可能更喜歡使用手動通知,而不是自動比對差異,以防止畫面掉幀。話雖如此,比對差異的速度相當快,我們已經在最多 600 個模型中使用它,而效能影響微乎其微。一如既往,請分析您的程式碼,並確保它適用於您的特定情況。

關於演算法的注意事項 - 我們正在使用我們自行撰寫的自訂比對差異演算法。在我們完成這項工作後,Android 支援函式庫類別 DiffUtil 才發布。我們繼續使用原來的演算法,因為在我們的測試中,它比 DiffUtil 快約 35%。然而,它確實進行了一些最佳化,比 DiffUtil 使用更多的記憶體。我們重視速度的提升,但未來可能會加入選擇使用哪個演算法的選項。

綁定模型

Epoxy 使用 EpoxyModel#getLayout() 提供的版面配置資源 ID 來為該模型建立視圖。當呼叫 RecyclerView.Adapter#onBindViewHolder(ViewHolder holder, int position) 時,EpoxyAdapter 會查閱指定位置的模型,並使用膨脹的視圖呼叫 EpoxyModel#bind(View)。您可以在模型中覆寫此綁定呼叫,以使用您在模型中設定的任何資料更新視圖。

由於 RecyclerView 會在可能的情況下重複使用視圖,因此一個視圖可能會被綁定多次。您應該確保您對 EpoxyModel#bind(View) 的使用會根據模型中的資料完全更新視圖。

當視圖被回收時,EpoxyAdapter 會呼叫 EpoxyModel#unbind(View),讓您有機會釋放與視圖關聯的任何資源。這是一個清除視圖中大型或昂貴資料(例如點陣圖)的好機會。

如果 recycler view 使用 onBindViewHolder(ViewHolder holder, int position, List<Object> payloads) 提供了非空的 payload 清單,則會改為呼叫 EpoxyModel#bind(View, List<Object>),以便可以根據變更的內容最佳化模型以重新綁定。如果只變更了視圖的一部分,這可以幫助您防止不必要的版面配置變更。

模型 ID

RecyclerView 的 stable ideas 概念已內建於 EpoxyModels 中,並且在啟用 stable ids 時,系統運作效果最佳。

每次實例化模型時,都會自動為其分配一個唯一的 ID。您可以使用 id(long) 方法覆寫此 ID,這對於表示資料庫中物件的模型通常很有用,這些物件已經有與之關聯的 ID。

預設 ID 總是負值,因此它們不太可能與手動設定的 ID 衝突。當使用預設 ID 的模型時,通常有助於將該模型儲存為 adapter 中的欄位,以便模型和 ID 在 adapter 的生命週期內是唯一且恆定的。這對於標題等較靜態的視圖很常見,而從伺服器載入的動態內容則可能使用手動 ID。

強烈建議使用 stable ids,但並非必要。根據預設,EpoxyAdapter 會在其建構函式中將 setHasStableIds 設定為 true,但如果需要,您可以在子類別的建構函式中將其設定為 false。

Adapter 依賴 stable ids 來保存視圖狀態和進行自動比對差異。您必須保持啟用 stable ids 才能使用這些功能。stable ids 和差異比對的結合可以實現相當不錯的項目動畫,而無需您額外付出任何努力。

將模型新增至 adapter 後,其 ID 將無法再變更。這樣做會擲回錯誤。這可讓比對差異演算法進行多項最佳化,以避免檢查是否沒有進行移除、插入或移動。

指定版面配置

EpoxyModel 必須實作的唯一方法是 getDefaultLayout。此方法指定當 adapter 為該 model 建立 view holder 時,應該使用哪個 layout 資源。layout 資源 ID 也作為 EpoxyModel 的 view type,以便共享同一個 layout 的 view 可以被回收利用。由 layout 資源膨脹的 View 類型應為 EpoxyModel 的參數化類型,以便將正確的 View 類型傳遞給 model 的 bind 方法。

如果您想要動態變更 model 使用的 layout,您可以呼叫 EpoxyModel#layout(layoutRes) 並傳入新的 layout ID。這讓您可以輕鬆變更 view 的樣式,例如大小、內距等。如果您想要重複使用同一個 model,但根據其使用位置(例如橫向與縱向,或手機與平板電腦)改變 view 的樣式,這會很有用。

隱藏模型

如果您想要從 Recycler View 中移除一個 view,您可以從列表中移除其 model,或者直接將該 model 設定為隱藏。在 view 有條件顯示,且您想要輕鬆切換顯示與隱藏狀態時,隱藏 model 會很有用。

您可以使用 model.hide() 來隱藏它,並使用 model.show() 來顯示它,或使用條件式的 model.show(boolean)

隱藏的 model 技術上仍然在 RecyclerView 中,但它們會被變更為使用一個不佔用空間的空 layout。這表示變更 model 的可見性必須伴隨對 adapter 進行適當的 notifyItemChanged 呼叫。

adapter 上有一些輔助方法,例如 EpoxyAdapter#hideModel(model),它會設定 model 的可見性,然後在可見性變更時為您通知項目變更。

儲存的狀態

RecyclerView 不支援像一般的 ViewGroup 那樣儲存其子項的 view 狀態。EpoxyAdapter 透過自行管理每個 view 的儲存狀態,增加了這個缺失的支援。

儲存 view 狀態對於 view 由使用者修改的情況很有用,例如核取方塊、文字編輯框、展開/收合等。這些可以被視為 model 不需要知道的暫時性狀態。

要啟用此支援,您必須啟用穩定 ID。然後,覆寫 EpoxyModel#shouldSaveViewState,並在每個應該儲存狀態的 model 上回傳 true。啟用此功能後,EpoxyAdapter 會手動呼叫 View#saveHierarchyState,以在 view 被取消綁定時儲存其狀態。當 view 再次被綁定時,該狀態會被還原。這將在 view 滾動離開螢幕然後滾動回螢幕時儲存其狀態。

若要在不同的 adapter 實例之間儲存狀態,您必須呼叫 EpoxyAdapter#onSaveInstanceState(例如在您的 activity 的 onSaveInstanceState 方法中),然後在再次建立 adapter 後,使用 EpoxyAdapter#onRestoreInstanceState 還原它。

由於 view 的狀態與其 model ID 相關聯,因此 model 在不同的 adapter 實例中必須具有恆定的 ID。這表示您應該手動在使用儲存狀態的 model 上設定 ID。

網格支援

EpoxyAdapter 可以與 RecyclerView 的 GridLayoutManager 一起使用,以允許 EpoxyModels 變更其跨距大小。EpoxyModels 可以透過覆寫 int getSpanSize(int totalSpanCount, int position, int itemCount) 來聲明不同的跨距大小,以便根據 layout manager 的跨距計數以及 model 在 adapter 中的位置來改變其跨距大小。EpoxyAdapter.getSpanSizeLookup() 會回傳一個跨距大小查詢物件,該物件將查詢呼叫委派給每個 EpoxyModel。

int spanCount = 2;
GridLayoutManager layoutManager = new GridLayoutManager(getContext(), spanCount);
epoxyAdapter.setSpanCount(spanCount);
layoutManager.setSpanSizeLookup(epoxyAdapter.getSpanSizeLookup());

使用 @EpoxyAttribute 產生輔助類別

您可以使用 EpoxyAttribute 注解,為您的 model 類別產生具有 setter、getter、equals 和 hashcode 的子類別,來減少程式碼的樣板。

例如,您可以這樣設定一個 model

public class HeaderModel extends EpoxyModel<HeaderView> {
  @EpoxyAttribute String title;
  @EpoxyAttribute String subtitle;
  @EpoxyAttribute String description;
  @EpoxyAttribute(hash=false) View.OnClickListener clickListener;

  @LayoutRes
  public int getDefaultLayout() {
    return R.layout.view_model_header;
  }

  @Override
  public void bind(HeaderView view) {
    view.setTitle(title);
    view.setSubtitle(subtitle);
    view.setDescription(description);
    view.setOnClickListener(clickListener);
  }
}

將會產生一個 HeaderModel_.java 類別,它是 HeaderModel 的子類別,您會直接使用產生的類別。

models.add(new HeaderModel_()
    .title("My title")
    .subtitle("my subtitle")
    .description("my description"));

setter 會回傳 model,以便它們可以用於 builder 樣式。產生的類別包含所有已註解屬性的 hashCode() 實作,以便可以在 [自動差異比較](#diffing) 中使用該 model。有時候,您可能不希望某些欄位包含在您的 hash code 和 equals 中,例如在每次綁定呼叫中重新建立的點擊偵聽器。若要告知 Epoxy 跳過該註解,請將 hash=false 新增至註解中。

產生的類別名稱一律為原始類別的名稱,並在末尾附加一個底線。如果原始類別是抽象的,則不會為其產生類別。如果 model 類別是從其他也具有 EpoxyAttributes 的 model 子類別化而來的,則產生的類別會包含所有超類別的屬性。產生的類別會複製原始 model 類別上的所有建構子。如果原始 model 類別有任何與產生的 setter 相符的方法名稱,則產生的方法將會呼叫 super。

這是 Epoxy 的一個可選方面,您可以選擇不使用它,但它可以幫助減少 model 中的樣板程式碼。