Lessons learned from our first Gradle plugin for Android, Victor
The Android team at Trello is happy to announce our first open source Android library, Victor! It's a Gradle plugin that lets you use SVGs as resources in your Android apps.
Victor started as a request from our designers. They were regularly creating vector icons that they would later convert to PNG for Android. Could we cut out the middleman and use the SVGs directly, they asked? Our answer: Victor.
Try out Victor and let us know what you think!
There were quite a few lessons we learned from working on our first Gradle plugin that we’d like to share.
Getting Started
Before we could write any improvements for our build files we had to learn to speak their language, Groovy. And Gradle. And the Android plugin on top of that. If that sounds like a lot, it is. Here are some resources that helped:
-
Groovy - The documentation, which has a bunch of guides (most importantly the differences between Java and Groovy).
-
Gradle - The user guide, the DSL reference, and the samples provided in their releases were all crucial to our understanding.
-
Android plugin - There is a plugin language reference, though often I went to the source code itself to learn more.
Evaluating Extensions
Victor comes with a victor {}
closure which lets you configure the plugin. This is known as an extension. While it makes end-user configuration easy, it proved tricky to use due to the order of execution.
Roughly, these are the steps for adding Victor to a project:
- Use
project.apply()
to add the Victor plugin. - The plugin adds a configurable extension,
victor {}
. - The plugin configures its tasks with variables from the extension.
- End-users can now create their own
victor {}
configuration.
This flow creates a catch-22 because we have to apply the plugin all at once. Users can't create the victor {}
block until the plugin is applied, but then that means the plugin can't use that information until after the plugin is done with configuration.
The solution to this problem is project.afterEvaluate
. The code inside the closure is executed after the inital evaluation of the project, so you can use variables from the extension. Here's a rough sketch of how it looks in action:
class VictorPlugin implements Plugin<Project> {
void apply(Project project) {
// Create our extension that users can configure
project.extensions.create('victor', VictorPluginExtension)
project.afterEvaluate {
// Access extension variables here, now that they are set
}
}
}
Relative Paths
Build processes naturally involve lots of files; Victor especially so, because it generates PNG resources based on the SVGs provided. Where do you put all those files?
At first, all our file paths were relative to the working directory. This was bad news; the working directory can change depending on a variety of factors which meant our plugin was very brittle.
We eventually learned of the existence of three project variables that make things so much easier: buildDir
, projectDir
and rootDir
. Use these if you want to write consistent Gradle code that accesses files .
Adding Resources
Victor adds new resources to the project - but how? Our initial attempt was to create a task that put generated files into the build directory that the Android plugin uses. This solution had two big problems:
- How do we ensure this task is run at the right time in the build process?
- How do we ensure the output files are actually used by the Android plugin?
To solve these problems, you have to know about application variants. The Android plugin creates one variant per combination of build type and product flavor. (By default, your project has two variants: debug and release.) You can access the list of these variants in android.applicationVariants
.
These application variants received a new method in recent versions of the Android plugin: registerResGeneratingTask()
. This tells the Android plugin that we want to add new resources when compiling that variant. It ensures that the resource generating task is run at the correct time in the Android build process. It also tells the Android plugin where those generated resources will be located.
In the end, our code looked something like this:
project.android.applicationVariants.all { variant ->
File outputDir = project.file("$project.buildDir/some/path")
Task victorTask = /* ...Setup the task here... */
variant.registerResGeneratingTask(victorTask, outputDir)
}
Avoiding Extra Work
Gradle tasks should only execute when necessary to avoid inflating your build time.
Unfortunately, custom tasks are always executed since Gradle doesn't know whether they are up-to-date or not. Luckily, you can tell the task your inputs/outputs and Gradle can automatically detect if any of them have changed since the last build.
Here's how Victor's task looks with its inputs and outputs annotated:
class RasterizeTask extends DefaultTask {
@InputFiles
FileCollection sources
@OutputDirectory
File outputDir
@Input
List<Density> includeDensities
@Input
int baseDpi
}
If any of these inputs/outputs change (either their values or the content of their files), then the task is run again. But if nothing is different, we'll skip the work!
We took it one step further with Victor and made it an incremental task. That means that we only need to do work for the subset of files that have changed each build, further improving build times when you only modify a couple files.
Testable Code
Testing the plugin was a key concern, not only because it helps ensure correct output, but also because it is a faster way of developing new code.
Unfortunately, we found testing Gradle plugins to be a huge pain. It is quite difficult to test the plugin's task configuration outside of an actual project.
To get around this problem we split our code into two parts:
- Gradle-specific code (plugin and tasks).
- Everything else.
While the first part is still difficult to test, the second half is now free to be tested in any way you please. In Victor, the actual rasterization code takes place separately and could be run as an independent library. The Gradle tasks themselves simply call into that code.
No Project is an Island
There's no need to go it alone when taking on a new project! We benefited greatly from the wisdom of others when working on this library and wanted to thank them for their help with writing this plugin:
- Xavier Ducrohet, for many things, especially pointing me towards
registerResGeneratingTask()
. - Jake Ouellette and Jake Wharton, for helping fill in the gaps in my Gradle knowledge.
Thanks!
This article was originally posted on the Trello engineering blog and has been reproduced here for posterity.