-
Notifications
You must be signed in to change notification settings - Fork 728
Epoxy Controller
- Philosophy
- Usage
- Async
- Listening for Model Changes
- Validations
- AutoModels
- Usage With Kotlin
- Typed Controllers
- Debug Logs
- Filtering Duplicates
- Interceptors
- Swallowed Exceptions
The EpoxyController encourages usage similar to the popular Model-View-ViewModel and Model-View-Intent patterns.
Data flows in one direction: from your state, to the EpoxyModels, to the views on the RecyclerView.
User input to the views triggers callbacks that may update your state and restart the cycle.
Epoxy influences the interface between your state and the EpoxyModels, and the EpoxyModels and the views. How you manage your state and view callbacks is left up to you.
The EpoxyModel is similar to a ViewModel. It is an immutable class that is the interface between data and a view. It formats the data and updates the view accordingly. The view cannot modify an EpoxyModel, and must instead call back to change your state so that a new EpoxyModel can be created.
This is different from the EpoxyAdapter, where EpoxyModels are mutable and can be updated and reused. That pattern can be unpredictable and error prone, and is not allowed in the EpoxyController.
Subclass EpoxyController and implement the buildModels
method. The goal of this method is to build the EpoxyModels that represent the state of your data at the time the method is called. The resulting models are immutable, and are used by Epoxy to create and bind views in the RecyclerView. When buildModels
is called again Epoxy diffs the newest models against the previous ones to determine what updates need to be made to the RecyclerView.
Your buildModels
implementation should create new instances of EpoxyModels (or use AutoModels), set the appropriate data on them, and then add them in the order they should be displayed in the RecyclerView. They can be added either by calling EpoxyController#add(EpoxyModel)
or EpoxyModel#addTo(EpoxyController)
.
To use your controller, create a new instance and call getAdapter()
to get the backing adapter to set on your RecyclerView. Then, call requestModelBuild
on your controller to tell Epoxy to trigger a model rebuild and update the adapter (You cannot call buildModels
directly). Call requestModelBuild
again whenever your data changes and you would like the RecyclerView to be updated accordingly.
public class PhotoController extends EpoxyController {
private List<Photo> photos = Collections.emptyList();
private boolean loadingMore = true;
public void setLoadingMore(boolean loadingMore) {
this.loadingMore = loadingMore;
requestModelBuild();
}
public void setPhotos(List<Photo> photos) {
this.photos = photos;
requestModelBuild();
}
@Override
protected void buildModels() {
new HeaderModel_()
.id("header model")
.title("My Photos")
.addTo(this);
for (Photo photo : photos) {
new PhotoModel_()
.id(photo.getId())
.url(photo.getUrl())
.comment(photo.getComment())
.addTo(this);
}
new LoadingModel_()
.id("loading model")
.addIf(loadingMore, this);
}
}
The controller is setup like this:
controller = new PhotoController();
recyclerView.setAdapter(controller.getAdapter());
controller.requestModelBuild();
Then you can imagine we might make several network requests to load photos, and we would call controller.setPhotos
with the updated photo list each time it changes, and controller.setLoading(false)
when we have no more to load.
Notice that both of these setters call requestModelBuild
to notify Epoxy that models should be rebuilt. It is your choice whether to make this call inside your controller or externally. Similarly, in this example we store our data as fields and add setter methods to update it, but EpoxyController has no requirement for how the data used in buildModels
is stored or accessed. This flexibility makes the EpoxyController adaptable to whichever architecture patterns you follow. However, if you would like a more structured usage you can use the TypedEpoxyController
In order for the controller to work correctly Epoxy expects the models to obey two important rules:
- All models must set a unique id.
- Models are immutable and their state cannot be changed once they are added to the controller. (The one exception is Interceptors)
These rules are necessary for diffing to work properly, so that every view is kept consistent with your data state. Epoxy enforces these rules via runtime validations.
Models are not allowed to to use the default id assigned to them when they are instantiated. That is only allowed in an EpoxyAdapter. Each model in an EpoxyController must have an explicit id set on it or be an AutoModel
requestModelBuild
does what its name implies, it requests that models be built but does not guarantee that it will happen immediately. The very first time it is called on a controller, models will be built immediately (so that the view is populated ASAP and view state can be restored), but subsequent calls are posted and debounced. This is to decouple model building from data changes. This way all data updates can be completed in full without worrying about calling requestModelBuild
multiple times.
For example, in our PhotoController example we don't want to trigger two model builds if we change both the loading state and the list of photos. With the posting and debouncing we can safely call requestModelBuild
twice (once in each setter), and models will only be built once. This relieves the calling code from trying to optimize calls to requestModelBuild
.
Every buildModels
call is completely independent of the previous call. buildModels
always starts with an empty list of models and must create, modify, and add all models that represent the data at that time.
You can conditionally add a model by using EpoxyModel#addIf
instead of the normal add
method. Hiding models with EpoxyModel#hide()
is not allowed in an EpoxyController.
Once models are built, Epoxy sets the new models on the backing adapter and runs a diffing algorithm to compute changes against the previous model list. Any item changes are notified to the RecyclerView so that views can be removed, inserted, moved, or updated as necessary.
Epoxy manages the creation, binding, and recycling of all views for you. View binding calls are delegated to the model so that the model can update the view with the data it represents. Other view lifecycle events, such as attaching/detaching to window and view recycling, are also delegated to the model.
EpoxyController has a second constructor that takes two Handler
instances, one for running model building and one for handling diffing. By default these use the main thread, but they can be changed to allow async work for performance improvement.
Note: The first time an EpoxyController builds models it will always run on the main thread, even if you have specified an async handler. This is necessary to allow views to synchronously restore saved state when first created. See Javadoc on EpoxyController#requestModelBuild
for more details.
To easily use this out of the box, you can override AsyncEpoxyController
. If you would instead prefer control over which threads Epoxy uses read below.
The default handlers can be changed by setting the static fields EpoxyController#defaultDiffingHandler
and EpoxyController#defaultModelBuildingHandler
which lets you force async behavior for all controllers in an app.
For example, you can set the global default to async like this
HandlerThread handlerThread = new HandlerThread("epoxy");
handlerThread.start();
Handler handler = new Handler(handlerThread.getLooper());
EpoxyController.defaultDiffingHandler = handler;
EpoxyController.defaultModelBuildingHandler = handler;
Epoxy provides some utils in the class EpoxyAsyncUtil
for creating async Handlers. For example, you can use the function EpoxyAsyncUtil#getAsyncBackgroundHandler
to easily get a Handler that uses a dedicated async background thread.
A Handler is used because Epoxy needs to be able to easily work with the main thread as the default. Also, Epoxy doesn't support parallel model building, and a Handler enforces this with its single threaded looping behavior.
Note that if you use async model building you must guarantee that all of your data access is thread safe - Epoxy does not help you with that. This means that your EpoxyController#buildModels
implementation is thread safe in all of its data access.
If the same Handler is provided for model building and diffing then all of that work will proceed synchronously on the same thread, from the start of the model building to the end of the diffing.
If you would like to be alerted when models have finished building and diffing, and the RecyclerView has been updated, you can use the EpoxyController#addModelBuildListener
method to register a callback.
Use this to react to changes in your models that need to happen after the RecyclerView has been notified, such as scrolling.
As mentioned above, the EpoxyController expects every model to have a unique id and to not be modified after it is added to a controller (The one exception is Interceptors). Violations of this will result in a runtime exception.
These validations are extremely important for helping you to avoid mistakes when using Epoxy. They are enabled by default, but can be disabled. You may wish to disable them for production builds to avoid the runtime overhead of the validation checking.
The main purposes of the validations is to enforce immutability of models. Anything that results in a change to a model's hashCode is not allowed. This can be an easy mistake since models don't have their immutability enforced by the compiler; that is, the general pattern for EpoxyModels does not use final fields and builder patterns, so the compiler will still allow a field to be changed. The runtime validations are in place to help warn you of any accidental violations by periodically asserting that each model's hashCode has not changed from when it was first added.
If you have models that always exist in the adapter (like a header or loader) you can mark them as a field with an @AutoModel
annotation to have Epoxy automatically create the model for you and assign a unique id. This id will be stable across different adapter instances so it can be used to save model state across rotation.
Note: This does not work with PagedListEpoxyController
, and it is not recommended if you are using Kotlin. For recommended Kotlin usage see Usage With Kotlin
For example, we could update the photo controller from above like so:
public class PhotoController extends EpoxyController {
@AutoModel HeaderModel_ header;
@AutoModel LoadingModel_ loader;
private List<Photo> photos = Collections.emptyList();
private boolean loadingMore = true;
public void setLoadingMore(boolean loadingMore) {
this.loadingMore = loadingMore;
requestModelBuild();
}
public void setPhotos(List<Photo> photos) {
this.photos = photos;
requestModelBuild();
}
@Override
protected void buildModels() {
header
.title("My Photos")
.addTo(this);
for (Photo photo : photos) {
new PhotoModel_()
.id(photo.getId())
.url(photo.getUrl())
.comment(photo.getComment())
.addTo(this);
}
loader
.addIf(loadingMore, this);
}
}
The auto model fields are created for you automatically; a new instance is created before every buildModels call. You should never assign a value manually to an auto model field, or otherwise modify them outside of the buildModels
method. Also, keep in mind that since the model is always recreated you cannot use == to compare a field value with a model on the adapter (eg in an onBind callback).
The generated ids are always negative values so that they are less likely to clash with manually set ids (eg ids from databases which are generally greater than 0).
(Available in 2.1 release)
If enabled, models created with the auto model annotation will be automatically added to the controller after they are modified in the buildModels
method. This allows you to omit the addTo
method call after building the model. This is disabled by default.
There are a few rules governing this process. First, an auto model is "staged" for implicit adding once it has been modified in the buildModels
method (ie any setter method is called on it to update its data). Once the model is staged it will be automatically added to the controller once another model is modified or added (or the buildModels
method returns).
A model will be removed from staging if it fails an addIf
condition. If the staged model is manually added with addIf
, addTo
, or add(...)
then it will be removed from staging and not added twice.
If you don't need to modify a model (eg a loader) then there is no way to stage it for implicit adding, so you must add it manually like normal - model.addTo(...)
or add(model)`.
This implicit adding can be a nice way to remove boilerplate if you have many auto models. Below is an example of how it would look if we updated the sample code from above to use implicit adding:
@Override
protected void buildModels() {
header
.title("My Photos");
// No "addTo" call is needed here. The model will automatically be added.
for (Photo photo : photos) {
new PhotoModel_()
.id(photo.getId())
.url(photo.getUrl())
.comment(photo.getComment())
.addTo(this);
}
loader
.addIf(loadingMore, this);
}
AutoModel annotations do not work on fields in inner classes (static nested classes work fine). If you want to use an EpoxyController as a nested class (so that it can access data in its parent class) then you must handle all ids manually. A nice pattern to use is this:
@Override
protected void buildModels(){
new HeaderModel_()
id("header")
.title(..)
.subtitle(...)
.addTo(this);
...
new LoaderModel_()
.id("loader")
.addIf(isLoading);
}
This does basically the same thing as @AutoModel, but in general we prefer to use auto models so we don't have to worry about id collisions or worry about assigning unique ids. In simple controllers that are not nested you may still like to use this approach.
If you are writing your EpoxyController classes in Kotlin Epoxy can generate extension functions for building your models. This replaces the AutoModel pattern.
For a model named HeaderViewModel
usage looks like this.
override fun buildModels() {
headerView {
id("header")
title("Hello World")
}
}
To use, make sure you have applied the kapt plugin (apply plugin: 'kotlin-kapt'
) in your build.gradle
file. Epoxy will then generate a Kotlin file named EpoxyModelKotlinExtensions.kt
in each package that contains generated EpoxyModels. This file will contain an extension function on the EpoxyController class for each generated model in that package. The function name is the name of the model with any Epoxy
or Model
suffix removed.
These functions can only be used in your controller's buildModels
method. Each function takes a lambda with the model as a receiver, creates a new instance of the model, calls the lambda to allow you to initialize the model, and then adds the model to the controller for you.
You must manually specify an id for each model built this way.
A normal EpoxyController does not dictate where the data used to build its models comes from. This makes it flexible, but can also require extra overhead in passing and storing data, or encourage bad architectural patterns.
The TypedEpoxyController
aims to fix this by removing boilerplate around data flow and encouraging buildModels
to be a pure function. Subclasses of TypedEpoxyController
are assigned a data type, and setData
is called to pass an object of that type whenever models should be rebuilt. Finally, the buildModels
method is called with that data object.
Continuing with our photo controller example from previous sections, we can simplify it greatly with the typed controller (but without the loader):
class PhotoController extends TypedEpoxyController<List<Photo>> {
@AutoModel HeaderModel_ header;
@Override
protected void buildModels(List<Photo> photos) {
header
.title("My Photos")
.addTo(this);
for (Photo photo : photos) {
new PhotoModel_()
.id(photo.getId())
.url(photo.getUrl())
.comment(photo.getComment())
.addTo(this);
}
}
}
To use this we call photoController.setData(photos)
whenever the photo data changes and we want models to be rebuilt. Ideally the buildModels(List<Photo>)
implementation is written in such a way that it depends only on the photos
input and has no side effects other than adding models. This way it is extremely predictable, readable, and testable.
The setData
method replaces the normal requestModelBuild
method used on the base EpoxyController. It is an error to call requestModelBuild
directly on a typed controller.
Also available are Typed2EpoxyController
, Typed3EpoxyController
, and Typed4EpoxyController
- each one having a different number of type parameters. These are useful if your data is represented by more than one object.
We can use this to restore the loading data to our photo controller:
class PhotoController extends Typed2EpoxyController<List<Photo>, Boolean> {
@AutoModel HeaderModel_ header;
@AutoModel LoadingModel_ loader;
@Override
protected void buildModels(List<Photo> photos, Boolean loadingMore) {
header
.title("My Photos")
.addTo(this);
for (Photo photo : photos) {
new PhotoModel_()
.id(photo.getId())
.url(photo.getUrl())
.comment(photo.getComment())
.addTo(this);
}
loader
.addIf(loadingMore, this);
}
}
In this case we can call photoController.setData(photos, isLoadingMore)
to have the state include whether more photos are being loaded. Unfortunately, primitives are not allowable types so we must use Boolean
instead of boolean
. Another downside is that by default the method signature of setData
shows generic parameter names (data1, data2, etc) which are unclear.
To fix this we can override setData
to provide clarity.
@Override
public void setData(List<Photo> photos, Boolean loadingMore) {
super.setData(photos, Predicates.notNull(loadingMore));
}
This gives callers information about what the Boolean
parameter represents and protects against a null value.
Call setDebugLoggingEnabled(true)
to enable printing debug messages to Logcat. The tag will be the name of your controller class.
These debug messages will include:
-
Diffing results, saying what item changes were detected and notified. Checking this is useful for validating that your data changes are having the expected recycler view updates. Discrepancies may indicate incorrect set up with your model state, problems with model ids, or other general bugs in your
buildModels
implementation. -
Timing output to report how much time it took to build and diff models. This can be useful to profile performance and keep an eye on regressions or slowdowns.
By default the DiffUtil
diffing algorithm ignores items with duplicate ids and behavior in that case is undefined, since it breaks the RecyclerView stable id contract which EpoxyController requires to work properly. In complicated controllers it can be hard to guarantee there are no stable id regressions, and if there are we should recover gracefully in production.
To do this, call setFilterDuplicates(true)
. When enabled, Epoxy will search for models with duplicate ids added during buildModels
and remove any duplicates found. If models with the same id are found, the first model is left in the adapter and subsequent models are removed. onExceptionSwallowed
will be called for each duplicate removed.
This may be useful if your models are created via server supplied data, in which case the server may erroneously send duplicate items. Alternatively, if your model id's rely on hashing (eg any string ids, or multiple number ids) there is an (extremely) small chance of hash collisions which you may want to protect against.
In general it is not possible to modify EpoxyModels after they are added to a controller in buildModels
. The one exception is if an Interceptor
is used. Interceptors are run after buildModels
is called, but before the models are diffed and set on the adapter. At that time, each interceptor's intercept
method is called with the list of models added during buildModels
, and the interceptor is free to modify the list, and each model in the list, however it likes.
This can be useful for cases where the models must be acted upon in aggregate, such as toggling dividers. Another useful case is modifying models for an A/B experiment.
Add interceptors with addInterceptor
. Interceptors are run in the order they are added, with each subsequent interceptor receiving the modified list produced by the previous interceptor.
It is an error for interceptors to modify the list, or any models in it, after the intercept
method has returned.
If Epoxy encounters a recoverable error it will call onExceptionSwallowed
with the exception and then continue on as best as possible. A common case for this might be when a duplicate is found when setFilterDuplicates
is enabled.
By default onExceptionSwallowed
does nothing, but your subclass can override it to alert you of any issues. A good pattern is to have a base EpoxyController in your app that all your other controllers extend. In the base controller you can override onExceptionSwallowed
and throw in debug builds or log to a crash reporting service in production. It is recommended that you don't ignore onExceptionSwallowed
since it reports actual problems in your controller.