1. 程式人生 > >Epoxy: Airbnb’s View Architecture on Android

Epoxy: Airbnb’s View Architecture on Android

Android’s RecyclerView is a powerful tool for displaying lists of items, but its usage is cluttered with boilerplate and configuration. A common requirement for our team is to display lists with complexities such as multiple view types, pagination, tablet support, and item animations. We found ourselves duplicating the same configuration patterns over and over. We developed

Epoxy to mitigate this trend, and to simplify the creation of list based views for both static and dynamically-loaded content.

Epoxy takes a composable approach to building lists. Each item in the list is represented by a model that defines the item’s layout, id, and span. The model also handles binding data to the item’s view, and releasing the view’s resources when it is recycled. These models are added to Epoxy’s adapter in the order you want them displayed, and the adapter handles the intricacies of displaying them for you.

Displaying Search Results With Epoxy

Let’s look at a practical example of how this works. Here is a view of the Airbnb app showing search results for neighborhoods in a city.

Breaking this view down we have:

  • A header describing the city
  • A link to the city’s guidebook
  • An undetermined number of neighborhood carousels
  • A filter suggestion interstitial mixed with the carousels

Beyond that there are a few other views that are sometimes shown such as:

  • A loading indicator at the end of results when paginating
  • An error message if there was a network issue
  • A text row when you’ve scrolled through all results.
  • A pricing disclaimer shown in some countries

This gives us eight unique view types, and we need to use a RecyclerView to combine them all so that the whole page is scrollable and presented in a consistent interface.

Setting up a RecyclerView adapter with this many view types would normally be messy. We would have a complicated class specifying view type ids, item counts, span counts, view holders, click listeners, and on and on.

With Epoxy’s compositional approach our adapter can instead focus on specifying what items to show, and the details of displaying them are delegated to the models.

This roughly looks like the following:

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();
}
}

Our bindSearchData() method accepts an object that contains all of the information we need to build the view. It is idempotent, called whenever something may have changed, and it rebuilds the model state to reflect the new search data. In the last line we tell Epoxy to compute a diff between the new models and the old models, and it notifies the RecyclerView of the exact changes, if any.

This is similar to how React approaches user interfaces in javascript. The code only has to describe what should be shown, and the adapter takes care of the details of how to display it. We don’t need to explicitly define any information such as item ids, counts, or view holders. Furthermore, we are freed of all responsibility of notifying what changed.

This lends itself to a nice architecture where an activity loads data from various sources such as databases, caches, or network requests. It stores this state in an object that is passed to the adapter, and the adapter builds its models to reflect the current state. Whenever the state object is changed, whether due to user input or newly loaded data, the new state is passed to the adapter and the models are updated again. Click listeners can be set on models to call back to the activity whenever something changed.

This approach separates responsibilities cleanly. Models can easily be swapped in or out as designs change or as new features are added. Complexity is kept low because of the compositional approach and the abstraction provided by the adapter.

Normally performance might suffer due to frequent adapter item changes. However, Epoxy adds a diffing algorithm to detect changes in models and only update views that actually changed.

Tracking Adapter Item Changes

An additional complexity of a normal adapter is tracking item changes. Items may be added, removed, updated or moved, and the adapter must be notified of each of these changes to function properly. Done correctly, these notify calls allow the RecyclerView to only redraw the views that changed, as well as animate the changes. However, managing this manually in an already complex adapter can be difficult.

Epoxy solves this problem for you by using a diffing algorithm on your models. Any time you change your model setup Epoxy finds the differences and reports the change set to the RecyclerView. This simplifies your adapter code, provides item change animations for free, and improves performance by only rebinding views when necessary.

This diffing algorithm relies on each model implementing hashCode so it can detect when a model has changed. Epoxy provides an annotation processor so your model can simply annotate the fields that should be considered in the model’s state. A generated subclass implements the proper hashCode method for you, as well as getter and setter methods for each field.

Continuing with our example from above, our header model would look something like this:

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;
}
}

A HeaderModel_ class is generated with the `setCity` method we need, and we use an instance of that class when adding a header to our models list. The header view is then only updated when the City object changes. This assumes that the City object also implements a proper hashCode method to define its state.

You’ll also notice that the model implements getDefaultLayout() to return a layout resource. This resource is used to inflate the view that is passed to the model’s bind method, where the data is actually set on the view. Additionally, the layout is used as the item’s view type id in the adapter.

Stable IDs By Default

In order to function correctly Epoxy enables stable ids in the RecyclerView by default. This makes diffing possible, as well as enables item animations and saved state. Each model is responsible for defining its id, and we manually set an id on dynamically generated models. For example, each neighborhood carousel model is assigned the id given by the neighborhood object in the network request.

Static views like our header are trickier. There is no id inherently associated with it, so we have to make one up. Epoxy eases this by automatically generating an id for every new model created. The id is guaranteed to be unique among all other generated model ids for the life of the app process, and negative ids are used to avoid collision with the ids you may set manually.

The only catch here is we must use the same model for the life of the adapter so that the id stays constant. For our header model (and other static views) this means we declare it as a field, make it final, and initialize it inline. Then we add it to the models list as needed and update its data as normal. The id is then unique with no extra work from us.

Saving View State

Epoxy also adds support for saving the state of views in a list, something which RecyclerView lacks by default. For example, the carousels in our search design above can be swiped through horizontally, and for a good user experience we want to save this carousel scroll position. If the user scrolls down through the results and then back up they should see a carousel in the same state they left it. Similarly, if they rotate their phone or switch apps and come back we should present the same state despite the activity being recreated.

With a normal RecyclerView adapter this would take considerable overhead to achieve. Epoxy, however, has generic support for saving the view state of any model. It does this by leveraging stable ids to associate the view’s parceled state with the model id.

To use it, simply add the following:

@Override
public boolean shouldSaveViewState {
return true;
}

To your model and Epoxy will save its state when it is goes off screen and restore when it comes back. By default this is set to false so that memory and scrolling performance aren’t affected by saving the state of unnecessary views.

Using Epoxy For Static Content

Recycler views are often only used when showing dynamic content that is loaded from a remote source like a network request or database. Otherwise it is simpler to specify a scroll view with your components in XML. With Epoxy though, you can have the benefits of the RecyclerView without much more work than a ScrollView. We do this with our listing details page pictured below.

It would be simplest to build this with a ScrollView. However, we used a RecyclerView with Epoxy to give us faster page load times as well as easy animations.

This performance edge is crucial to us since this page is loaded often as users explore search results. Clicking a search result animates the listing image in a shared element transition to this details page. Making this animation smooth is important to an enjoyable search experience, but it requires us to have a listing details view that loads very quickly.

Let’s look at the views on this screen in detail and how they affect performance. First, the photo at the top is actually a horizontal RecyclerView so people can swipe through the listing’s images. In the middle we have a static map view showing where the listing is, and at the bottom we have another RecyclerView showing similar listings in the area. Interspersed between those are quite a few text rows and smaller images that describe the listing.

Altogether this gives us a fairly complicated view hierarchy with many bitmaps. This makes measure and layout passes take longer, and also requires more memory to load the images.

Additionally, we load data from a variety of sources — databases, in-memory caches, and multiple network requests — to power this page. This is great for showing the user immediate data, but results in extra time spent updating views if not handled properly.

Between the large view hierarchy, numerous bitmaps, and multiple view refreshes we have good reason to be worried about performance. Thankfully Epoxy lets us deliver a great user experience despite those concerns for three reasons:

  • Because we use a RecyclerView, only a small portion of the view hierarchy is loaded when the user first opens this screen. This avoids prematurely loading the map bitmap, the bottom carousel, and all the views in between. This makes for faster layout times, less memory usage, and most importantly a smoother transition.
  • As more data is loaded we don’t need to repeatedly invalidate the view hierarchy, reducing the chance of dropping frames. If the similar listings request comes back and that carousel isn’t on screen we don’t need to do anything. If a date change results in a new total price all Epoxy does is update the price text field. This adds to the smoothness of the entry transition and prevents frame drops as the user scrolls.
  • Item changes are animated for free. As data changes we can hide, show, or update views accordingly with smooth animations. For example, clicking a button to translate reviews nicely inserts a loader, which then transitions to the translated text when available. This avoids the jarring nature of instant view changes that would otherwise happen by default.

The Future of Epoxy

We’re happy to now share Epoxy as an open source library, and welcome contributions from developers interested in improving it with us. We are actively developing Epoxy to improve its annotation processor, diffing algorithm, and general utility. We hope other developers can find new uses for the library, and help us evolve it into an even better tool.