Chained Work

Where WorkManager shines in comparison to previous deferrable-task solutions is in its support for chained work. Chained work is where you set up work requests that in turn depend upon other work requests. Later work requests in the chain are only performed if the previous ones succeeded. And, work requests can supply data to the next request in the chain, akin to command-line pipelines or basic workflow systems.

Why?

On the one hand, chained work may not seem necessary. In principle, what you do as a series of work requests could be done in one large work request.

The big benefit of splitting the work into separate requests comes with the application of constraints. For example, the sample app that we will examine demonstrates chained work by downloading a ZIP file, then unZIPping it. Downloading a ZIP file requires an Internet connection, but unZIPping it does not. By providing separate constraints for each work request, you can require a network connection for the download, yet not require it for the unZIP task, thereby allowing that work to proceed even if Internet connectivity is lost.

Also, smaller Worker classes can be made more reusable. One can imagine a library of common Worker classes. Rather than having to write your own CompositeWorker that used several Worker classes, you can simply set up a chain using existing APIs.

Chained work also helps to address the delivery of status updates as a larger task is being processed. AsyncTask offers publishProgress() and onProgressUpdate() to inform users of the task about ongoing progress. WorkManager lacks that sort of facility. However, each WorkRequest in the chain has its own WorkStatus that can be monitored via LiveData. This way, you can at least get coarse-grained information about how the chain overall is proceeding.

How Do We Chain Work?

To enqueue a WorkRequest, we used enqueue() on the WorkManager instance. In truth, that is a convenience method. This:

WorkManager.getInstance().enqueue(request);

is really this:

WorkManager.getInstance().beginWith(request).enqueue();

beginWith() returns a WorkContinuation. This is an object that knows a WorkRequest to process and knows how to be chained.

To have a follow-on WorkRequest in a simple two-element chain, call then() on the WorkContinuation before the terminal enqueue() call:

WorkManager.getInstance().beginWith(request).then(otherRequest).enqueue();

Now, request will be processed, and if it succeeds, then (and only then) will otherRequest be processed.

How Do We Pass Data Along the Chain?

We provide input to a WorkRequest via its Builder and setInputData(). However, this is input that is created outside the processing of any individual request; it is input that is defined when the chain is defined.

In addition, a Worker can provide output data to factory methods like success() on ListenableWorker.Result. Those factory methods take the same sort of Data object that setInputData() does. The output data can be used in two places:

OK, Where’s the Code?

The Work/UnZIP sample project is a variation on the previous example, this time where we have two requests in a chain.

DownloadWorker is largely the same as before, with two differences:

  1. Rather than receiving a filename as input, it decides what the filename will be, as that will merely serve as a temporary file
  2. It passes the path to that file to the next request in the chain via setOutputData()
package com.commonsware.android.work.download;

import android.content.Context;
import android.support.annotation.NonNull;
import android.util.Log;
import com.commonsware.cwac.security.ZipUtils;
import java.io.File;
import java.io.IOException;
import androidx.work.Data;
import androidx.work.ListenableWorker;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okio.BufferedSink;
import okio.Okio;

public class DownloadWorker extends Worker {
  public static final String KEY_URL="url";
  public static final String KEY_RESULTDIR="resultDir";

  public DownloadWorker(@NonNull Context context,
                        @NonNull WorkerParameters workerParams) {
    super(context, workerParams);
  }

  @NonNull
  @Override
  public Result doWork() {
    OkHttpClient client=new OkHttpClient();
    Request request=new Request.Builder()
      .url(getInputData().getString(KEY_URL))
      .build();

    File dir=getApplicationContext().getCacheDir();
    File downloadedFile=new File(dir, "temp.zip");

    if (downloadedFile.exists()) {
      downloadedFile.delete();
    }

    try (Response response=client.newCall(request).execute()) {
      BufferedSink sink=Okio.buffer(Okio.sink(downloadedFile));

      sink.writeAll(response.body().source());
      sink.close();
    }
    catch (IOException e) {
      Log.e(getClass().getSimpleName(), "Exception downloading file", e);

      return ListenableWorker.Result.failure();
    }

    return ListenableWorker.Result.success(new Data.Builder()
      .putString(UnZIPWorker.KEY_ZIPFILE, downloadedFile.getAbsolutePath())
      .build());
  }
}

We now also have an UnZIPWorker. This expects two pieces of input: the file to unZIP and the directory to unZIP it into. It uses the CWAC-Security library’s ZipUtils.unzip() method, as that safely handles possibly-malicious ZIP files (e.g., zip bombs):

package com.commonsware.android.work.download;

import android.content.Context;
import android.support.annotation.NonNull;
import android.util.Log;
import com.commonsware.cwac.security.ZipUtils;
import java.io.File;
import androidx.work.ListenableWorker;
import androidx.work.Worker;
import androidx.work.WorkerParameters;

public class UnZIPWorker extends Worker {
  public static final String KEY_ZIPFILE="zipFile";
  public static final String KEY_RESULTDIR="resultDir";

  public UnZIPWorker(@NonNull Context context,
                     @NonNull WorkerParameters workerParams) {
    super(context, workerParams);
  }

  @NonNull
  @Override
  public Result doWork() {
    File downloadedFile=new File(getInputData().getString(KEY_ZIPFILE));
    File dir=getApplicationContext().getCacheDir();
    String resultDirData=getInputData().getString(KEY_RESULTDIR);
    File resultDir=new File(dir, resultDirData==null ? "results" : resultDirData);

    try {
      ZipUtils.unzip(downloadedFile, resultDir, 2048, 1024*1024*16);
      downloadedFile.delete();
    }
    catch (Exception e) {
      Log.e(getClass().getSimpleName(), "Exception unZIPing file", e);

      return ListenableWorker.Result.failure();
    }

    return ListenableWorker.Result.success();
  }
}

DownloadViewModel now sets up a request chain using both worker classes:

package com.commonsware.android.work.download;

import android.arch.lifecycle.LiveData;
import android.arch.lifecycle.MediatorLiveData;
import android.arch.lifecycle.ViewModel;
import androidx.work.Constraints;
import androidx.work.Data;
import androidx.work.NetworkType;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkInfo;
import androidx.work.WorkManager;

public class DownloadViewModel extends ViewModel {
  public final MediatorLiveData<WorkInfo> liveWorkStatus=new MediatorLiveData<>();

  public void doTheDownload() {
    OneTimeWorkRequest downloadWork=
      new OneTimeWorkRequest.Builder(DownloadWorker.class)
        .setConstraints(new Constraints.Builder()
          .setRequiredNetworkType(NetworkType.CONNECTED)
          .setRequiresBatteryNotLow(true)
          .build())
        .setInputData(new Data.Builder()
          .putString(DownloadWorker.KEY_URL,
            "https://commonsware.com/Android/source_1_0.zip")
          .build())
        .addTag("download")
        .build();
    OneTimeWorkRequest unZIPWork=
      new OneTimeWorkRequest.Builder(UnZIPWorker.class)
        .setConstraints(new Constraints.Builder()
          .setRequiresStorageNotLow(true)
          .setRequiresBatteryNotLow(true)
          .build())
        .setInputData(new Data.Builder()
          .putString(DownloadWorker.KEY_RESULTDIR, "unzipped")
          .build())
        .addTag("unZIP")
        .build();

    WorkManager.getInstance()
      .beginWith(downloadWork)
      .then(unZIPWork)
      .enqueue();

    final LiveData<WorkInfo> liveOpStatus=
      WorkManager.getInstance().getWorkInfoByIdLiveData(unZIPWork.getId());

    liveWorkStatus.addSource(liveOpStatus, workStatus -> {
      liveWorkStatus.setValue(workStatus);

      if (workStatus.getState().isFinished()) {
        liveWorkStatus.removeSource(liveOpStatus);
      }
    });
  }
}

Of note:

In principle, we should be monitoring both requests’ status updates. If the first request fails for some reason (e.g., HTTP 404 error), the second request will never run. We could do that by calling getWorkInfosLiveData() on the WorkContinuation, which returns a LiveData of a list of WorkInfo objects, one for each request in the chain. That significantly increases the complexity of the sample (e.g., what do we do for data binding in this case?), and so we cheat for the sake of brevity.

How Complex Can This Get?

It can get as complicated as you like:

WorkManager.getIntstance().beginWith(lets).then(go).then(crazy).enqueue();

However, while WorkManager is useful for deferrable tasks, it is not a full workflow system:

As a result, at least for the time being, be careful when trying to create complex WorkRequest chains.


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.