Understanding Android Gradle build files

Taking the magic away

Łukasz Wasylkowski
8 min readFeb 1, 2017

For most purposes we don’t need to modify our *.gradle files much — we add dependencies, modify target and minimum API levels, maybe set up signing configs and build types. Anything more complicated than that, and we end up copy-pasting mysterious snippets from Stack Overflow, without understanding how they work. In this post we’ll write, step by step, Gradle configuration files for a single Android project in order to take some of the magic away.

Groovy

Syntax

Gradle files are basically Groovy scripts. Syntax is easy to grasp if you know Java, and for us it’s important that:

  • there’s no need for parentheses when calling methods with at least one parameter (if it’s unambiguous):
  • if the last parameter is a closure (for now think lambda), it can be written outside the parentheses:
  • If you invoke Groovy method with named parameters, they are converted into a map and passed as first argument of the method. Other (non-named arguments) are then appended to parameters list:

This will print "John is 24 years old" followed by John works as Android developer at Tooploox. Mind that in both cases the result will be the same regardless of parameters order! Also notice omitted parentheses in both calls.

Closures

One important feature that needs some explaining are closures. If you’re familiar with Kotlin, you might find below explanation somewhat similar to function literals with receiver.

Closures in Groovy can be thought of as lambdas on steroids. They’re blocks of code that can be executed, can have parameters, and return values. What’s different is that we can change the delegate of a closure. Let’s consider following code:

We can see that printClosure calls printText method on the delegate it is provided (the same goes for properties). We will see later why this is crucial in Gradle.

There’s actually a bit more to delegates and how closure’s statements are executed. You can read more about delegation and delegation strategies in Groovy documentation.

Gradle

Script files

There are three main script files that Gradle uses. Each one is a block of code (closure, anyone?) executed against various objects:

  • build scripts in build.gradle files. These are executed against Project objects;
  • settings scripts in settings.gradle files, executed against Settings object;
  • init scripts used for global configuration (executed against Gradle instance).

Projects

Gradle build consists of one or more projects, and projects consist of tasks. There is always at least the root project, which may contain subprojects, which in turn can have nested subprojects as well. Common convention is that the root project’s role is only to orchestrate group projects, provide common configuration, plugins classpaths etc.

From now on project will refer to whatever subproject we’re currently interested in, and root project will be used when referring to root project specifically.

Creating Gradle-based Android project

In typical Android project we have the following folder structure:

├── settings.gradle # [1]
├── build.gradle # [2]
├── gradle
│ └── wrapper
└── app
├── gradle.properties # [3]
├── build.gradle # [4]
└── src
  1. This is root project’s settings file, executed against its Settings instance
  2. Root project’s build configuration
  3. App project’s properties file, injected into app’s Settings
  4. App project’s build configuration

Let’s go step by step, then.

Creating a Gradle project

Let’s create new folder, say example. If we cd into it and execute gradle projects, we can see that it’s already a Gradle project!

$ gradle projects
:projects
------------------------------------------------------------
Root project
------------------------------------------------------------
Root project 'example'
No sub-projects
To see a list of the tasks of a project, run gradle <project-path>:tasks
For example, try running gradle :tasks
BUILD SUCCESSFULTotal time: 0.741 secs

If you don’t have Gradle installed locally, you can install it using macports or homebrew, or download an installer from official webpage. You can also create a new project in Android Studio and remove everything except for Gradle wrapper.

Setting up projects hierarchy

If we want similar structure to a default Android project (empty root project and an app project with our application), we need a settings.gradle file. From the documentation we know that settings.gradle script:

declares the configuration required to instantiate and configure the hierarchy of Project instances which are to participate in a build.

Further we read that we can add projects to the build using void include(String[] projectPaths) method. Let’s add a app subproject then:

$ echo "include ':app'" > settings.gradle
$ gradle projects
# ...------------------------------------------------------------
Root project
------------------------------------------------------------

Root project 'example'
\--- Project ':app'

...

BUILD SUCCESSFUL

Total time: 0.666 secs

Colon (:) is used in Gradle to separate paths to subprojects, what we can see here. That’s why we write :app and not app (although in this case app would work as well)

It’s also good practice to include rootProject.name = <<name>> in settings.gradle file. Without it, root project’s name defaults to the name of the folder in which the project is, which may be different for example on a CI server.

Setting up Android subproject

Now we’d normally set up root project’s build.gradle file, but what do we need to put there? Let’s find out by trying to set up an Android project instead.

From the user guide we know that we need to apply com.android.application plugin to our app project. Let’s have a look at apply method signatures:

void apply(Closure closure)
void apply(Map<String, ?> options)
void apply(Action<? super ObjectConfigurationAction> action)

While the third one is the one that’s important — it uses statically typed API — we usually only use the second one, as it takes advantage of feature that we’ve mentioned before — named parameters are passed to the method as a map. To know what keys (parameters names) we can use, we peek into the documentation:

void apply(Map<String, ?> options)

The following options are available:
from: A script to apply. (…)
plugin: The id or implementation class of the plugin to apply.
to: The target delegate object or objects. (…)

We now know we need to pass our plugin id as plugin parameter. We could write apply(plugin: 'com.android.application'), but we also know we can omit parentheses if the invocation is non-ambiguous, which it is. Let’s add apply plugin: ‘com.android.application’ to app’s build.gradle file, then:

$ echo "apply plugin: 'com.android.application'" > app/build.gradle

What now?

$ gradle app:tasksFAILURE: Build failed with an exception.

* Where:
Build file '(...)/example/app/build.gradle' line: 1

* What went wrong:
A problem occurred evaluating project ':app'.
> Plugin with id 'com.android.application' not found.

BUILD FAILED

Total time: 0.69 secs

Okay then, there’s no com.android.application plugin defined. Well, we’re not surprised — how would Gradle find Android plugin’s jar file? We can see in the user guide we need to add plugin’s classpath, and the repository in which it can be found.

Currently we can configure this classpath either in app’s or in root project’s build.gradle file, because buildscript closure is executed against ScriptHandler, which subprojects also use. This is not recommended though — all plugin dependencies should be declared at root project’s build.gradle instead. Let’s put buildscript block there, then, and discuss what it does:

If we add parentheses in our heads, we see all of these are simple method calls, of which some pass Closure as a parameter. If we then dig into the documentation, we read what objects these closures are executed against. In summary:

  • buildscript(Closure) is called on Project instance, and passed closure is executed against ScriptHandler object
  • repositories(Closure) is called on ScriptHandler instance, while passed closure is executed against RepositoryHandler
  • dependencies(Closure) is also called on ScriptHandler, but its argument is executed against DependencyHandler

Which means that:

  • jcenter() is called within RepositoryHandler
  • classpath(String) is called on DependencyHandler (*)

We only need to know that the first call — buildscript — is executed against Project instance. For the rest, the documentation specifies the delegates explicitly.

(*) If you inspect DependencyHandler code, you’ll notice there’s no classpath method. This is a special type of call, which we’ll discuss later on with dependencies.

Configuring Android subproject

If we now try to execute some Gradle task, we’ll be greeted with an error:

$ gradle projectsFAILURE: Build failed with an exception.

* What went wrong:
A problem occurred configuring project ':app'.
> buildToolsVersion is not specified.

Obviously, we haven’t put any Android-related configuration yet, but we can see that Android plugin is now applied correctly! Let’s add some configuration:

We already see what’s happening — somehow there’s android method added to theProject instance, that delegates whatever closure it’s passed to some object (AppExtension in this case), which has buildToolsVersion and compileSdkVersion methods defined. This way Android plugin receives all configurations passed, including default configuration, flavors etc.

In order to run any tasks now we still need two things — AndroidManifest.xml file, and a local.properties file with sdk.dir property (or ANDROID_HOME environment variable) pointing to Android SDK location on our machine.

Extensions

But how did android method suddenly appear in the Project instance, against which our build.gradle is executed? In short, Android plugin registered AppExtension class as an extension with android name. This goes out of scope of this post, but what’s important for us is that Gradle adds configuration closure block for each extension object registered by the plugins.

Dependencies

There’s one last block that’s always there, that haven’t been discussed yet - dependencies. Here’s an example:

Why is this block special? Well, if you look into DependencyHandler, to which dependencies method delegates the passed closure, you’ll see there’s no compile method on it, nor testCompile or any of those we usually use. Which makes sense — if we add free flavor, we can write freeCompile 'somelib'DependencyHandler can’t define methods for all possible flavors now, can it? Instead, it uses another feature of Groovy language — methodMissing, which allows for catching calls to undefined methods in runtime (*).

(*) Actually Gradle uses abstraction over methodMissing declared in MethodMixIn, but the effect is mostly the same. Similar mechanism can also be applied to undefined properties.

The relevant fragment of the default dependency handler implementation can be found here, and it does the following:

  • If any undefined method is called with more than 0 arguments, and
  • if there exists configuration(*) with the name of that method, then
  • depending on number of parameters and their type, call doAdd method with relevant parameters.

(*) Each plugin can add configurations to dependencies handler. For example java plugin defines compile, compileClasspath, testCompile and some other configurations, specified here. Android plugin on the other hand adds annotationProcessor configuration, as well as <variant>Compile, <variant>TestCompile etc., based on defined build types and product flavors.

While doAdd method is private, it’s being called by add method which is public. Thus, we could(*) rewrite above dependencies block as:

(*) But please, don’t do that.

Flavors, build types, signing configs

Let’s consider this piece of code:

What does productFlavors method delegate to? If we look into source code, productFlavors is declared like so:

Action<T> in Gradle world is a closure executed against T

So, here we have some NamedDomainObjectContainer which creates and configures objects of type ProductFlavorDsl and stores them alongside their names.

This container also uses dynamic method dispatch to create an object of a given type (here ProductFlavorDsl) and put it into the container along with its (method) name. So if we call method prod with parameter {}, it’s executed against productFlavors instance, which is NamedDomainObjectContainer. Here’s what happens:

  • NamedDomainObjectContainer captures called method’s name,
  • creates ProductFlavorDsl object,
  • configures it against given closure,
  • stores mapping from method name to newly configured ProductFlavorDsl object

We (and Android plugin) can then retrieve ProductFlavorDsl objects from productFlavors. What’s important, we can access them as properties, so in our case we can write productFlavors.dev, and we’ll retrieve ProductFlavorDsl that we’ve put with dev name. This is why we can write signingConfig signingConfigs.debug for example.

Summary

Gradle files are ubiquitous for Android developers, yet they’re often treated as a necessary evil, or at least as a magic black box that does things. But while there’s lots of conventions when it comes to writing Gradle scripts, and Gradle itself adds some complexity over Groovy language, when we get to know both, Gradle files aren’t that magical. I hope after reading this post and applying some curiosity, even that obscure code pasted from Stack Overflow will start to make sense now!

P.S. Many thanks to Stefan Oehme and Android team at Tooploox for valuable input and proof-reading this article!

--

--