
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.gradlefiles. These are executed againstProjectobjects; - settings scripts in
settings.gradlefiles, executed againstSettingsobject; - init scripts used for global configuration (executed against
Gradleinstance).
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
projectwill refer to whatever subproject we’re currently interested in, androot projectwill 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
Settingsinstance - 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
macportsorhomebrew, 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
Projectinstances 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:appand notapp(although in this caseappwould work as well)It’s also good practice to include
rootProject.name = <<name>>insettings.gradlefile. 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.applicationplugin 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.gradleinstead. 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 onProjectinstance, and passed closure is executed against ScriptHandler objectrepositories(Closure)is called onScriptHandlerinstance, 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 withinRepositoryHandlerclasspath(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
DependencyHandlercode, you’ll notice there’s noclasspathmethod. 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.xmlfile, and alocal.propertiesfile withsdk.dirproperty (orANDROID_HOMEenvironment 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
methodMissingdeclared 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
0arguments, and - if there exists
configuration(*) with the name of that method, then - depending on number of parameters and their type, call
doAddmethod with relevant parameters.
(*) Each plugin can add configurations to dependencies handler. For example
javaplugin definescompile,compileClasspath,testCompileand some other configurations, specified here. Android plugin on the other hand addsannotationProcessorconfiguration, as well as<variantCompile,variantTestCompileetc., 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:
NamedDomainObjectContainercaptures called method’s name,- creates
ProductFlavorDslobject, - configures it against given closure,
- stores mapping from method name to newly configured
ProductFlavorDslobject
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!