Epoxy: Airbnb的安卓视图架构

Epoxy是由Airbnb开发的,用于构建复杂RecyclerView的安卓库。 Epoxy介绍: github.com/airbnb/epoxy

以下是Epoxy: Airbnb’s View Architecture on Android的译文。

Android中的RecyclerView是一个显示列表的强大工具,但是它的用法比较琐碎。显示复杂度高的列表是我们团队的一个常用需求,比如具有多种视图类型,分页功能,支持平板和item动画的列表。我们发现自己总是不断的重复相同的设置。所以开发了Epoxy来减轻这个趋势,以简化基于列表的视图的创建,加载静态或者动态的内容。

Epoxy采用可组合的方式来创建列表。列表中的每个item由一个model代表,model定义了item的布局,id以及span。model还负责处理数据到视图的绑定,在视图被回收的时候释放资源。如果要显示这些model则把它们添加到Epoxy的adapter中,adapter为你处理复杂的显示问题。

使用Epoxy显示搜索结果

让我们看一个如何运作的实际例子。这里是Airbnb应用中显示一个城市街区搜索结果的视图。

1-fhCv2XzLgHvt03c97VNDcA.png

把这个视图拆分我们得到:

  • 描述城市的header

  • 一个指向城市旅行指南的连接

  • 数目不确定的街区视图流

  • 嵌入街区视图流的过滤提示

除此之外有时还有一些其它的视图,比如:

  • 分页的时候结尾部分的加载提示

  • 网络出问题时的错误信息

  • 当滚动完所有结果之后的文字

  • 在某些国家显示的定价声明

总共有8个不同的view类型,我们需要使用一个RecyclerView把它们联系起来这样整个页面就可以滚动,呈现在一个连贯的界面中。

设置一个拥有这么多view类型的RecyclerView adapter通常会非常混乱。我们将会得到一个需要指定view type id, item counts, span counts, view holders, click listeners等设置的复杂类。

但是使用Epoxy的组合方法,我们的adapter可以专注于指定显示哪些item,而显示的细节则代理给了model。

代码大致是这样的:

public class SearchAdapter extends EpoxyAdapter {
    public void bindSearchData(SearchData data) {
      header.setCity(data.city);
      guidebookRow.showIf(data.hasGuideBook());
      for (Neighborhood neighborhood : data.neighborhoods) {
        addModel(new NeighborhoodCarouselModel(neighborhood));
      }
    loader.showIf(data.hasMoreToLoad());
      notifyModelsChanged();
    }
  }

我们的bindSearchData()方法接收一个包含了显示这个view所需的所有信息的对象。当有东西变化的时候将被调用,它将重建 model state 以反应新的搜索数据。最后一行告诉Epoxy计算新model与旧model之间的差别,如有变化,则把确切的变化通知给RecyclerView。

这类似于javascript中React处理用户界面的方式。代码只需描述该显示什么,adapter处理好如何显示的细节。我们无需明确的定义item ids, counts, 或者 view holders这些信息。而且我们也无需负责通知发生了什么变化。

这使得它在activity从不同的数据源(比如数据库,缓存,网络请求)加载数据的时候可以是一个不错的架构。它把这个状态存储在一个对象中,传递给adapter,adapter构建model从而反映出当前的状态。每当状态对象发生变化,不管是因为用户输入还是新加载的的数据导致的变化,新的状态对象被传递给adapter,model被再次更新。可以在model上设置点击事件的listeners,当有什么发生变换的时候通过回调传递给activity。

这种方式明确的分离了职责。随着设计的改变或者新功能的添加,Models可以轻易的替换。组合的方式以及adapter所提供的抽象概念使得复杂度维持在低等水平。

adapter频繁的变化item通常会影响性能。但是,Epoxy增加了diffing算法来检测models中的变化,只更新实际发生了改变的视图。

追踪Adapter Item的变化

普通adapter的另一个难点是追踪item的变化。Item可能被添加,删除,更新或者移动,这些变化必须通知adapter。如果处理得当,这些notify调用可以让RecyclerView只重绘发生了变化的view。但是在一个已经很复杂的adapter中手动处理这个事情还是很困难的。

Epoxy通过在models中使用一种diffing算法帮你解决了这个问题。只要你改变了model的设置,Epoxy就会找到变化然后通知RecyclerView。这简化了你的adapter,提高了性能,还顺便提供了item change动画。

这个diffing算法依赖于每个model实现了hashCode,这样当一个model发生变化的时候就可以被检测到。Epoxy提供了一个注解处理器,这样你的model就可以为那些能代表model状态的成员添加注解。一个生成的subclass可以为你实现正确的hashCode方法,同时为每个field生成getter 和 setter 方法。

继续我们上面的例子,header model大致是这样的:

public class HeaderModel extends EpoxyModel<HeaderView> {
    @EpoxyAttribute City city;
        
    @Override
    public void bind(HeaderView headerView){
        headerView.setImage(city.getImage());
        headerView.setTitle(city.getName());
        headerView.setDescription(city.getDescription());
    }
        
    @LayoutRes
    public int getDefaultLayout() {
        return R.layout.model_header_view;
    }
}

这将生成一个带有`setCity`方法的HeaderModel_类,我们使用这个类(注意不是HeaderModel)的实例来为models列表添加header。这个header视图只有在City对象改变的情况下才会更新。前提是假设City对象也实现了一个正确的hashCode方法来定义自己的状态。

你还会注意到这个model实现了getDefaultLayout() 来返回一个布局资源。这个资源用于inflate传递给modelbind方法的view,bind方法中把数据设置到view上。另外,在adapter中layout(资源id)还被用作这个item的view type id。

Stable IDs By Default

为了让功能正常工作,Epoxy默认启用了RecyclerView的stable id(要了解什么是stable id,参见RecyclerView Adapter的setHasStableIds(boolean hasStableIds)方法)。

这使得diffing,item动画以及状态保存成为可能,每个model负责定义它的id,我们为动态生成的model手动设置id。比如每个neighborhood carousel model用网络请求中的neighborhood对象提供的id设置。

静态视图比如header就要复杂点。它没有一个固有的id与之关联,因此我们需要制作一个。Epoxy为每个新创建的model自动生成一个id。这个id可以保证在app生命周期中不会和其他生成的model id重复,而负id被用来避免和手动设置的id重复。

保存View的状态

Epoxy还添加了对保存视图状态的支持,这是默认的RecyclerView所缺乏的。比如,上面search设计中的carousels是可以横向滑动的,为了更好的用户体验我们想保存这个carousel的滚动位置。如果用户向下滚动之后再回到这里时他们应该看到carousel保持了原来的状态。类似的,如果他们旋转手机或者切换app之后再回来,尽管activity发生了重建,我们还是应该呈现出相同的状态。

如果使用普通的RecyclerView adapter这就难以实现了。Epoxy支持保存任意model的view状态,为了做到这点,它是用了stable ids把view的状态和model id联系起来。

要保存view的状态只需再model中添加如下代码:

@Override
public boolean shouldSaveViewState {
    return true;
}

Epoxy将在它离开屏幕的时候保存自己的状态,并在返回的时候恢复。默认这个设置为false,这样内存和滚动的性能就不会因为保存了不必要的视图状态而受影响。

Epoxy在静态内容中的应用

RecyclerView通常用于显示从远程数据(比如网络或者数据库)加载的动态内容,否则使用scrollview要简单些。但是Epoxy可以让RecyclerView的使用和ScrollView一样简单,我们的详情界面就是这样做的。

1-9_r4T_-xlv9qgtkyB3hJfg.png

这种效果使用ScrollView来实现可能是最简单的。但是我们使用Epoxy配合RecyclerView可以得到更快的加载速度,也更容易实现动画。

性能对我们来说至关重要,这个页面通常在用户搜索的时候展示,用户点击一个搜索结果的图片,然后使用共享元素动画切换到详情页面,为了让搜索体验良好,动画必须流畅,因此details view的加载必须非常快。

让我们仔细看看这个view了解为什么它们会影响性能。首先,最顶上的图片实际是一个横向的RecyclerView,这样用户就能滑动查看房间的图片。在中间我们有一张静态的地图显示房源的位置,而在底部我们还有另一个RecyclerView,显示该地区的类似房源。而在这三个比较大的视图中间还穿插着一些文字信息和小图片。

这些加在一起就得到了一个带有很多位图的非常复杂的结构。这使得测量和布局的过程要花更长的时间,同时还需要更多的内存来加载图片。

另外,我们还从不同的渠道加载数据-databases, in-memory caches, 以及多个网络请求。这对为用户显示即时数据有好处,但是如果处理不好也会增加更多的时间开销。

庞大的视图结构,多个bitmap,多个view刷新,这些要求使得我们有足够的理由去关注性能问题。多亏了Epoxy我们可以在兼顾这些考虑的情况下也能提供很棒的用户体验。这是因为:

  • 因为我们使用的是RecyclerView,当用户首次打开这个屏幕的时候只有一小部分视图被加载。避免了过早的加载map图片,底部的画廊以及它们之间的所有视图。这就使得布局更快,内存使用更小,过度动画更流畅。

  • 当新数据被加载的时候我们无需反复的刷新view,减小了丢帧的概率。如果遇到类似的列表请求,而那个carousel不在屏幕上,我们什么夜不用做。如果价格发生了变换,Epoxy只是更新价格标签。这增加了进入动画的流畅度,同时防止用户滚动的时候丢帧。

  • 自带Item change动画。当数据发生变化的时候我们可以以相应的动画显示,隐藏或者更新view。比如,点击翻译按钮可以插入一个加载器,当加载完成再过渡到翻译后的text,这避免了突兀的变化。

Epoxy的未来

我们非常高兴将Epoxy作为开源库分享出来,欢迎感兴趣的开发者贡献代码帮助我们改进。我们积极的开发Epoxy以改进注解处理器,diffing算法以及通用工具。希望其它开发者能找到这个库的新用法,帮助我们把它做成一个更好的工具。

可以在 airbnb.io上查看我们所有的开源项目。Twitter:@AirbnbEng + @AirbnbData