Better Programming

Advice for programmers.

Follow publication

Understanding Gradle Tasks

Learn how to create, use, and see the outcomes of tasks, and more

Dmitrii Leonov
Better Programming
Published in
6 min readNov 16, 2022

image by author

You may rarely deal with Gradle Tasks , and most of the time, new Tasks are added either as a part of a plugin or as a copy-pasted piece of code from libraries’ “How to start” guide. Having no understanding of how Tasks work, their structure, and their lifecycle makes developers more likely to stay away from adding any changes to those tasks, even if there is room for improvement.

In this article, let’s try to understand what are Gradle Tasks, how they are created and used, and what forms can they take.

Basic things you should know about a Task:

  • A Task is an executable piece of code that contains sequences of actions.
  • Actions are added to a Task via the doFirst{} and doLast{} closures.
  • A list of available tasks can be accessed by executing ./gradlew tasks.

To better understand the structure, syntax, and relationship between Gradle project entities, look at my earlier article.

Further in the article, given you are testing code in an Android project or any other project with Gradle wrapper in it, executing task X means running ./gradlew X on Mac or gradlew.bat X on Windows.

What Do Tasks Look Like?

As you may already know, in Gradle, things come in many different forms, and Tasks are no exception. Task definition can be done in many different ways, most commonly in one like this:

code snippet #1

When running the taskName1 from above, the output is not so obvious:

> Configure project :app
Why is this printed first?
> Task :app:taskName
First?
Last?

But wait, it gets more confusing. Let’s add an alternative and more explicit form of the same task, just named differently:

code snippet #2

The code above is more descriptive and speaks for itself. As you can see, we are calling create() on the project’s TaskContainer object tasks, after which we configure a group property of the newly created Task and add actions to the list.

Let’s execute our taskName1 once again and look at the output:

> Configure project :app
Why is this printed first?
Why is this printed first?
> Task :app:taskName
First?
Last?

As you can see, “Why is this printed first?” is printed twice. So why is it happening, and where is it coming from?

Build lifecycle and phases

Unlike the tasks declared above, most tasks depend on each other. To execute a task, Gradle has to understand the tasks available in the project and what dependencies the task has. For that, Gradle creates a directed acyclic dependency graph and executes tasks according to it.

Do not get shocked by the term directed acyclic dependency graph. All it means is that:

  • Tasks and their dependencies are composed into a graph structure where nodes represent tasks, and vertices/lines represent dependencies.
  • Directions of the vertices represent how one task depends on other tasks.
  • Acyclic means that there are no such tasks A and B where both are dependent on each other directly or transitively.

There are three build phases:

  • Initialization — starts with creating a Settings object according to settings.gradle file and builds a hierarchy of sub-projects (in Android Studio referred to as modules) included in the Gradle project.
  • Configuration — configures each project discovered in the initialization phase, then goes to respective build.gradle files and configures Project instances and builds a graph of tasks on which the targeted task depends directly or transitively.
  • Execution — executes a task and all the tasks depend on the task executed, which is known from the configuration phase.

Knowing build phases, we can now understand that, although we do not execute taskName2 directly, the code inside the configure{} closure is still executed during the configuration phase, which causes Why is this printed first? to appear twice.

Can this be avoided?

Yes, for that, Gradle has Configuration Avoidance API. It is a recommended method for creating tasks that help to reduce configuration time by avoiding working with Task instances directly and instead doing it using TaskProvider, and creating a reference to a Task.

code snipped #3

Using TaskContainer.register() will prevent a task from being included in the configuration phase unless the registered task is executed directly or is included in the dependency graph of the task being executed.

Try running taskName1 once again and see that the output is the same as it was before adding the taskName3. At the same time, running the taskName3 adds one more line to the configuration part of the logs because now it is included in the configuration phase together with taskName2 and taskName1

> Configure project :app
Why is this printed first?
Why is this printed first?
Why is this printed first?
> Task :app:taskName
First?
Last?

Why do we have doFirst and doLast?

Why is simply putting Actions in a correct sequence of execution not enough, and why do we need doFirst{} and doLast{}?

For a moment, treat these closures as something to execute before and after X. What is X, then?

To answer that, let’s define a simple task class and run a task of the newly created task class, demonstrating one more way a task can be defined.

code snippet #4

Putting the configuration-related part of logs aside, the output for both of the tasks of the newly defined type will be:

> Task :app:taskName5
First?
Before and after actions annotated with @TaskAction
Last?

So the Actions supplied todoFirst{} and doLast{} are executed before and after the action(s) annotated with @TaskAction.

As you can see from code snippet #4, the CustomTaskType extends DefaultTask class, a basic class you can extend to implement a custom task class. Gradle has several useful, ready-to-use task types that you can find in Gradle‘s Github.

What else to know about Gradle Tasks?

Tasks have outcomes that indicate what happened with the tasks during the build process. Intuitively, you may guess that a task can have three outcomes — not executed, executed using cached results, and just executed.

In Gradle, there are five task outcomes:

  1. NO-SOURCE — a task is unexecuted because the input data required for its execution was not found. An example of an input can be a file annotated with @InputFiles @SkipWhenEmpty and @Incremental that failed to be produced by a prior task.
  2. SKIPPED — skipped for some reason. Such reason can be — a task marked as enabled = false in the task’s body or excluded from the execution process via command line argument -x and a few others.
  3. UP-TO-DATE — a task result has not changed since the last build and can be reused. This happens as a part of the Incremental build feature.
  4. FROM-CACHE — the task can be taken from the previous builds. It uses a feature called Task output caching. It’s an advancement of Incremental build used in UP-TO-DATE as it can reuse remote caches by getting them from CI. Unless you have org.gradle.caching=true in gradle.properties or you use --build-cache flag when executing a task, this is not applied to your builds. For a task to be catchable, it should be annotated as @CacheableTask.
  5. EXECUTED — the task is executed successfully. This label is not displayed in logs.

To make these outcomes visible, use a flag --console=plain, for example, in an Android project, you can use it on assembleDebug:

./gradlew assembleDebug --console=plain

Ideally, a build should have as many UP-TO-DATE and FROM-CACHE to have a faster execution time.

Task ordering and dependencies

It has been mentioned that a Task can depend on other tasks, but what does it look like in code? These indicators below denote that tasks are dependent on one another:

Explicitly define relationships between two tasks:

  • dependsOntask X { dependsOn Y }, task X requires task Y for its execution, and if Y fails to execute, the execution of X will not happen.
  • finalizedBytask X { finalizedBy Y } task Y will be executed after task X even if the X failed to execute or was skipped.

Input and output annotations:

  • @OutputFile and @InputFile— is an implicit way to create a dependency by annotating the inputs and outputs of tasks. This approach requires configuring tasks that have matching inputs and outputs.

Conclusion

Tasks can be defined in many ways, but not all are equally good. For the configuration time, utilize Configuration avoidance API and register tasks using TaskContainer.register().

It is good to understand the Task outcomes to identify weak points of the build and try to cache task execution results where possible by properly structuring dependencies between tasks and putting Incremental build and Task output caching mechanisms to work.

Want to Connect?

Connect with me on Twitter and LinkedIn.

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Dmitrii Leonov
Dmitrii Leonov

Written by Dmitrii Leonov

Android Developer | Getting better at my craft by sharing knowledge!

Responses (3)