Android用Epoxy

RecyclerViewで複雑な画面を構築するためのAndroidライブラリ
7,339
作成者Eli Hart

Epoxyは、RecyclerViewで複雑な画面を構築するためのAndroidライブラリです。ビューホルダー、アイテムタイプ、アイテムID、スパン数などの定型コードを抽象化し、複数のビュータイプを持つ画面の構築を簡素化します。さらに、Epoxyはビューの状態の保存とアイテム変更の自動差分処理をサポートします。

Airbnbでは、RecyclerViewの操作を簡素化し、必要な不足機能を補うためにEpoxyを開発しました。現在、アプリのメイン画面のほとんどで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を拡張するクラスを作成し、通常どおりアダプターのインスタンスをRecyclerViewに追加します。

EpoxyModelを作成し、表示したい順序でアダプターに追加します。ベースの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は、どのビューをどの順序で表示するかを知るために、EpoxyModelのリストを使用します。モデルが使用するレイアウトと、そのビューにデータをバインドする方法を指定するには、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();
  }
}

この場合、PhotoModelPhotoViewで型指定されているため、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パターンとは少し異なり、データとビューロジックの分離を可能にします。

モデルを使用すると、スパンサイズ、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は、複雑なデータ構造に裏打ちされた多くのビュータイプを持つ画面に特に役立ちます。このような場合、データはネットワークリクエスト、非同期オブザーバブル、ユーザー入力、またはモデルを更新してアダプターへの適切な変更を通知する必要がある他のソースによって更新される可能性があります。

これらの変更をすべて手動で追跡することは難しく、正しく行うには大きなオーバーヘッドが発生します。このような場合は、Epoxyの自動差分処理を利用してオーバーヘッドを削減しながら、変更されたビューのみを効率的に更新できます。

差分処理を有効にするには、EpoxyAdapterサブクラスのコンストラクターでenableDiffing()を呼び出します。次に、モデルリストを変更した後、notifyModelsChanged()を呼び出すだけで、差分アルゴリズムが変更された内容を把握できます。これにより、モデルの挿入、削除、変更、または移動への適切な呼び出しがディスパッチされ、必要に応じてバッチ処理されます。

これが機能するためには、安定したIDをtrue(これは[デフォルト](#model-ids)です)に設定したままにし、モデルの状態を完全に定義するためにモデルにhashCode()を実装する必要があります。このハッシュは、モデルのデータが変更されたことを検出するために使用されます。

具体的に何が変更されたかがわかっている場合は、notifyItemInserted()などの通常の通知呼び出しとnotifyModelsChanged()を組み合わせて使用できます。これは、差分アルゴリズムに依存するよりも効率的です。

この一般的な使用パターンは、状態オブジェクトに従ってモデルを更新するアダプターにメソッドを持たせることです。以下は非常に簡単な例です。実際には、さらに多くのモデルがあり、モデルを非表示にしたり表示したり、新しいモデルを挿入したり、クリックリスナーを含めたりすることがあります。

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になります。

第二に、各差分では、アイテムの変更を判別するために各モデルのハッシュコードを再計算する必要があります。ハッシュコードに不要な計算を含めないようにしてください。これは、差分を大幅に遅くする可能性があります。

第三に、クリックリスナーなど、意図せずにモデルの状態を変更することに注意してください。たとえば、モデルにクリックリスナーを設定するのが一般的であり、バインド時にビューに設定されます。ここでの簡単な間違いは、匿名の内部クラスをクリックリスナーとして使用することです。これにより、モデルのハッシュコードに影響を与え、モデルが更新または再作成されたときにビューを再バインドする必要があります。代わりに、リスナーをフィールドとして保存して各モデルで再利用し、モデルのハッシュコードを変更しないようにすることができます。もう1つの一般的な間違いは、モデルのバインド呼び出し中にハッシュコードに影響するモデルの状態を変更することです。

これらの点を考慮して、notifyModelsChanged()を不必要に呼び出すことを避け、変更をできるだけバッチ処理してください。モデルのリストが非常に長い場合や、アイテムの移動が多い場合は、フレームドロップを防ぐために、自動差分処理よりも手動通知を使用することをお勧めします。とはいえ、差分処理はかなり高速であり、パフォーマンスへの影響が無視できる程度で最大600のモデルで使用しています。常に、コードをプロファイルして、特定の状況で機能することを確認してください。

アルゴリズムに関する注意事項 - Airbnb社内で作成したカスタム差分アルゴリズムを使用しています。AndroidサポートライブラリクラスDiffUtilは、この作業が完了した後にリリースされました。当社のテストではDiffUtilよりも約35%高速であるため、引き続き元のアルゴリズムを使用しています。ただし、DiffUtilよりも多くのメモリを使用する最適化を行います。速度の向上を重視していますが、将来的には使用するアルゴリズムを選択できるオプションを追加する可能性があります。

モデルのバインディング

Epoxyは、EpoxyModel#getLayout()によって提供されるレイアウトリソースIDを使用して、そのモデルのビューを作成します。RecyclerView.Adapter#onBindViewHolder(ViewHolder holder, int position)が呼び出されると、EpoxyAdapterは指定された位置にあるモデルを検索し、インフレートされたビューを使用してEpoxyModel#bind(View)を呼び出します。モデルに設定したデータでビューを更新するには、モデルでこのバインド呼び出しをオーバーライドできます。

RecyclerViewは可能な場合にビューを再利用するため、ビューは複数回バインドされる場合があります。EpoxyModel#bind(View)の使用法が、モデルのデータに従ってビューを完全に更新していることを確認する必要があります。

ビューがリサイクルされると、EpoxyAdapterEpoxyModel#unbind(View)を呼び出し、ビューに関連付けられたリソースを解放する機会を与えます。これは、ビットマップなどの大きくてコストのかかるデータからビューをクリアする良い機会です。

リサイクラービューがonBindViewHolder(ViewHolder holder, int position, List<Object> payloads)で空ではないペイロードのリストを提供した場合、代わりにEpoxyModel#bind(View, List<Object>)が呼び出されるため、モデルは変更された内容に従って再バインドするように最適化できます。これにより、ビューの一部のみが変更された場合、不要なレイアウト変更を防ぐことができます。

モデルID

安定したアイデアのRecyclerViewの概念はEpoxyModelに組み込まれており、安定したIDが有効になっている場合にシステムが最適に機能します。

モデルがインスタンス化されるたびに、一意のIDが自動的に割り当てられます。このIDは、id(long)メソッドでオーバーライドできます。これは、データベースからのオブジェクトを表すモデルの場合に特に便利です。データベースには、すでにIDが関連付けられているためです。

デフォルトのIDは、手動で設定されたIDと衝突する可能性を低くするために、常に負の値になります。デフォルトのIDを持つモデルを使用する場合、そのモデルをアダプターのフィールドとして保存すると、モデルとIDがアダプターのライフサイクル全体で一意で一定になるため、役立つことがよくあります。これは、ヘッダーのようなより静的なビューでは一般的ですが、サーバーからロードされた動的なコンテンツは、手動IDを使用する可能性が高くなります。

安定したIDを使用することを強く推奨しますが、必須ではありません。デフォルトでは、EpoxyAdapterはそのコンストラクターでsetHasStableIdsをtrueに設定しますが、必要に応じて、サブクラスのコンストラクターでfalseに設定できます。

アダプターは、ビューの状態の保存と自動差分処理のために、安定したIDに依存しています。これらの機能を使用するには、安定したIDを有効にしたままにする必要があります。安定したIDと差分処理の組み合わせにより、特別な作業をせずに、かなり良好なアイテムアニメーションが可能になります。

モデルがアダプターに追加されると、そのIDを変更することはできません。変更しようとするとエラーがスローされます。これにより、差分アルゴリズムは、削除、挿入、または移動が行われていない場合に、それらのチェックを回避するためのいくつかの最適化を行うことができます。

レイアウトの指定

EpoxyModelが実装しなければならない唯一のメソッドは、getDefaultLayoutです。このメソッドは、そのモデルのビューホルダーを作成するときに、アダプターが使用するレイアウトリソースを指定します。レイアウトリソースIDは、EpoxyModelのビュータイプとしても機能するため、レイアウトを共有するビューを再利用できます。レイアウトリソースによってインフレートされるViewのタイプは、EpoxyModelのパラメーター化されたタイプである必要があり、適切なViewタイプがモデルのbindメソッドに渡されます。

モデルに使用するレイアウトを動的に変更する場合は、新しいレイアウトIDを使用してEpoxyModel#layout(layoutRes)を呼び出すことができます。これにより、サイズ、パディングなどのビューのスタイルを簡単に変更できます。これは、同じモデルを再利用しながら、使用場所(たとえば、横向きと縦向き、またはスマートフォンとタブレット)に基づいてビューのスタイルを変更する場合に役立ちます。

モデルの非表示

RecyclerViewからビューを削除する場合は、リストからモデルを削除するか、モデルを非表示に設定するだけです。モデルを非表示にすることは、ビューが条件付きで表示される場合、および表示と非表示を簡単に切り替えたい場合に役立ちます。

model.hide()を呼び出して非表示にし、model.show()を呼び出して表示するか、条件付きのmodel.show(boolean)を使用できます。

非表示のモデルは技術的にはRecyclerViewにまだ存在しますが、スペースを取らない空のレイアウトを使用するように変更されます。これは、モデルの可視性を変更するには、アダプターへの適切なnotifyItemChanged呼び出しを伴う必要があることを意味します。

アダプターには、EpoxyAdapter#hideModel(model)のようなヘルパーメソッドがあり、モデルの可視性を設定してから、可視性が変更された場合にアイテムの変更を通知します。

保存された状態

RecyclerViewは、通常のViewGroupのように子ビューの状態を保存することをサポートしていません。EpoxyAdapterは、各ビューの保存された状態を独自に管理することで、この欠落したサポートを追加します。

ビューの状態の保存は、チェックボックス、編集テキスト、展開/折りたたみなど、ユーザーによってビューが変更される場合に役立ちます。これらは、モデルが知る必要のない一時的な状態と見なすことができます。

このサポートを有効にするには、安定したIDを有効にする必要があります。次に、状態を保存する必要がある各モデルでEpoxyModel#shouldSaveViewStateをオーバーライドし、trueを返します。これが有効になっている場合、EpoxyAdapterは、ビューのバインドが解除されたときに、View#saveHierarchyStateを手動で呼び出してビューの状態を保存します。その状態は、ビューが再びバインドされるときに復元されます。これにより、ビューが画面外にスクロールされてから画面に戻ってスクロールされたときに、ビューの状態が保存されます。

別のアダプターインスタンス間で状態を保存するには、EpoxyAdapter#onSaveInstanceState(たとえば、アクティビティのonSaveInstanceStateメソッド)を呼び出し、アダプターが再び作成されたら、EpoxyAdapter#onRestoreInstanceStateで復元する必要があります。

ビューの状態はモデルIDに関連付けられているため、モデルは、アダプターインスタンス間で一定のIDを持つ必要があります。つまり、保存された状態を使用しているモデルには、手動でIDを設定する必要があります。

グリッドサポート

EpoxyAdapterは、RecyclerViewのGridLayoutManagerと一緒に使用して、EpoxyModelsがスパンサイズを変更できるようにすることができます。EpoxyModelsは、int getSpanSize(int totalSpanCount, int position, int itemCount)をオーバーライドして、レイアウトマネージャーのスパン数と、アダプター内のモデルの位置に基づいてスパンサイズを変化させることで、さまざまなスパンサイズを要求できます。EpoxyAdapter.getSpanSizeLookup()は、ルックアップ呼び出しを各EpoxyModelに委任するスパンサイズルックアップオブジェクトを返します。

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

@EpoxyAttributeを使用してヘルパークラスを生成する

EpoxyAttributeアノテーションを使用して、セッター、ゲッター、equals、およびhashcodeを持つモデルのサブクラスを生成することで、モデルクラスのボイラープレートを削減できます。

たとえば、次のようなモデルを設定できます

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"));

セッターは、ビルダー形式で使用できるようにモデルを返します。生成されたクラスには、モデルを[自動差分処理](#diffing)で使用できるように、アノテーション付きのすべての属性のhashCode()実装が含まれています。場合によっては、すべてのバインド呼び出しで再作成されるクリックリスナーなど、特定のフィールドをハッシュコードとequalsに含めたくない場合があります。Epoxyにそのアノテーションをスキップするように指示するには、アノテーションにhash=falseを追加します。

生成されたクラスは常に、末尾にアンダースコアが追加された元のクラスの名前になります。元のクラスが抽象クラスの場合、クラスは生成されません。モデルクラスが、EpoxyAttributesを持つ他のモデルからサブクラス化されている場合、生成されたクラスには、すべてのスーパークラスの属性が含まれます。生成されたクラスは、元のモデルクラスのコンストラクターを複製します。元のモデルクラスに、生成されたセッターと一致するメソッド名がある場合、生成されたメソッドはsuperを呼び出します。

これはEpoxyのオプションの側面であり、使用しないことを選択できますが、モデルのボイラープレートを削減するのに役立ちます。