Become a Firebase Taskmaster! (Part 2: Choosing the Best Options)

Ohai! You’ve just joined us for the second part of a blog series about the Play Services Task API, which is used by some Firebase features to respond to work that its APIs perform asynchronously. Last time, we got acquainted with a Task used by the Firebase Storage API, and learned a little bit about how Tasks work in general. So, if you haven’t seen that post, now’s good time to circle back to it before continuing here. In this post, we’ll take a look at some of the nuances in behavior between the different variations for adding a listener to a Task to capture its result.

Last time, we saw a listener get added to a Task like this, using the Firebase Storage API:

Task task = forestRef.getMetadata();
    task.addOnSuccessListener(new OnSuccessListener() {
        @Override
        public void onSuccess(StorageMetadata storageMetadata) {
            // Metadata now contains the metadata for 'images/forest.jpg'
        }
    });  

In this code, addOnSuccessListener() is called with a single argument, which is an anonymous listener to invoke upon completion. With this form, the listener is invoked on the main thread, which means we can do things that can only be done on the main thread, such as update a View. It’s great that the Task helps put the work back on the main thread, except there is one caveat here. If a listener is registered like this in an Activity, and it’s not removed before the Activity is destroyed, there is a possibility of an Activity leak.

But I Don’t Want Leaky Activities!

Right, nobody wants leaky Activities! So, what’s an Activity leak, anyway? Put briefly, an Activity leak occurs when an object holds onto an Activity object reference beyond its onDestroy() lifecycle method, retaining the Activity beyond its useful lifetime. When onDestroy() is called on an Activity, you can be certain that instance is never going to be used by Android again. After onDestroy(), we want the Android runtime garbage collector to clean up that Activity, all of its Views, other dead objects. But the garbage collector won’t clean up the Activity and all of its Views if some other object is holding a strong reference to it!

Activity leaks can be a problem with Tasks, unless you take care to avoid it. In the above code (if it was inside an Activity), the anonymous listener object actually holds a strong, implicit reference to the containing Activity. This is how code inside the listener is able to make changes to the Activity and its members - the compiler silently works out the details of that. An Activity leak occurs when an in-progress Task holds on to the listener past the Activity’s onDestroy(). We really don’t have any guarantees at all about how long that Task will take, so the listener can be held indefinitely. And since the listener implicitly holds a reference to the Activity, the Activity can be leaked if the Task doesn’t complete before onDestroy(). If lots of Tasks holding references to Activities back up over time (for example, due to a hung network), that can cause your app to run out of memory and crash. Yow. You can learn more in this video.

Back to the Task at Hand

If you’re concerned about leaking Activities (and I hope you are!), you should know that the single argument version of addOnSuccessListener() has the caveat of possibly leaking the Activity if you aren’t careful to remove the listener at the right time.

It turns out there’s a convenient way to do this automatically with the Task API. Let’s take the above code in an Activity, and modify its call to addOnSuccessListener() slightly:

Task task = forestRef.getMetadata();
    task.addOnSuccessListener(this, new OnSuccessListener() {
        @Override
        public void onSuccess(StorageMetadata storageMetadata) {
            // Metadata now contains the metadata for 'images/forest.jpg'
        }
    });  

This is exactly like the previous version, except there are now two arguments to addOnSuccessListener(). The first argument is `this`, so when this code is in an Activity, that would make `this` refer to that enclosing Activity instance. When the first parameter is an Activity reference, that tells the Task API that this listener should be “scoped” to the lifecycle of the Activity. This means that the listener will be automatically removed from the task when the Activity goes through its onStop() lifecycle method. This is pretty handy because you don’t have to remember to do it yourself for all the Tasks you may create while an Activity active. However, you need to be confident that onStop() is the right place for you to stop listening. onStop() is triggered when an Activity is no longer visible, which is often OK. However, if you intend to keep tracking the Task in the next Activity (such as when an orientation change replaces the current Activity with a new one), you’ll need to come up with a way to retain that knowledge in the next Activity. For some information on that, read up on saving Activity state.

Skipping the Traffic on Main St.

There are cases where you simply don’t want to react to the completion of a Task on the main thread. Maybe you want to do blocking work in your listener, or you want to be able to handle different Task results concurrently (instead of sequentially). So, you’d like to avoid the main thread altogether and instead process the result on another thread you control. There’s one more form of addOnSuccessListener() that can help your app with your threading. It looks like this (with abbreviated listener):

Executor executor = ...;  // obtain some Executor instance
Task task = RemoteConfig.getInstance().fetch();
task.addOnSuccessListener(executor, new OnSuccessListener() { ... });  

Here, we’re making a call to the Firebase Remote Config API to fetch new configuration values. Then, the returned Task from fetch() gets a call to addOnSuccessListener() and receives an Executor as the first argument. This Executor determines the thread that will be used to invoke the listener. For those of you unfamiliar with Executor, it’s a core Java utility that accepts units of work and routes them to be executed on threads under its control. That could be a single thread, or a pool of threads, all waiting to do work. It’s not very common to use for apps to use an Executor directly, and can be seen as an advanced technique for managing the threading behavior of your app. What you should take away from this is the fact that you don’t have to receive your listeners on the main thread if that doesn’t suit your situation. If you do choose to use an Executor, be sure to manage them as shared singletons, or make sure their lifecycles are managed well so you don’t leak their threads.

One other interesting thing to note about this code is the fact that the Task returned by Remote Config is parameterized by Void. This is the way a Task can say that it doesn’t generate any object directly - Void is the data type in Java that indicates absence of type data. The Remote Config API is simply using the Task as an indicator of task completion, and the caller is expected to use other Remote Config APIs to discover any new values that were fetched.

Choose Wisely, Indy!

All told, there are three varieties of addOnSuccessListener():

Task addOnSuccessListener(OnCompleteListener listener) 
Task addOnSuccessListener(Activity activity, OnSuccessListener listener) 
Task addOnSuccessListener(Executor executor, OnSuccessListener listener)  

On top of that, we have the same varieties for failure and completion listeners:

Task addOnFailureListener(OnFailureListener listener)
Task addOnFailureListener(Activity activity, OnFailureListener listener)
Task addOnFailureListener(Executor executor, OnFailureListener listener)

Task addOnCompleteListener(OnCompleteListener listener)
Task addOnCompleteListener(Activity activity, OnCompleteListener listener)
Task addOnCompleteListener(Executor executor, OnCompleteListener listener)  

Hold on, what’s an OnCompleteListener?

There’s nothing too special going on with OnCompleteListener. It’s just a single listener that’s capable of receiving both success and failure, and you have to check for that status inside the callback. The file metadata callback from the last post could be rewritten like this, instead of giving the task separate success and failure listeners:

Task task = forestRef.getMetadata();
task.addOnCompleteListener(new OnCompleteListener() {
  @Override
  public void onComplete(Task task) {
    if (task.isSuccessful()) {
      StorageMetadata meta = task.getResult();
      // Do something with metadata...
    } else {
      Exception e = task.getException();
      // Handle the failure...
    }
  }
});

So, with OnCompleteListener, you can have a single listener that handles both success and failure, and you find out which one by calling isSuccessful() on the Task object passed to the callback. Practically speaking, this is functionally equivalent to registering both an OnSuccessListener and an OnFailureListener. The style you choose is mostly a matter of preference.

Wrapping Up (part 2 of this series)

Now you’ve seen that Tasks can receive three different kinds of listeners: success, failure, and overall completion. And, for each of those kinds of listeners, there are three ways to receive that callback: on the main thread, on the main thread scoped to an Activity, and on a thread determined by an Executor. You have some choices here, and it’s up to you to choose which one suits your situation the best. However, these aren’t the only ways to handle the results of your Tasks. You can create pipelines of Task results for more complex processing. Please join me for those details next time, where you can continue the journey to become a Firebase Taskmaster!

If you have any questions, consider using Twitter with the #AskFirebase hashtag or the firebase-talk Google Group. We also have a dedicated Firebase Slack channel. And you can follow me @CodingDoug on Twitter.

Continue reading with part 3.