Graywater: an android library for performant lists
Graywater is a RecyclerView
adapter that facilitates the performant decomposition of complex and varied list items. It does this by mapping large data models to multiple viewholders, splitting the work needed to create a complex list item over multiple frames.
The concept is based off of Facebook's post on a faster news feed and Components for Android, which have been realized as Litho.
Tumblr developed Graywater to improve scroll performance, reduce memory usage, and lay the foundation for a more modular codebase.
The name "Graywater" comes from the process of recycling water.
What is it?
An adapter basically takes a list of models (of type T
) and maps them to a list of viewholders (of type VH extends RecyclerView.ViewHolder
).
One naive solution is to map models directly to viewholders. For example, a list of "posts" can have a viewholder for each post. But this architecture quickly becomes slow and unwieldy if there is either a large variety of posts or if individual posts are complex.
So to improve performance, the parts of a post that are offscreen can be recycled.
model views
+---------+ +------+
| | | head | <------- does not exist
| | +------+ <------------+
| item #1 | | body | <---+ |
| | +------+ | |
| | | foot | | |
+---------+ +------+ | |
screen view hierarchy
+---------+ +------+ | |
| | | head | | |
| | +------+ | |
| item #2 | | body | <---+ |
| | +------+ <------------+
| | | body | <------- does not exist
+---------+ +------+
Due to Tumblr's needs, there are additional features that help improve performance and reduce memory usage:
- Viewholders are shared between models of the same and different types, e.g. a body viewholder can be shared between a item #1 and item #2.
- Models can have multiple viewholders of the same type, e.g. an item can have an unlimited number of body viewholders.
This results in a minimal number of viewholders to maximize cache effectiveness and reduce memory pressure.
In order to accomplish this, we introduce the concept of a Binder, which takes a model (T
) and binds it to a viewholder (VH
).
+-------+ +--------+ +------------+
| Model | --> | Binder | --> | ViewHolder |
+-------+ +--------+ +------------+
We no longer desire the one-to-one relationship between models and viewholders that, because monolithic models result in monolithic viewholders. For example, a video post (VideoPost
) used to have a corresponding VideoPostViewHolder
. Instead, we want VideoPost
to be composed of a header, body, and footer.
+--------+ +------------+
/--> | Binder | --> | ViewHolder |
+-------+ +---+ / +--------+ +------------+
| Model | --> | ? | *----> | Binder | --> | ViewHolder |
+-------+ +---+ \ +--------+ +------------+
\--> | Binder | --> | ViewHolder |
+--------+ +------------+
To manage this relationship, we introduce the concept of an ItemBinder, which aggregates the binders needed to display a post. It takes a model (T
) and returns a list of binders, each of which bind the model to a specific viewholder.
+------------+
/-----> | ItemBinder |
/ +------------+
/ v
/ +--------+ +------------+
/ /----> | Binder | --> | ViewHolder |
+-------+ / +--------+ +------------+
| Model | *------> | Binder | --> | ViewHolder |
+-------+ \ +--------+ +------------+
\----> | Binder | --> | ViewHolder |
+--------+ +------------+
ItemBinder<? extends T, ? extends VH>
takes a modelT
and maps it to a list of binders of typeBinder<T, ? extends VH>
.Binder<? super T, ? extends VH>
takes a model of typeT
and maps it to aViewHolder
of typeVH
A minor design point is that RecyclerView.Adapter#onCreate()
creates the viewholders, so some sort of mechanism for creating viewholders is necessary. This is where ViewHolderCreator comes in - it is a model-independent way of creating viewholders (in other libraries with a one-to-one relationship between models and viewholders, this code would live in the model - e.g. Epoxy).
+------------+
/-----> | ItemBinder |
/ +------------+
/ v
/ +--------+ +------------+ +-------------------+
/ /----> | Binder | --> | ViewHolder | <-- | ViewHolderCreator |
+-------+ / +--------+ +------------+ +-------------------+
| Model | *------> | Binder | --> | ViewHolder | <-- | ViewHolderCreator |
+-------+ \ +--------+ +------------+ +-------------------+
\----> | Binder | --> | ViewHolder | <-- | ViewHolderCreator |
+--------+ +------------+ +-------------------+
Dependency Injection with Dagger 2 Map Multibindings
For Graywater to know about the ItemBinders and ViewHolderCreators, each of them needs to be registered when the adapter is created. When there are a substantial number of both, there can be a significant impact on the time it takes to initialize the adapter.
One solution is to use Dagger 2 map multibindings. This allows you to use the full power of dependency injection to control which binders a given screen will support, as well as the ability to inject different versions of the same binder on different screens to facilitate screen-dependent behavior.
+------------+ +----------------+
/-----> | ItemBinder | <-------------------- | Dagger 2 Maps |
/ +------------+ +----------------+
/ v v
/ +--------+ +------------+ +-------------------+
/ /----> | Binder | --> | ViewHolder | <-- | ViewHolderCreator |
+-------+ / +--------+ +------------+ +-------------------+
| Model | *------> | Binder | --> | ViewHolder | <-- | ViewHolderCreator |
+-------+ \ +--------+ +------------+ +-------------------+
\----> | Binder | --> | ViewHolder | <-- | ViewHolderCreator |
+--------+ +------------+ +-------------------+
But using Dagger 2 by itself does not improve startup time, because the maps are created at injection time, which requires all the binders to also be created. This can be somewhat alleviated with Lazy<Map>
, but another benefit of Dagger 2 is the automatic support for Map<K, Provider<V>>
. When applied to ItemBinders
, this allows each ItemBinder
to be constructed on demand.
Note that Graywater does not have built-in support for Dagger 2.
Lazy Loading Binders
Normally, when an item is added to the adapter, the corresponding ItemBinder
is loaded as well as all the necessary Binder
classes.
Binders ItemBinders Items Screen
+--------+ +------------+ +-----------+ +--------+
| Photo | -------- | | /- | TextPost | | Header |
+--------+ /---- | Photo Post | -\ / +-----------+ +--------+
| Footer | --x /--- | | \----- | PhotoPost | | |
+--------+ x +------------+ / +-----------+ | |
| Header | --x \--- | | --/ /- | TextPost | | Text |
+--------+ \---- | Text Post | / +-----------+ | |
| Text | -------- | | ----/ | |
+--------+ +------------+ +--------+
But on-screen, only the first item is visible, and out of the first item, only two components are visible. So in the above example, there is no need to load the "Footer" binder. This is what List<Provider<Binder>>
facilitates.
Binders ItemBinders Items Screen
+--------+ +------------+ +-----------+ +--------+
| Photo | | | /-- | TextPost | -x--- | Header |
+--------+ | Photo Post | / +-----------+ \ +--------+
| Footer | | | / | PhotoPost | \- | |
+--------+ +------------+ / +-----------+ | |
| Header | ---\ | | -/ | TextPost | | Text |
+--------+ \--- | Text Post | +-----------+ | |
| Text | -------- | | | |
+--------+ +------------+ +--------+
This is very useful for improving initialization performance when loading long cached lists by deferring binder creation until the binder is nearly on screen.
How do you use it?
Graywater relies heavily on generics for type safety - here are the major type parameters:
T
is the base model type.VH
is the base viewholder type.MT
is the type of the model type (e.g.Class<?>
).
Although this may seem overly generic, it is convenient if your base model or viewholder type has methods you need to access.
Add a model that subclasses T
.
class Text {
String text;
}
Create the viewholder(s).
class TextViewHolder extends RecyclerView.ViewHolder {
TextView textView;
public TextViewHolder(View view) {
super(view);
textView = (TextView) view.findViewById(R.id.text);
}
}
Create the corresponding ViewHolderCreator
implementations.
class TextViewHolderCreator implements GraywaterAdapter.ViewHolderCreator {
public TextViewHolder create(final ViewGroup parent) {
return new TextViewHolder(GraywaterAdapter.inflate(parent, R.layout.item_text));
}
public int getViewType() {
return R.layout.item_text;
}
}
Create the Binder<T, ? extends VH>
implementations for each ViewHolder
.
class TextBinder implements GraywaterAdapter.Binder<Text, TextViewHolder> {
public Class<TextViewHolder> getViewHolderType() {
return TextViewHolder.class;
}
public void prepare(final Text model,
final List<GraywaterAdapter.Binder<? super Text, ? extends TextViewHolder>> binders,
final int binderIndex) {
}
public void bind(final Text model,
final TextViewHolder holder,
final List<GraywaterAdapter.Binder<? super Text, ? extends TextViewHolder>> binders,
final int binderIndex,
final GraywaterAdapter.ActionListener<Text, TextViewHolder> actionListener) {
holder.textView.setText(model.text);
}
public void unbind(final TextViewHolder holder) {
holder.textView.setText(null);
}
}
Create the ItemBinder
that returns the list of binders for the model.
class TextItemBinder implements GraywaterAdapter.ItemBinder<Text, RecyclerView.ViewHolder> {
TextBinder textBinder;
public TextItemBinder(TextBinder textBinder) {
this.textBinder = textBinder;
}
public List<GraywaterAdapter.Binder<? super Text, ? extends RecyclerView.ViewHolder>> getBinderList(
final Text model,
final int position) {
return new ArrayList<GraywaterAdapter.Binder<? super Text, ? extends RecyclerView.ViewHolder>>() {{
add(textBinder);
add(textBinder);
}};
}
}
Lastly, subclass GraywaterAdapter
and register the created classes!
private static class TextAdapter extends GraywaterAdapter<Text, RecyclerView.ViewHolder, Class<?>> {
public TextAdapter() {
register(new TextViewHolderCreator(), TextViewHolder.class);
final TextBinder textBinder = new TextBinder();
register(String.class, new TextItemBinder(textBinder), null);
}
@Override
protected Class<?> getModelType(final Text model) {
return model.getClass();
}
}
You can then add items using GraywaterAdapter.add()
or remove them with GraywaterAdapter.remove()
. Note that getItemCount()
will return the number of viewholders, not the number of model objects in your list. getModelType(MT)
will generally have the example implementation, but it may be useful to have a custom implementation if subtypes have different definitions across types, or if you need a "default" type.
In example code, an adapter is created that repeats each item in the list once.
How does it work?
At its core, Graywater maps models to viewholders, which basically means it is a just a dictionary. These are the fields used in a dictionary-like way:
List<T> mItems
- the list of items (or a map of position to item)Map<Class<? extends VH>, ViewHolderCreator> mViewHolderCreatorMap
- the map of viewholder class toViewHolderCreator
.Map<MT, ItemBinder<? extends T, ? extends VH>> mItemBinderMap
- the map ofMT
(model type) toItemBinder
Map<MT, ActionListener<? extends T, ? extends VH>> mActionListenerMap
- the map ofMT
(model type) toActionListener
So in add()
, the new model is added to the list of items. In register()
, the parameters are added to the respective map.
A simple optimization is to cache the ItemBinders. This is done by binderListCache
, which is of type List<List<Binder<? super T, ? extends VH>>>
. Every time add()
is called, getBinderList()
is called and the return value is added to the cache.
What is MT
?
protected abstract MT getModelType(T model);
Instead of automatically using the class of the model as the model's type, it can be anything (preferably a similar property of the model).
Note that RecyclerView.Adapter
has these methods:
abstract class Adapter<VH extends ViewHolder> {
abstract VH onCreateViewHolder(ViewGroup parent, int viewType);
abstract void onBindViewHolder(VH holder, int position);
int getItemViewType(int position);
abstract int getItemCount();
}
It is important to note that position
in the above methods is the viewholder position, not the model position. This distinction is extremely important, because when we are given the viewholder position when we need the model position.
For now, we assume that viewType
has a one-to-one correspondence to the viewholder class.
Here is a visualization of the model and viewholder positions:
model viewholder
position position
+-----+ +-----+
| | | 0 |
| | +-----+
| | | 1 |
| 0 | +-----+
| | | 2 |
| | +-----+
| | | 3 |
+-----+ +-----+
+-----+ +-----+
| | | 4 |
| 1 | +-----+
| | | 5 |
+-----+ +-----+
If we are given a viewholder position of 5
, we need to arrive at the model position of 1
, that way we can grab the model from the backing data store.
The way to do this is to iterate through the models, going to the corresponding ItemBinder
and accumulating the size of the list that is returned. Unfortunately, this is slow.
But in order to make it fast, we need to cache a lot of intermediary state.
On add()
, we compute these two caches, viewHolderToItemPosition
and itemPositionToFirstViewHolderPosition
. Note that the code uses item to refer to the model position.
model viewholder viewHolderToItemPos itemPosToFirstViewHolderPos
position position
+-----+ +-----+
| | | 0 | { 0, 0 }
| | +-----+
| | | 1 | { 1, 0 }
| 0 | +-----+ { 0, 0 }
| | | 2 | { 2, 0 }
| | +-----+
| | | 3 | { 3, 0 }
+-----+ +-----+
+-----+ +-----+
| | | 4 | { 4, 1 }
| 1 | +-----+ { 1, 4 }
| | | 5 | { 5, 1 }
+-----+ +-----+
viewHolderToItemPositionCache
is also used for getItemCount()
.
itemPositionToFirstViewHolderPosition
is primarily used for one purpose: to determine the position of the viewholder and associated binder in the list of viewholders for a given model. In the above example, the viewholder at position 5
is the 2nd viewholder for the 2nd model. This is important when there is more than one instance of a viewholder for a model, such as reblog comments.
getItemViewType()
works by tracking the registered ViewHolderCreators
, which have this interface:
interface ViewHolderCreator {
RecyclerView.ViewHolder create(ViewGroup parent);
int getViewType();
}
When a new ViewHolderCreator
is registered, it is added to viewHolderCreatorList
, which is of type SparseArray<Class<? extends VH>>
, and associates the viewType
with the correct class. The class is then associated with the ViewHolderCreator
via viewHolderCreatorMap
, which is of type Map<Class<? extends VH>, ViewHolderCreator>
.
viewHolderCreatorList viewHolderCreatorMap
+------------------------------+ +---------------------------------------+
| viewtype -> ViewHolder.class | | ViewHolder.class -> ViewHolderCreator |
+==============================+ +=======================================+
| 8324 -> Header.class | | Header.class -> HeaderCreator |
+------------------------------+ +---------------------------------------+
| 9802 -> Body.class | | Body.class -> BodyCreator |
+------------------------------+ +---------------------------------------+
| 2383 -> Footer.class | | Footer.class -> FooterCreator |
+------------------------------+ +---------------------------------------+
To implement getItemViewType()
So when given a viewholder position,
viewHolderToItemPos
is used to retrieve the model position.binderListCache
and the model position is used to get the list of binders.itemPosToFirstViewHolderPos
is used to retrieve the position of the first viewholder.- The binder position in the list of binders is computed.
- The correct binder for the viewholder position is retrieved.
viewHolderCreatorMap
is passed theBinder.getViewHolderType()
to get theViewHolderCreator
.ViewHolderCreator.getViewType()
is called to get theviewType
.
onCreateViewHolder(ViewGroup parent, int viewType)
is quite a bit simpler:
return (VH) viewHolderCreatorMap.get(getViewHolderClass(viewType)).create(parent);
viewHolderCreatorList
is used to get the class from theviewType
viewHolderCreatorMap
is used to get theViewHolderCreator
- The
ViewHolderCreator
creates the new viewholder.
onBindViewHolder(VH holder, int viewHolderPosition)
is implemented by following the first 5 steps of getItemViewType()
, and then calling Binder.bind()
with the model and the viewholder.
Other features
In bind()
, the adapter looks ahead to the next numViewHoldersToPrepare()
viewholders (default 3) and calls Binder.prepare()
on them. Note that it does not call prepare()
more than once, unless unbind()
is called. This state is stored in viewHolderPreparedCache
which stores the indices of viewholders that have been prepared.
This also works in both directions of the RecyclerView
. It checks the order of bind()
operations to determine which direction to prepare viewholders in.
ActionListener
is a bit of an experimental feature to avoid creating extra ClickListener
objects on every bind()
call.
An addendum on binders and generics
ItemBinder#getBinderList()
has a somewhat complex return type:
List<Binder<? super T, ? extends VH>> getBinderList(@NonNull T model, int position)
In particular, Binder<? super T, ? extends VH>
is quite confusing.
When you write your binder, try to parameterize the binder with the least-restrictive model it can take and the most restrictive viewholder it can bind to.
If these were your type hierarchies:
Model Type ViewHolder Type
Hierarchy Hierarchy
A 1
/ \ <- Binder -> / \
B C 2 3
- A binder should be written to take
A
if possible,B
orC
if necessary. - A binder can only be written to take
2
or3
(note that1
should never be registered, because then it is ambiguous).
This can be illustrated with an example
Post BaseViewHolder
/ \ <- Binder -> / \
Text Photo Header Body
If every post has a header, it makes sense to have
HeaderBinder extends Binder<Post, Header>
PhotoBinder extends Binder<Photo, Body>
TextBinder extends Binder<Text, Body>
That way HeaderBinder
can take a Text
or a Photo
, while PhotoBinder
won't ever take a Text
.
So what does the ItemBinder<T, VH>
look like?
TextItemBinder extends ItemBinder<Text, BaseViewHolder>
HeaderBinder
TextBinder
PhotoItemBinder extends ItemBinder<Photo, BaseViewHolder>
HeaderBinder
PhotoBinder
You can see that HeaderBinder
binds Post
, which is a superclass of Text
, while TextBinder
binds to Body
, which is a subclass of BaseViewHolder
.
When registering:
register(Text.class, textItemBinder)
register(Photo.class, photoItemBInder)
assuming the class is the model type.
The adapter should be of type GraywaterAdapter<Post, BaseViewHolder>
, the superclasses for all the types used.
Contact
License
Copyright 2017 Tumblr, Inc.
Licensed under the Apache License, Version 2.0 (the โLicenseโ); you may not use this file except in compliance with the License. You may obtain a copy of the License at apache.org/licenses/LICENSE-2.0.
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an โAS ISโ BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.