[译]Epoxy:Airbnb的安卓视图架构

原文:link 作者:Eli Hart
项目:github.com/airbnb/epoxy

安卓的RecyclerView是一个显示列表的强大工具,但是它的使用涉及混杂的样式和设置。我们团队有个显示多样式的复杂列表的需求,要有分页、支持平板电脑显示已经item动画。我们发现一直重复同样的模板配置工作,于是我们开发了Epoxy来减缓这种趋势,并且简化静态、动态加载内容的列表类视图。

Epoxy以组件的方式来创建列表,列表中的每个item通过model来定义绘制所需的layout,id和span(间距).model还负责绑定数据到它的view上,并且在view回收时释放资源。这些model按你希望显示的顺序插入到Epoxy的adapter中,adapter来负责复杂的显示工作。

用Epoxy显示搜索结果

我们来通过一个例子来看一下它是如何工作的。这是一个Airbnb中显示城市中邻居的搜索结果页。

我们可以将这个视图拆分为:

  • 一个城市介绍的标题栏
  • 一个城市向导的链接
  • 一个不定数量的城市邻居轮播图
  • 一个某些国家会有的价格免责声明

这给了我们8(4?)种不同类型的视图,我们需要用RecyclerView来讲他们组合在一起,从而使整个页面可以完整显示并滚动。

为这样一个包含多样式的页面设置RecyclerView的adapter十分操蛋。我们将会有好多乱七八糟的东西:视图类型id,item数量,间距值,view holder,点击事件处理等等。

使用Epoxy的组件方式可以将注意力集中于item需要显示什么,显示适配工作将交由models代理完成。

大概这个样:

1
2
3
4
5
6
7
8
9
10
11
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方法接受一个object类型对象,其中包含了view所需的所有信息。它是幂等的(相同参数多次执行结果相同),当条件发生变化它将重新创建model state来反映新的搜索结果。最后一行,我们告诉Epoxy去执行diff操作,当数据发生变化时将通知到RecyclerView上。

这和React的组件UI非常类似。代码只需描述什么需要被显示,adapter来负责具体的显示工作。我们不需要挨个定义ids,counts,holders等。另外,我们也不需要去处理变化通知的工作。

这使得其成为一个加载多数据源(数据库,缓存,网络请求)的非常优秀的框架。它在adapter中记录一个状态对象,adapter创建models来反映当前状态。当状态对象发生变化时,不论是用户输入或者新数据加载导致的,新的状态将传递给adapter,然后models会再次更新。点击事件可以在model中设置,来回调到Activity。

这种方式职责分离很清晰。models可以根据需求很简单的引入或剔除。通过组件以及adapter的抽象封装。

通常频繁的item变化会十分影响效率,然而,Epoxy加入了一个相当牛逼的算法来检测models的变化,只有在models真正变化时才更新UI(需要确认和google原生DiffUtil的关系)

检测adapter item的变化

一般的adapter中另一个复杂点是跟踪item的变化。item有可能被添加、删除、更新、移动,adapter需要根据这些不同类型的通知去执行相应的处理。这些通知使得RecyclerView可以只重绘变化的views,以及执行相应的动画效果。然而,在已经非常复杂的adapter中手动处理这些问题会十分困难。

Epoxy在models上使用了比对算法来帮你解决这类问题。任何时候当你改变model设置,Epoxy将找出这些变化并反映到RecyclerView上。这将简化你的adapter代码,很容易的添加item变化的动画效果,并且通过只改变需要改变的views来提高性能。

比对算法根据每个model实现的哈希值,所以可以侦测到model的改变,Epoxy提供了注解的方式,所以你的model可以很简单的通过注解来标注需要被考虑进model状态变化的字段。自动生成的子类会帮你实现hashcode计算函数以及各字段的getter和setter。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}
}

一个HeaderModel_类会被自动生成,并且包含setCity方法,我们就用这个实例来向models列表中添加一个头部。头部视图只会在city对象变化时刷新。这里假设City对象也有一个hashCode的实现来定义它的状态变化。

你可能注意到了,getDefaultLayout()方法返回了一个layout资源。这个就是用来绑定数据的视图资源,同时这个值也被用作adapter中的视图类型。

稳定的默认IDs

为了在Epoxy中正确实现RecyclerView的默认ids,并由此来实现比对方法,item动画以及状态保持。每一个model都需要定义它的id,我们手动在动态生成的model里设置了一个id。例如,每一个邻居轮播图model的id与网络请求返回的邻居对象相关联。

静态视图,例如我们的头部视图,没有与之关联的id,所以我们得造一个出来。Epoxy会为每一个新创建的model自动生成一个id,并且这个id在app生命周期中确保唯一,负值的id用来避免和手动设置的id相冲突。

唯一需要注意的是,我们必须在adapter生命周期中使用同一个model。对于头部视图(或其他静态视图)这就代表我们定义了一个final类型的字段,并在内联方法中将其初始化。然后将其加入model列表并且像通常一样去更新。我们不必再去做更多工作来保证其唯一。

保存视图状态

Epoxy也加入了对视图列表的状态支持,RecyclerView默认并不支持。例如,上图中的轮播图可以左右滑动,为了更好的用户体验,我们希望保存其滑动位置。当用户往下滑动然后再返回时,轮播图应该停在之前相同的位置,同样当用户旋转屏幕或者切换到其他app再切换回来,尽管Activity已经重新创建,我们也应该展现相同的页面状态。

在一般的RecyclerView adapter中实现要费了老劲了,然而,Epoxy直接就支持,啥model都能保存状态。它是通过leveraging stable ids将model id与视图的序列化状态相关联来实现的。

使用也很简单:

@Override
public boolean shouldSaveViewState {
    return true;
}

默认状态为false,来减少对性能的影响。

Epoxy关于静态内容的使用

RecyclerView经常被用来显示远程加载的动态内容。其他的内容一般用Scrollview来实现,使用Epoxy可以在不用做过多工作的情况下使用RecyclerView来替换Scrollview。下面的列表就是这么用的:

使用ScrollView实现最为简单,但是使用Epoxy的RecyclerView可以有更快的加载速度,添加动画效果也更简单。

这个页面的性能问题非常重要,因为用户点击搜索结果时就会跳转到这个页面,让这个过渡动画效果平滑是提升用户搜索体验的重要因素,同时还需要详情页加载非常快才行。

让我们来看一下详情页上的元素是如何影响性能的。首先,头部的照片是一个横向的RecyclerView,中间有一个静态地图显示地理位置,底部还有一个横向RecyclerView显示附近类似房子,中间还有一些描述性文字和小图片。

整体上构成了一个十分复杂的视图结构,包含了很多bitmaps。这使得测量和展现时间变得很长,并且需要更多的内存去加载图片。

另外,我们通过多种渠道加载数据——数据库,内存缓存,多个网络请求——来支撑这个页面,这对向用户显示即时信息很有帮助,但如果处理不好则需要花费额外的时间去更新视图。

综上所述,我们必须要关心效率问题。谢天谢地有了Epoxy!让我们有了极致的用户体验!!(妈了个鸡。。。我为啥要翻译这个。。。)

  • 因为使用了RecyclerView,当用户首次加载时,只有一小部分视图需要加载。这避免了过早加载地图视图和底部的轮播图,以及中间的那些玩意儿。于是有了更快的加载速度,更小的内存使用,以及最为关键的梗平滑的过渡效果。
  • 防止了上下滑动时视图结构失效引起的帧率下降,假如有数据返回但对应的view不在屏幕上,我们都不用管。如果时间字段变化导致价格需要更新,Epoxy会自动更新价格。
  • item变化动画效果。数据变化时我们可以用平滑的动画效果来隐藏、显示或更新视图。例如,点击翻译会插入一个loader动画,当翻译完成时会自动变为翻译后的结果,避免了默认的不自然的变化效果。

Epoxy的未来

开源了, 欢迎贡献~我们会持续改进注解处理,对比算法,以及工具类。欢迎引入其他牛逼的库。

Check out all of our open source projects over at airbnb.io and follow us on Twitter: @AirbnbEng + @AirbnbData