Streamline your development process with a convenient Gradle plugin
As your Gradle project grows and you decide to split it up into multiple modules, you will quickly find yourself copying and pasting your build scripts over and over again. You may forget to adjust the dependencies for your newly created module, and you may accidentally include or expose dependencies that you don’t really need.
Wouldn’t it be nice to get rid of all that boilerplate? Wouldn’t it be more convenient to apply all the defaults with just a single line, like this:
plugins {
id("your.org.convenient.plugin")
}
What do I actually mean by defaults?
For Android developers, defaults are something you set up in the android
extension (aka just “block”). In fact, setting up a namespace
for your created library is now required by the Android Gradle Plugin in its newer version. However, as your project grows, you will need to do even more configuration on this extension, such as setting up lint, enabling compose, enabling viewbinding, and so on.
Don’t forget that you may need to use the same Gradle plugins repeatedly. To stick with the Android Developer example, you need to add the Android Gradle Plugin to every single module you create. Of course, this is necessary to set up the android
extension 😉. However, the same configuration rules apply here, as mentioned above. As your project grows, you may need to add more Gradle plugins repeatedly to each module you create.
A typical example of such a repetitive build script might look like this:
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.parcelize")
}
android {
compileSdk = 33
namespace = "your.org.lib.package"
defaultConfig {
minSdk = 26
}
buildFeatures.compose = true
composeOptions {
kotlinCompilerExtensionVersion = "1.4.0"
}
}
In short, you don’t want to repeat yourself. Either in your code or in your build scripts, right? This is where a convenient plugin (we’ll just call it that for now) comes in handy.
Please note that this blog is not just for Android developers. The techniques I describe here can be applied to any Gradle project. However, since I’m an Android developer, I’ve chosen to use the Android Gradle Plugin as an example. In “pure JVM” Gradle projects, you may want to extract other parts, such as setting up JUnit 5 or similar configurations.
Composite builds
Before we dive into the code, let me explain a few things about included builds — also known as composite builds.
When you create new modules for your project, you need to use the include
keyword in your settings.gradle[.kts]
file. This will tell Gradle that the specified path is part of the project. But there is also another keyword, includeBuild
. This will tell Gradle that the project you’re working on includes another standalone Gradle project.
By “standalone”, I mean that this is an almost 100% independent Gradle project. Configurations are not shared between the two projects and each included build is configured and executed in isolation.
These composite builds are compiled and tested etc. before your actual project is compiled and tested (and what not). You could also say that your project depends on the included build.
I am mentioning this in advance because I am utilizing these techniques to develop a convenient plugin. It is important to clarify that the included builds are standalone projects that can potentially be extracted into their own git repositories. By providing this information upfront, you can gain a better understanding of the context and techniques involved.
Last note on composite builds and the
buildSrc
directory:
I know of a few people/companies that use thebuildSrc
directory for similar techniques. Starting with Gradle 8, this is perfectly fine, as the buildSrc directory is now treated more like a “normal” composite build. However, before Gradle 8, this was not the case, and any change to thebuildSrc
directory would result in a rebuild of the entire project. This is no longer the case starting with Gradle 8 and with composite builds in general.
Show me code
Since composite builds are standalone Gradle projects, you need to create a settings.gradle[.kts]
file and a build.gradle[.kts]
file and place them in a directory within your project, such as convenient-plugins
.
While the settings.gradle[.kts]
file can be left empty, the build.gradle[.kts]
file needs some content.
plugins {
kotlin("jvm") version "1.8.10"
`java-gradle-plugin`
}
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
dependencies {
compileOnly("com.android.tools.build:gradle:7.4.0")
compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10")
}
gradlePlugin {
plugins {
register("iokiAndroidLibraryPlugin") {
id = "your.org.convenient.plugin
implementationClass = "your.org.tool.gradle.plugin.AndroidLibraryPlugin"
}
}
}
What does the build script do? First, it applies some plugins that we need to build the convenient plugin. Since we are using Kotlin, we apply the Kotlin Gradle Plugin. The Gradle Plugin Development Plugin (a nice name, isn’t it?) prepares everything for us to create a Gradle plugin. I won’t go into more detail here, but if you want to learn more, check out this link.
Next, we define some repositories where our declared dependencies can be found.
The dependencies
block declares the dependencies that we need at compile time and that consumers need at runtime. Since we want to configure the Android Gradle Plugin and apply the Kotlin Android and the Kotlin Parcelize Plugin, we need those two dependencies. Despite its name, the Kotlin Gradle Plugin actually ships with more than one plugin.
Note on dependency configuration:
I’m not 100% sure which is the correct way to declare these dependencies, whether withcompileOnly
orimplementation
. One could argue thatcompileOnly
doesn’t make sense, since the plugin wouldn’t work if the consumer doesn’t add these as build dependencies. However, since these dependencies will be used inside an Android (Kotlin) project, they will most likely apply the build dependencies anyway. The advantage of usingcompileOnly
would be, that Gradle doesn’t have to detect the higher version and choose the “better” dependency by itself. Instead, we (as plugin authors) can trust that the consumer build dependency will simply work, even if they use a different version.
Does that make sense? 🤔
Finally, with the gradlePlugin
extension — that is provided by the java-gradle-plugin
plugin — you can define the plugins you want to create. Since we only want to provide one plugin to handle Android library projects, we also define only one. Actually, the name in register()
doesn’t matter, it is only needed for internal Gradle behavior. This has something to do with the NamedDomainObjectContainer
. Go ahead and read it if you’re curious, but I will skip it for now. The id
property is the name that you will also use later in your consumer code to apply the plugin. The implementationClass
property defines the fully qualified name of the actual plugin “entry point” / source code.
Having said that, the next step is to create the plugin itself. As defined in the implementatonClass
, we now need a package your.org.tool.gradle.plugin
, and inside it, the class AndroidLibraryPlugin
. This class must implement the Plugin<T>
interface where T
is of type Project
.
So the (first draft) of our implementation looks like this:
import org.gradle.api.Plugin
import org.gradle.api.Project
class AndroidLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) {}
}
Wait, what? A Project
? Isn’t that the same instance we have in our build scripts (build.gradle[.kts]
)? Yes, it is the same instance 🎉
What does that mean?
Well, you can more or less copy and paste the code from your build script into the apply
function. Use target
as “this
“ and generally use it
instead of “this
“ when configuring extensions or calling functions that take an Action<T>
as a parameter.
override fun apply(target: Project) {
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.parcelize")
}
android {
compileSdk = 33
namespace = "your.org.lib.package"
defaultConfig {
minSdk = 26
}
buildFeatures.compose = true
composeOptions {
kotlinCompilerExtensionVersion = "1.4.0"
}
}
}
Even if we replace plugins
with target.plugins
, it is still red. The android
extension is also red and can’t be resolved by your IDE (or compiler, if you tried to compile this code 😁).
The first one is easier to fix. The target.plugins
call returns a PluginContainer
object. This has a function called apply
that takes an id
as a parameter. So the code needs to be swapped out:
target.plugins.apply("com.android.library")
target.plugins.apply("org.jetbrains.kotlin.android")
target.plugins.apply("org.jetbrains.kotlin.plugin.parcelize")
The second problem is less obvious. But wait, haven’t I referred to these as “extensions” before? Maybe, there is something behind target.extensions
? Yes, there is something 🎉 and that will return an ExtensionContainer
.
Okay, we know that the com.android.library
gives us the android
extension. So we could use ExtensionContainer.findByName(“android”)
to configure it. And indeed, this will work. But unfortunately, the function returns a generic Object
(or Any?
in Kotlin). So we can’t do anything useful with it. Fortunately the ExtensionContainer
also has another function called findByType(Class<T>)
. But what is the Class
that we need here? Well, take a closer look at your build.gradle[.kts]
file:
As you can see, the android
extension is a type of LibraryExtension
. So, give this a try:
val android = target.extensions.findByType(LibraryExtension::class.java)!!
with(android) {
compileSdk = 33
namespace = "your.org.lib.package"
defaultConfig {
minSdk = 26
}
buildFeatures.compose = true
composeOptions {
kotlinCompilerExtensionVersion = "1.4.0"
}
}
AAAAAAAAnnnd.. we are done 🎊.
But wait, how do we use this now?
First, you need to include the build into your “main project” by modifying the settings.gradle[.kts]
file to look like this:
pluginManagement {
includeBuild("convenient-plugins")
}
Then you’ll be able to apply our plugin to your build scripts with the following and all the defaults will be applied for you. No need to copy and paste build scripts anymore.
plugins {
id("your.org.convenient.plugin")
}
Some words about naming
In this blog I have used the typical terminology I have heard from the community so far. However, I would like to point out that these are not the “correct” terms that Gradle itself uses in their code and documentation.
For example, what we call a “project” (meaning the software project itself) is called a “build”, or what we call a “multi-module project” is called a “multi-project build”, what we call a “module” is actually a “project”, because each “project” gets its own Project
instance. 😉
I just wanted to share this with you so that you don’t get confused when you read the Gradle documentation and wonder why the same concepts are named differently.
Furthermore, I came up with the name “convention plugin” by myself. There is no “official wording” for that. I just came up with that because I think it makes sense. 🙃