This post originally appeared on Medium.
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 againstProject
objects; - settings scripts in
settings.gradle
files, executed againstSettings
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, androot 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:
- This is root project’s settings file, executed against its
Settings
instance - Root project’s build configuration
- App *project’s properties file, injected into *app’s
Settings
- 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!
If you don’t have Gradle installed locally, you can install it using
macports
orhomebrew
, 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 an app
subproject then:
Colon (
:
) is used in Gradle to separate paths to subprojects, what we can see here. That’s why we write:app
and notapp
(although in this caseapp
would work as well)It’s also good practice to include
rootProject.name = <<name>>
insettings.gradle
file. Without it, root project’s name defaults to the name of the folder in which the project resides, 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 project. Let’s have a look at apply
method signatures:
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:
What now?
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 onProject
instance, and passed closure is executed against ScriptHandler objectrepositories(Closure)
is called onScriptHandler
instance, while passed closure is executed against RepositoryHandlerdependencies(Closure)
is also called onScriptHandler
, but its argument is executed against DependencyHandler
Which means that:
jcenter()
is called withinRepositoryHandler
classpath(String)
is called onDependencyHandler
(*)
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 noclasspath
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:
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 the Project
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 alocal.properties
file withsdk.dir
property (orANDROID_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 inMethodMixIn
, 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 definescompile
,compileClasspath
,testCompile
and some other configurations, specified here. Android plugin on the other hand addsannotationProcessor
configuration, as well as<variantCompile
,variantTestCompile
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, is declared like so:
Action<T>
in Gradle world is a closure executed againstT
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 here at Tooploox for valuable input and proof-reading this article!