Sometimes, when using the Firebase client APIs for Android, it's required that Firebase perform some work at the request of the developer in an asynchronous fashion. Perhaps some requested data is not immediately available, or work needs to be queued for eventual execution. When we say some work must be done asynchronously in an app, that means the work needs to happen at the same time as the app performs its primary job of rendering the app’s views, but not get in the way of that work. To perform this asynchronous work correctly in Android apps, the work can't occupy time on the Android main thread, otherwise the app may delay rendering of some frames, causing "jank" in the user experience, or worse, the dreaded ANR! Typical examples of work that can cause delays are network requests, reading and writing files, and lengthy computations. In general, we call this blocking work, and we never want to block the main thread!
When a developer uses a Firebase API to request work that would normally block the main thread, the API needs to arrange that work to run on a different thread, in order to avoid jank and ANRs. Upon completion, the results of that work sometimes have to make it back to the main thread in order to safely update views.
That's what the Play services Task API is for. The goal of the Task API is to provide an easy, lightweight, and Android-aware framework for Firebase (and Play services) client APIs to perform work asynchronously. It was introduced in Play services version 9.0.0 along with Firebase. If you've been using Firebase features in your app, it's possible that you may have been using the Task API without even realizing it! So, what I'd like to do in this blog series is unpack some of the ways the Firebase APIs make use of Tasks, and discuss some patterns for advanced use.
Before we begin, it's important to know that the Task API isn't a full replacement for other threading techniques on Android. The Android team has put together some great content that describe other tools for threading, such as Services, Loaders, and Handlers. There's also a whole season of Application Performance Patterns on YouTube that discusses your options. Some developers even opt into third party libraries that will help you with your threading in Android apps. So, it's up to you to learn about those and determine which solution is the best for your particular threading needs. Firebase APIs uniformly use Tasks to manage threaded work, and you can use those in conjunction with other strategies as you see fit.
If you're using Firebase Storage, you'll definitely encounter Tasks at some point. Here's a straightforward example of fetching metadata about a file that's already uploaded to Storage, taken directly from the documentation for file metadata:
// Create a storage reference from our app StorageReference storageRef = storage.getReferenceFromUrl("gs://"); // Get reference to the file StorageReference forestRef = storageRef.child("images/forest.jpg"); forestRef.getMetadata().addOnSuccessListener(new OnSuccessListener() { @Override public void onSuccess(StorageMetadata storageMetadata) { // Metadata now contains the metadata for 'images/forest.jpg' } }).addOnFailureListener(new OnFailureListener() { @Override public void onFailure(@NonNull Exception exception) { // Uh-oh, an error occurred! } });
Even though we never see a "Task" anywhere in this code, there is actually a Task in play here. The last part of the above code could be rewritten equivalently like this:
Task task = forestRef.getMetadata(); task.addOnSuccessListener(new OnSuccessListener() { @Override public void onSuccess(StorageMetadata storageMetadata) { // Metadata now contains the metadata for 'images/forest.jpg' } }); task.addOnFailureListener(new OnFailureListener() { @Override public void onFailure(@NonNull Exception exception) { // Uh-oh, an error occurred! } });
Ah, it looks like there was a Task hidden in that code after all!
With the sample code rewritten above, it's now more clear how a Task is being used to obtain file metadata. The getMetadata() method on the StorageReference has to assume that the file metadata is not immediately available, so it will make a network request to get a hold of it. So, in order to avoid blocking the calling thread on that network access, getMetadata() returns a Task that can be listened to for eventual success or failure. The API then arranges to perform the request on a thread it controls. The details of this threading are hidden by the API, but the returned Task is used to indicate when the results become available. The returned Task then guarantees that any added listeners will be invoked upon completion. This form of API to manage the results of asynchronous work is sometimes called a Promise in other programming environments.
Notice here that the returned Task is parameterized by the type StorageMetadata, and that's also the type of object that gets passed to onSuccess() in the OnSuccessListener. In fact, all Tasks must declare a generic type in this way to indicate the type of data they generate, and the OnSuccessListener must share that generic type. Also, when an error occurs, an Exception is passed to onFailure() in the OnFailureListener, which will probably be the specific exception that caused the failure. If you want to know more about that Exception, you may have to check its type in order to safely cast it to the expected type.
The last thing to know about this code is that the listeners will be called on the main thread. The Task API arranges for this to happen automatically. So, if you want to do something in response to the StorageMetadata becoming available that must happen on the main thread, you can do that right there in the listener method. (But remember that you still shouldn’t be doing any blocking work in that listener on the main thread!) You have some options about how these listeners work, and I'll say more in a future post about your alternatives.
Some Firebase features provide other APIs that accept listeners that are not associated with Tasks. For example, if you're using Firebase Authentication, you've almost certainly registered a listener to find out when the user successfully logs in or out of your app:
private FirebaseAuth auth = FirebaseAuth.getInstance(); private FirebaseAuth.AuthStateListener authStateListener = new FirebaseAuth.AuthStateListener() { @Override public void onAuthStateChanged(@NonNull FirebaseAuth firebaseAuth) { // Welcome! Or goodbye? } }; @Override protected void onStart() { super.onStart(); auth.addAuthStateListener(authStateListener); } @Override protected void onStop() { super.onStop(); auth.removeAuthStateListener(authStateListener); }
The FirebaseAuth client API makes two main guarantees for you here when you add a listener with addAuthStateListener(). First, it will call your listener immediately with the currently known login state for the user. Then, it will call the listener again with all subsequent changes to the user's login state, for as long as the listener is added to the FirebaseAuth object. This behavior is very different than the way Tasks work!
Tasks only call any added listener at most once, and only after the result is available. Also, the Task will invoke a listener immediately if the result was already available before that listener was added. The Task object effectively remembers the final result object and continues to deal it out to any future listeners, until it has no more listeners and is eventually garbage collected. So if you're using a Firebase API that works with listeners on something other than a Task object, be sure to understand its own behaviors and guarantees. Don't assume that all Firebase listeners behave like Task listeners!
Consider the active lifetime of your added Task listeners. There are two things that can go wrong if you don’t do this. First, you can cause an Activity leak if the Task continues beyond the lifetime of an Activity and its Views that are being referenced by an added listener. Second, the listener might execute when it’s no longer needed, causing wasteful work to be done, and possibly doing things that access Activity state when it’s no longer valid. The next part of this blog series will go into these issues in more detail, and how to avoid them.
We've taken a brief look at the Play Services Task API and uncovered its (sometimes hidden!) use in some Firebase sample code. Tasks are the way that Firebase lets you respond to work that has an unknown duration and must be executed off the main thread. Tasks can also arrange for listeners to be executed back on the main thread to deal with the result of the work. However, we've only just scratched the surface of what Tasks can do for you. Next time, we'll look at the variations on Task listeners so you can decide which one best suits your use cases.
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.