Chained Work
Where WorkManager
shines 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. 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(getApplicationContext())
.enqueue(request);
is really this:
WorkManager.getInstance(getApplicationContext())
.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(getApplicationContext())
.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:
- If this request has another request chained after it, that later request receives the earlier request’s output data as input.
- The output data is available from the
WorkInfo
once the work is finished, so consumers of theLiveData
status stream can also see the output data.
OK, Where’s the Code?
The UnZIPWork
sample module in the Sampler
and SamplerJ
projects are 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:
- Rather than receiving a filename as input, it decides what the filename will be, as that will merely serve as a temporary file
- It passes the path to that file to the next request in the chain via
setOutputData()
package com.commonsware.jetpack.work.download;
import android.content.Context;
import android.util.Log;
import java.io.File;
import java.io.IOException;
import androidx.annotation.NonNull;
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());
}
}
package com.commonsware.jetpack.work.download
import android.content.Context
import android.util.Log
import androidx.work.Data
import androidx.work.Worker
import androidx.work.WorkerParameters
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.buffer
import okio.sink
import java.io.File
import java.io.IOException
class DownloadWorker(context: Context, workerParams: WorkerParameters) :
Worker(context, workerParams) {
override fun doWork(): Result {
val client = OkHttpClient()
val request = Request.Builder()
.url(inputData.getString(KEY_URL)!!)
.build()
val dir = applicationContext.cacheDir
val downloadedFile = File(dir, "temp.zip")
if (downloadedFile.exists()) {
downloadedFile.delete()
}
try {
client.newCall(request).execute().use { response ->
val sink = downloadedFile.sink().buffer()
response.body?.let { sink.writeAll(it.source()) }
sink.close()
}
} catch (e: IOException) {
Log.e(javaClass.simpleName, "Exception downloading file", e)
return Result.failure()
}
return Result.success(
Data.Builder()
.putString(UnZIPWorker.KEY_ZIPFILE, downloadedFile.absolutePath)
.build()
)
}
companion object {
const val KEY_URL = "url"
const val KEY_RESULTDIR = "resultDir"
}
}
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 and its ZipUtils.unzip()
method, as that safely handles possibly-malicious ZIP files (e.g., zip bombs):
package com.commonsware.jetpack.work.download;
import android.content.Context;
import android.util.Log;
import com.commonsware.cwac.security.ZipUtils;
import java.io.File;
import androidx.annotation.NonNull;
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();
}
}
package com.commonsware.jetpack.work.download
import android.content.Context
import android.util.Log
import androidx.work.ListenableWorker
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.commonsware.cwac.security.ZipUtils
import java.io.File
class UnZIPWorker(context: Context, workerParams: WorkerParameters) :
Worker(context, workerParams) {
override fun doWork(): Result {
val downloadedFile = File(inputData.getString(KEY_ZIPFILE)!!)
val dir = applicationContext.cacheDir
val resultDirData = inputData.getString(KEY_RESULTDIR)
val resultDir = File(dir, resultDirData ?: "results")
try {
ZipUtils.unzip(downloadedFile, resultDir, 2048, 1024 * 1024 * 16)
downloadedFile.delete()
} catch (e: Exception) {
Log.e(javaClass.simpleName, "Exception unZIPing file", e)
return Result.failure()
}
return Result.success()
}
companion object {
const val KEY_ZIPFILE = "zipFile"
const val KEY_RESULTDIR = "resultDir"
}
}
DownloadViewModel
now sets up a request chain using both worker classes:
package com.commonsware.jetpack.work.download;
import android.app.Application;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
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 AndroidViewModel {
public final MediatorLiveData<WorkInfo> liveWorkStatus=new MediatorLiveData<>();
public DownloadViewModel(@NonNull Application application) {
super(application);
}
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(getApplication())
.beginWith(downloadWork)
.then(unZIPWork)
.enqueue();
final LiveData<WorkInfo> liveOpStatus=
WorkManager.getInstance(getApplication()).getWorkInfoByIdLiveData(unZIPWork.getId());
liveWorkStatus.addSource(liveOpStatus, workStatus -> {
liveWorkStatus.setValue(workStatus);
if (workStatus.getState().isFinished()) {
liveWorkStatus.removeSource(liveOpStatus);
}
});
}
}
package com.commonsware.jetpack.work.download
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
class DownloadViewModel(application: Application) :
AndroidViewModel(application) {
val liveWorkStatus = MediatorLiveData<WorkInfo>()
fun doTheDownload() {
val downloadWork = OneTimeWorkRequest.Builder(DownloadWorker::class.java)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
)
.setInputData(
Data.Builder()
.putString(
DownloadWorker.KEY_URL,
"https://commonsware.com/Android/source_1_0.zip"
)
.build()
)
.addTag("download")
.build()
val unZIPWork = OneTimeWorkRequest.Builder(UnZIPWorker::class.java)
.setConstraints(
Constraints.Builder()
.setRequiresStorageNotLow(true)
.setRequiresBatteryNotLow(true)
.build()
)
.setInputData(
Data.Builder()
.putString(DownloadWorker.KEY_RESULTDIR, "unzipped")
.build()
)
.addTag("unZIP")
.build()
WorkManager.getInstance(getApplication())
.beginWith(downloadWork)
.then(unZIPWork)
.enqueue()
val liveOpStatus = WorkManager.getInstance(getApplication())
.getWorkInfoByIdLiveData(unZIPWork.id)
liveWorkStatus.addSource(liveOpStatus) { workStatus ->
liveWorkStatus.value = workStatus
if (workStatus.state.isFinished) {
liveWorkStatus.removeSource(liveOpStatus)
}
}
}
}
Of note:
-
downloadWork
is defined the same as before, except that we skip supplying the filename, and the URL now points to a ZIP file instead of a PDF -
unZIPWork
does not require an Internet connection, but it does require that we have a reasonable amount of storage available -
unZIPWork
gets the name of a directory to create ingetCacheDir()
to hold the unZIPped results - We use
beginWith()
andthen()
to set up the chain, usingenqueue()
to enqueue the results - We monitor the
unZIPWork
status for the purposes of re-enabling the button and showing theToast
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:
- You can keep chaining work together by successive
then()
calls:
WorkManager.getIntstance(getApplicationContext())
.beginWith(lets)
.then(go)
.then(crazy)
.enqueue();
- You can have parallel requests as part of a chain, by passing multiple
WorkRequest
objects tobeginWith()
orthen()
- You can chain a
WorkContinuation
onto anotherWorkContinuation
- You can create
InputMerger
implementations to help coordinate out the output data from previous steps in the chain are merged together to form the input data for successive steps in the chain - And so on
However, while WorkManager
is useful for deferrable tasks, it is not a full workflow system:
- There is limited ability to cancel work, as noted previously
- There is no ability to change enqueued work, except by trying to cancel it and then enqueuing its replacement
- There are no specifications for how long any individual request or an entire chain can take, in terms of time
- There are no specifications for how results are handled when it takes multiple process invocations to complete a chain (e.g., a long chain extending past the 10-minute limit)
- And so on
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.