Android Epoxy 库

用于在 RecyclerView 中构建复杂界面的 Android 库
7,339
作者Eli Hart

Epoxy 是一个用于在 RecyclerView 中构建复杂界面的 Android 库。它抽象了 ViewHolder、项目类型、项目 ID、跨度计数等样板代码,从而简化了构建具有多种视图类型的界面的过程。此外,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` 的类,并将您的适配器实例添加到 `RecyclerView` 中,就像您通常一样。

创建 `EpoxyModels` 并按照您希望显示的顺序将它们添加到适配器中。基础 `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` 模式略有不同,并且允许数据和视图逻辑分离。

模型还允许您控制视图的其他方面,例如跨度大小、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。

其次,每个差异都必须重新计算每个模型的哈希码以确定项目更改。避免在哈希码中包含不必要的计算,因为这会显着减慢差异比较速度。

第三,小心不要无意中更改模型状态,例如使用点击侦听器。例如,通常的做法是在模型上设置点击侦听器,然后在绑定时将其设置在视图上。这里一个简单的错误是使用匿名内部类作为点击侦听器,这会影响模型哈希码,并在更新或重新创建模型时需要重新绑定视图。相反,您可以将侦听器保存为字段以与每个模型一起重用,这样它就不会更改模型的哈希码。另一个常见的错误是在模型的绑定调用期间修改影响哈希码的模型状态。

考虑到这些因素,请避免不必要地调用 `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)`,让您有机会释放与视图关联的任何资源。这是一个清除视图中大型或昂贵数据(例如位图)的好机会。

如果回收器视图使用 `onBindViewHolder(ViewHolder holder, int position, List<Object> payloads)` 提供了一个非空的有效负载列表,则将调用 `EpoxyModel#bind(View, List<Object>)`,以便可以优化模型以根据发生更改的内容重新绑定。如果只有部分视图发生更改,这可以帮助您避免不必要的布局更改。

模型 ID

RecyclerView 的稳定 ID 概念内置于 EpoxyModels 中,并且当启用稳定 ID 时,系统效果最佳。

每次实例化模型时,都会自动为其分配一个唯一的 ID。您可以使用 `id(long)` 方法覆盖此 ID,这对于表示数据库中对象的模型通常很有用,这些模型已经具有与其关联的 ID。

默认 ID 始终为负值,因此不太可能与手动设置的 ID 冲突。当使用具有默认 ID 的模型时,通常将该模型保存为适配器中的字段很有帮助,以便模型和 ID 在适配器的生命周期内是唯一且不变的。这对于标题等更静态的视图很常见,而从服务器加载的动态内容则可能使用手动 ID。

强烈建议使用稳定 ID,但不是必需的。默认情况下,`EpoxyAdapter` 在其构造函数中将 `setHasStableIds` 设置为 true,但如果需要,您可以在子类的构造函数中将其设置为 false。

适配器依赖稳定 ID 来保存视图状态和进行自动差异比较。您必须启用稳定 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(例如在您的 activity 的 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 注解来生成具有 setter、getter、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"));

setter 返回模型,以便它们可以以构建器样式使用。生成的类包含所有已注释属性的 hashCode() 实现,以便模型可用于[自动差异化](#diffing)。有时,您可能不希望将某些字段包含在哈希码和 equals 中,例如每次绑定调用都会重新创建的点击监听器。要告诉 Epoxy 跳过该注解,请在注解中添加 hash=false

生成的类始终是原始类名,并在末尾附加下划线。如果原始类是抽象类,则不会为其生成类。如果模型类是从也具有 EpoxyAttributes 的其他模型继承的,则生成的类将包含所有超类的属性。生成的类将复制原始模型类中的任何构造函数。如果原始模型类有任何与生成的 setter 匹配的MethodName,则生成的 method 将调用 super。

这是 Epoxy 的一个可选方面,您可以选择不使用它,但它有助于减少模型中的样板代码。