In this codelab you'll learn how to use Agera to eliminate lag and jank from your UIs and think in a reactive and functional way. You will also learn how to use lambda expressions and method references to keep your code compact and clean.
You can clone the GitHub repository from the command line using this command:
$ git clone https://github.com/googlecodelabs/android-agera
To run a specific step of the codelab, choose it in the run configurations selector.
Every step of this codelab has a "final" version that runs the solution. Likewise, there is a "final" class with the suggested solution in every step.
Reactive programming is a paradigm related to how changes are propagated in a program. A good example is a spreadsheet: cells contain formulas that depend on other cells' values. When one of these values are changed, there's no need to manually update the resulting values; the changes are propagated for you.
Agera brings a reactive flavor to Android using an observer pattern.
The main class in Agera is the Repository
. To understand it, let's go over the basics first.
The foundation of Agera is a set of very simple interfaces including: Observable
, Updatable
, Supplier
and Receiver
:
public interface Observable {
void addUpdatable(Updatable updatable);
void removeUpdatable(Updatable updatable);
}
public interface Updatable {
void update();
}
public interface Supplier<T> {
T get();
}
public interface Receiver<T> {
void accept(T value);
}
As you can see, these four interfaces are very simple. This is what they're responsible for:
Interface | Description |
| Something that can be observed for changes and stores a list of updatable objects. When a change occurs, the updatables are poked. |
| Called when an event has occurred. Its |
| Something that supplies data when the |
| Something that can receive (and normally store) a value send to it via |
Let's create objects implementing these interfaces to get used to the terminology.
In this chapter, we'll create a simple implementation of the observer pattern to become familiar with Agera's interfaces. Skip to the next chapter if you already understand these concepts.
Many times the Observable
is also a Supplier
and a Receiver
, since the thing that is observed is probably the data we're interested in and you need a way to receive it. Create a class that is an Observable
, a Supplier
and a Receiver
:
Open step1/Step1Activity.java
.
Create a inner class in the Step1Activity
class that implements these three interfaces:
private static class MyDataSupplier implements Observable, Supplier<String>, Receiver<String> {
}
It will show an error, so let Android Studio tell us what we need to fill in:
Implement methods, create a list to store the Updatable
s and a way to set the data. It should look something like this:
private static class MyDataSupplier implements Observable, Supplier<String>, Receiver<String> {
List<Updatable> mUpdatables = new ArrayList<>();
private String mValue;
@Override
public void addUpdatable(@NonNull Updatable updatable) {
mUpdatables.add(updatable);
}
@Override
public void removeUpdatable(@NonNull Updatable updatable) {
mUpdatables.remove(updatable);
}
@NonNull
@Override
public String get() {
return mValue;
}
@Override
public void accept(@NonNull String value) {
mValue = value;
// Notify the updatables that we have new data
for(Updatable updatable : mUpdatables) {
updatable.update();
}
}
}
Now, in Step1Activity.onCreate()
, create an Updatable
and an instance of our Observable-Supplier class.
// Create a Supplier-Observable-Receiver
MyDataSupplier myDataSupplier = new MyDataSupplier();
// Create an Updatable
Updatable updatable = new Updatable() {
@Override
public void update() {
Log.d("AGERA", myDataSupplier.get());
}
};
// Connect the dots:
myDataSupplier.addUpdatable(updatable);
Convert that "new Updatable()
..." to a lambda expression.
updatable = () -> Log.d("AGERA", myDataSupplier.get());
Feel free to run the activity, but you won't see a thing in the logcat. We're missing a line in onCreate()
:
myDataSupplier.accept("Hello Agera!");
To understand how this works, let's follow the dots:
Seriously? All this effort for a hello world?
This is a naive implementation of the Observer Pattern, but most of this boilerplate is already in Agera. Let's remove some code in the next chapter.
The most important concept in Agera is the Repository. Repositories receive, supply, and store data and emit updates. The interfaces Observable
, Supplier
and Receiver
are combined into two types of repositories:
Interface | Observable | Supplier | Receiver |
| ☑ | ☑ | ☐ |
| ☑ | ☑ | ☑ |
A simple repository can be created using one of the utility methods in the class Repositories
.
Object.equals(Object)
).In our code, you could replace
implements Observable, Supplier<String>, Receiver<String>
with:
implements MutableRepository<String>
but Agera provides a Repository factory, so remove the MyDataSupplier
class altogether and replace
MyDataSupplier myDataSupplier = new MyDataSupplier();
with
MutableRepository<String> mStringRepo = Repositories.mutableRepository("Initial value");
mutableRepository()
creates a repository similar to our previous implementation, but that is thread-safe and has a more sophisticated update dispatcher, so let's use that from now on.
Also, remove the updatables (with removeUpdatable()
) when you know you're done with them. In our example this is not needed but it is a good practice: it avoids potential leaks and prevents updating destroyed views. The class is now much shorter:
public class Step1ActivityFinal extends AppCompatActivity {
private MutableRepository<String> mStringRepo;
private Updatable mLoggerUpdatable;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.step1);
// Create a MutableRepository
mStringRepo = Repositories.mutableRepository("Initial value");
// Create an Updatable
mLoggerUpdatable = () -> Log.d("AGERA", mStringRepo.get());
}
@Override
protected void onStart() {
super.onStart();
mStringRepo.addUpdatable(mLoggerUpdatable);
// Change the repository's value
mStringRepo.accept("Hello world.");
}
@Override
protected void onStop() {
mStringRepo.removeUpdatable(mLoggerUpdatable);
super.onStop();
}
}
However, there's still a lot of boilerplate. The next step will introduce complex repositories, which is where the power of Agera lies.
Observable
, Updatable
, Supplier
and Receiver
. These are combined in the Repository
and MutableRepository
interfaces.Repositories.mutableRepository(T)
and Repositories.repository(T)
for immutable repositories.A complex repository in Agera can react to other repositories (or observables, in general) and produce values by transforming data obtained from other data sources, synchronously or asynchronously. The data supplier by this repository is kept up to date in reaction to the events from the sources it's observing.
The description provides a declaration of:
The declaration is defined compiling a repository. Example:
topAlbumsRepository = repositoryWithInitialValue(emptyList())
.observe(accountManager, // When the account changes...
networkStatus) // or when we get online...
.onUpdatesPer(10000) // but at most every 10 seconds...
.goTo(networkExecutor) // on the network thread...
.getFrom(albumsFetcher) // fetch albums from API...
.thenTransform(
functionFrom(String.class)
.unpack(jsonUnpacker) // unpack JSON items...
.map(jsonToAlbum) // for each album...
.filter(fiveStarRating) // filter the best...
.thenLimit(5)) // and give us the first five of those!
.compile(); // Create the repository!
.repositoryWithInitialValue(value)
.observe(...)
.onUpdatesPer(...)
or .onUpdatesPerLoop()
.getFrom(...), .mergeIn(...)
See below (Operators)..compile()
, the previous directive must start with .then
(.thenTransform
, .thenMerge
, .thenGetFrom...
)..notifyIf(...)
, .onDeactivation(...)
, .onConcurrentUpdate(...)
, etc.Data transformation is done with operators.
Operator | Description |
| Ignores input and returns a value from a supplier |
| Applies a function to an input and returns the result |
| A function that takes the input from the previous directive and the input from an external supplier to produce a result |
| The input is not modified, but it's sent to an external receiver |
| Similar to |
| Evaluates a condition for "early exit" |
The functional interfaces Supplier
, Function
and Merger
are defined not to throw any exceptions, but realistically, many operations may fail. To help capture the failures, Agera provides a wrapper class Result
, which encapsulates the (either successful or failed) result of a fallible operation, which we call an attempt. The attempt can be implemented as a Supplier
, Function
or Merger
that returns a Result
.
The data processing flow provides failure-aware directives that allow terminating the flow in case of failure:
.attemptGetFrom(Supplier).or
...;
.attemptTransform(Function).or
...;
.attemptMergeIn(Supplier, Merger).or
...;
The Result wrapper class can also store absent (or present) values.
Enough theory. In the next chapter, you'll see this in action.
In this step, you're going to create a simple UI that uses a complex repository.
It has a TextView
and a Button
. The Button
increments the value shown by the text view.
Open step2/Step2Activity.java
, and in onCreate()
:
1. Set an OnClickListener
on the button that increments the valueRepository
which is a MutableRepository<Integer>
that will be the source of truth for the value.
mIncrementBt.setOnClickListener(view -> valueRepository.accept(valueRepository.get() + 1));
3. Create our first complex repository. It observes the valueRepository
and transforms its value to a String
.
textValueRepository = Repositories.repositoryWithInitialValue("N/A")
.observe(valueRepository)
.onUpdatesPerLoop()
.getFrom(valueRepository)
.thenTransform(input -> String.format("%d", input))
.compile();
4. Create an Updatable
that will react to a change in the textValueRepository
and set the text view.
mTextValueUpdatable = new Updatable() {
@Override
public void update() {
mValueTv.setText(textValueRepository.get());
}
};
Now, let Android Studio convert that to a lambda expression:
mTextValueUpdatable = () -> mValueTv.setText(textValueRepository.get());
5. Add and remove updatables in onStart
and onStop
.
@Override
protected void onStart() {
super.onStart();
textValueRepository.addUpdatable(mTextValueUpdatable);
}
...
6. Optional: Handle rotation changes with onSaveInstanceState
and onRestoreInstanceState
.
Now the TextView
should be updated on each click. Let's take it up a notch in the next chapter.
By now you should understand the difference between a simple repository and a complex repository and how to wire them to UI elements.
This peculiar calculator uses RadioButton
s to choose an operation and SeekBar
s to set the two operands. This way we can generate multiple operations per second while sliding one or both bars at the same time quickly. (You'll see why in the next chapter.)
Run the Step 3 - Calculator
configuration and play with it.
The valid range for the inputs go from 0 to 100 and there's a division operation, so there's a potential problem in this calculator. We'll show "DIV#0"
when the divisor (second input) is zero.
A very convenient way to handle errors in Agera is to wrap repository values in Result
. Its values belong to an attempt, which is a call that may fail. A Result
will be absent or present, and failed or succeeded.
Open step3/CalculatorActivity.java
.
Our complex repository will hold a result of a String
:
Repository<Result<String>> mResultRepository;
Therefore, the Updatable will also get a result of an String
, which is very convenient as we can react to present, failed, or absent values:
mResultUpdatable = () -> mResultRepository
.get()
.ifFailedSendTo(t -> Toast.makeText(this, t.getLocalizedMessage(),
Toast.LENGTH_SHORT).show())
.ifFailedSendTo(t -> {
if (t instanceof ArithmeticException) {
resultTextView.setText("DIV#0");
} else {
resultTextView.setText("N/A");
}
})
.ifSucceededSendTo(resultTextView::setText);
You can add multiple ifFailedSendTo
methods. They give access to the throwable that caused the failure, so we can react to different exceptions.
There are many ways to compile the complex repository. In step3.CalculatorActivity
you'll find (a bad) one:
Result.<String>
absent
()
onUpdatesPerLoop
)mResultRepository = Repositories.repositoryWithInitialValue(Result.<String>absent())
.observe(mValue1Repo, mValue2Repo, mOperationSelector)
.onUpdatesPerLoop()
.getFrom(() -> "Lambda all the things!")
.thenTransform(input -> {
Result<Integer> operation = mOperationSelector.get();
if (operation.isPresent()) {
Integer result1;
int a = mValue1Repo.get();
int b = mValue2Repo.get();
switch (operation.get()) {
case R.id.radioButtonAdd:
result1 = a + b;
break;
case R.id.radioButtonSub:
result1 = a - b;
break;
case R.id.radioButtonMult:
result1 = a * b;
break;
case R.id.radioButtonDiv:
try {
result1 = (a / b);
} catch (ArithmeticException e) {
return Result.failure(e);
}
break;
default:
return Result.failure(
new RuntimeException("Invalid operation"));
}
return Result.present(result1.toString());
} else {
return Result.absent();
}
})
.onConcurrentUpdate(RepositoryConfig.SEND_INTERRUPT)
.compile();
The problem with this approach is that the last function has too much responsibility and it's hard to maintain, reuse and test.
Let's break it into pieces.
In this chapter, we are going to replace the operator thenTransform()
with a set of directives that:
mValue1Repo
.Pair<int, int>
of operandsBut first, let's talk about lambdas:
If you use Java 8 and the Jack compiler (or retrolambda), you may use lambdas to keep your code clean of types and annotations.
Agera lets you use concepts from functional programming. If you're not familiar with lambdas, let Android Studio suggest to you what classes to create implementing interfaces and then convert to lambdas automatically. For example:
We know getFrom
takes a Supplier. Create a new Supplier anonymous class:
Android Studio will ask for a type.
Set it to String
, for now...
Android Studio will ask you to implement the get()
method.
Now the IDE will suggest lambdas:
Types? Where we're going we don't need types.
The expression is replaced with a lambda function that takes no arguments and returns a string. The next directive must receive a String
in this case or the compiler will complain.
Once the directives are in place, you can move pieces of logic out of the Activity
.
1. Replace the last function in the repository with the flow above, after onUpdatesPerLoop
:
mValue1Repo
, using getFrom(Supplier)
Pair<int, int>
of operands, using mergeIn(Supplier, Merger).
attemptMergeIn(Supplier, Merger).
thenTransform(Function)
.You should end up with something similar to this:
mResultRepository = Repositories.repositoryWithInitialValue(Result.<String>absent())
.observe(mValue1Repo, mValue2Repo, mOperationSelector)
.onUpdatesPerLoop()
.getFrom(mValue1Repo)
.mergeIn(mValue2Repo, Pair::create)
.attemptMergeIn(mOperationSelector, CalculatorOperations::attemptOperation)
.orEnd(Result::failure)
.thenTransform(input -> Result.present(input.toString()))
.compile();
Note that we moved the performOperation
to a different class, to keep the Activity
short.
You might be wondering why there's a Androidified person moving around the calculator. It's our visual indicator of jankiness. Jankiness is produced when our main thread is so busy that it has to drop frames, producing a bad user experience.
We are going to make our calculation really slow, and we'll use a lot of CPU cycles and allocate unnecessary memory to demonstrate how to keep the UI smooth, no matter how busy our device is.
After
.onUpdatesPerLoop()
add these directives to your result repository:
.transform(input -> {
String stringCounter = "0";
for (int i = 0; i < 200_000; i++) {
// Show no love for our CPU and GC.
Integer intCounter = Integer.valueOf(stringCounter);
intCounter++;
stringCounter = intCounter.toString();
}
return input;
})
This simulates a very slow operation that uses a lot of CPU and memory. It counts from 0 to 200000 using strings and allocating memory for it. Then it returns the unmodified input
parameter.
Open the activity and play around with it, you'll immediately find jankiness which can be confirmed with the Android Monitor in Android Studio:
The green saw wave in the GPU graph means the device had to drop frames because the UI thread was busy.
We need to move this calculation off the main thread. Before the new transform
directive, add:
.goTo(mExecutor)
mExecutor
is a single thread executor in this case, but you can choose the type and size of the thread pools for intensive and parallel calculations. This directive moves the execution to a different thread from there on. It can be used down the line. For example, you might have a networking thread and a general purpose pool of threads for other operations.
Run the Activity. The CPU usage is still high, but the jankiness is gone and it's not dropping frames any more.
That's it. Moving work to a different thread in Agera is so easy, you should almost always do it. You don't need to create AsyncTasks or worry about callbacks.
Our app is super responsive now but we want it to finish as quickly as possible. The data flow is executed, including the slow function, every time any of the three observables change. What if we change one of the inputs before the previous operation has finished? We want to interrupt this operation to start the new calculation right away, because in this case, we don't care about the previous result.
Add this directive to the repository before .compile()
.
.onConcurrentUpdate(RepositoryConfig.SEND_INTERRUPT)
This will send an interrupt signal to the operation that is taking place when something changes. This is something you have to implement manually.
Normally, you will only implement an interruption mechanism in slow functions so let's modify ours:
.attemptTransform(input -> {
String stringCounter = "0";
for (int i = 0; i < 200000; i++) {
if (Thread.currentThread().isInterrupted()) {
return Result.failure();
}
// Show no love for our CPU and GC.
Integer intCounter = Integer.valueOf(stringCounter);
intCounter++;
stringCounter = intCounter.toString();
}
return Result.present(input);
})
.orEnd(i -> Result.absent())
Now transform
is an attemptTransform
because we want to fail when an interruption is received.
The final repository looks something like:
mResultRepository = Repositories.repositoryWithInitialValue(Result.<String>absent())
.observe(mValue1Repo, mValue2Repo, mOperationSelector)
.onUpdatesPerLoop()
.goTo(mExecutor)
.attemptTransform(CalculatorUtils::keepCpuBusy)
.orEnd(Result::failure)
.getFrom(mValue1Repo)
.mergeIn(mValue2Repo, Pair::create)
.attemptMergeIn(mOperationSelector, CalculatorUtils::performOperation)
.orEnd(Result::failure)
.thenTransform(input -> Result.present(input.toString()))
.compile();
Congratulations! You have a very performant, jank-free activity. Now let's talk about testing with Agera.
This final chapter requires you to be familiar with advanced Espresso topics, like Idling Resources.
Functions, Binders, Receivers, Suppliers, etc. are very simple interfaces and their implementations are easy to unit test. In the CalculatorOperationsTest
class, we added some unit tests using the Result
wrapper to check for errors.
@Test
public void attemptOperationDivZero_fails() throws Exception {
Result<Integer> result = CalculatorOperations.attemptOperation(OPERANDS_1_0, OPERATION_DIV);
assertFalse(result.isPresent());
assertTrue(result.failed());
}
Animations will sometimes send messages to the main thread's queue, making Espresso wait until the app is idle, indefinitely in this case: we have an animation that needs to be disabled in the Calculator activity.
1. Open Step4CalculatorActivityTest
and finish getActivityIntent
so that it sends a boolean in the Intent
to tell the activity that animations should be disabled.
2. In step4.CalculatorActivity#onCreate()
add something like:
// For testing, the animation can be disabled via an Intent.
if (getIntent().hasExtra(ANIMATIONS_ENABLED_KEY)) {
mAnimationEnabled = getIntent().getBooleanExtra(ANIMATIONS_ENABLED_KEY, true);
}
3. Intercept the start of the animation:
@Override
protected void onResume() {
super.onResume();
if (mAnimationEnabled) {
UiUtils.startAnimation(findViewById(R.id.imageView));
}
}
If you run the Step4CalculatorActivityTest
in step4, you'll notice that the animation is no longer moving. However, this is not enough to prepare our app for UI testing.
Agera lets you get off the main thread very easily but the moment you do that, Espresso is unaware of messages that are outside the main thread or the AsyncTasks thread(s).
We use Idling Resources to tell Espresso that our app is busy. There are many ways to implement this. The most common is using a CountingIdlingResource
to count (increment and decrement) the number of "tasks" that are being processed and if there are zero, the app is idle.
But in this codelab, we're going to go deeper. Since we're creating our own Executor, we can query it to check if there are active tasks.
Check out the ThreadPoolIdlingResource
class. You'll find that isIdleNow()
is checking if the queue is empty and if there are any active tasks.
@Override
public synchronized boolean isIdleNow() {
if (threadPoolExecutor.getQueue().isEmpty() && threadPoolExecutor.getActiveCount() <= 0) {
if (callback != null) {
callback.onTransitionToIdle();
}
return true;
}
return false;
}
1. In the class under test, change the Executor in the result repository to use our new CalculatorExecutor.EXECUTOR
.
2. In the test class, complete registerIdlingResource
so that it creates a new ThreadPoolIdlingResource
and passes our executor to it. Also, register the new idling resource with Espresso.registerIdlingResource()
.
Run the tests, they should all pass, slowly but surely, since Espresso is letting the repository compute the calculations.
You are ready to explore the more advanced topics in Agera. Network calls, SQLite interaction, RecyclerView
s and lists, BroadcastReceiver
s, etc.
For more information:
https://github.com/google/agera
Thank you!
The Agera team