This is the third part of an undecided number of posts about creating a Gradle plugin for Android. Part one can be found here and Part two can be found here.

For those of you that are asking "Part 2? I've been on tenterhooks waiting for part 2 and you're telling me it's been out for ages and I missed it? Why didn't you tell me!??!" Well I managed to choose the day that Google released the 23.2 version of the Support Library to post it, so people were a bit pre-occupied. Ho hum! At least I didn't choose the day that the 'N' preview was released to post part 3.

At the end of the previous part I alluded - well virtually promised - that the next part would deal with taking the hard-coded stuff out of the code and into the build.gradle file. So I'd better keep that promise.

As before to make the code listings below a little shorter and more concise I haven't included the package definition or the imports. And I'm going to assume that you've done parts one and two, or at least read them.

Step 0 - Three little words

There are three things in the plugin that are hard-coded at the moment that could do with being less hard; the filename of the words, the name of the enum class, and the package that the enum class goes into.

Let's pull them out into our build.gradle into a section called bpconfig. Add the following to the build.gradle of your test app that is using the plugin:

bpplugin {
    words "plugin_words.txt"
    enumClass "WordsEnum"
    outputPackage "com.afterecho.android.util"
}

Android Studio will prompt you to re-sync your project after making the change. Try it. Aaanndd... whoops! The sync fails, complaining about bpplugin not being found. You see you can't put just any old thing in the build.gradle.

Step 1 - Building an extension

Back to IntelliJ and create a new Groovy class BlogPluginExtension. This class needs to have just the three fields to correspond to the three config items we have. All in all it will look like

class BlogPluginExtension {
    String words
    String enumClass
    String outputPackage
}

To hook it into our plugin, we create an extension. In the BlogPlugin class add this as the first line of the apply() method.

target.extensions.create('bpplugin', BlogPluginExtension)

Surprisingly enough this creates an extension block called bpplugin within the project and Gragle maps the values from the build.gradle into the fields of BlogPluginExtension. Neat huh?

A quick uploadArchives and now, when you sync your test project, there are no complaints. We have successfully added an extension block. But we're not actually using it yet.

Step 2 - Making use of it

After the target.extensions.create line you added to the apply() method, add some debug lines

println target.extensions.bpplugin.words
println target.extensions.bpplugin.enumClass
println target.extensions.bpplugin.outputPackage

Do the uploadArchives and sync dance and observe the output in the Gradle console windows. Do you see what I see? "null", "null" and "null". Hmmm. Why is that?

At this point I defer to the magnificent Dan Lew and his post Lessons learned from our first Gradle plugin for Android, Victor and the section Evaluating Extensions

Excellent. Thanks Dan. So we sort this out by simply wrapping almost everything in our apply() method in an afterEvaluate so it looks a little something like

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
    void apply(Project target) {
    target.extensions.create('bpplugin', BlogPluginExtension)

    target.afterEvaluate {

        println target.extensions.bpplugin.words
        println target.extensions.bpplugin.enumClass
        println target.extensions.bpplugin.outputPackage

        target.tasks.create(name: "showDevices", type: ShowDevicesTask)

        target.android.applicationVariants.all { variant ->
            File inputWordFile = new File(target.projectDir, "plugin_words.txt")
            File outputDir = new File(target.buildDir, "generated/source/wordsToEnum/${variant.dirName}")
            def task = target.tasks.create(name: "wordsToEnum${variant.name.capitalize()}", type: WordsToEnumTask) {
                outDir = outputDir
                wordsFile = inputWordFile
            }
            variant.registerJavaGeneratingTask task, outputDir
        }
    }

(Note that the showDevices task also got moved into the afterEvaluate block. As this task doesn't have any config there's no need for it to. If you feel that the world would be a brighter and happier place if it was not in afterEvaluate then please feel free to move it.)

After a quick uploadArchives and sync and those pesky nulls have vanished. From here it's a quick job to replace the hard-coded filename with the one from the extension in the apply() method. And the class and package name in the plugin? Well remember from part 2 where we moved the adb devices task into it's own class? We can access the Project via the project property of our Task, so we just replace the hard-coded values in there too.

So we end up with our Plugin looking like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class BlogPlugin implements Plugin<Project> {

    @Override
    void apply(Project target) {
        target.extensions.create('bpplugin', BlogPluginExtension)

        target.afterEvaluate {
            target.tasks.create(name: "showDevices", type: ShowDevicesTask)

            target.android.applicationVariants.all { variant ->
                File inputWordFile = new File(target.projectDir, target.extensions.bpplugin.words)
                File outputDir = new File(target.buildDir, "generated/source/wordsToEnum/${variant.dirName}")
                def task = target.tasks.create(name: "wordsToEnum${variant.name.capitalize()}", type: WordsToEnumTask) {
                    outDir = outputDir
                    wordsFile = inputWordFile
                }
                variant.registerJavaGeneratingTask task, outputDir
            }
        }
    }
}

And our Task looking like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class WordsToEnumTask extends DefaultTask {
    String group = "blogplugin"
    String description = "Makes a list of words into an enum"

    @InputFile
    File wordsFile

    @OutputDirectory
    File outDir

    @TaskAction
    def makeWordsIntoEnums() {
        Builder wordsEnumBuilder = enumBuilder(project.extensions.bpplugin.enumClass).addModifiers(Modifier.PUBLIC)
        wordsFile.readLines().each {
            wordsEnumBuilder.addEnumConstant(it).build()
        }
        TypeSpec wordsEnum = wordsEnumBuilder.build();
        JavaFile javaFile = JavaFile.builder(project.extensions.bpplugin.outputPackage, wordsEnum).build();
        javaFile.writeTo(outDir)
    }
}

Step 4 - A little gotcha

After all this you would expect that you can change the contents of the bpplugin block in your test project's build.gradle and it would take effect when you re-sync the project right? Well actually it's not quite that simple.

Recalling part 2, I said

Gradle is clever enough to not do unnecessary work. If the output of a task or the input of a task haven't changed then it won't waste time by running the task again.

Well it turns out that the configuration block in the build.gradle doesn't automatically count toward an input, so changing the values in the configuration won't make Gradle rebuild the output. You can do a clean and then the change will take effect. But I think we can do better than that, don't you?

Instead of pulling the enumClass and outputPackage from the project within the Task, let's pass them in from the Plugin instead. Create two new fields in the Task - enumClassName and outputPackageName and annotate them with @Input. This tells Gradle to consider these as inputs when deciding whether or not to run the task. Change the code to use these fields so the Task now looks like

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class WordsToEnumTask extends DefaultTask {
        String group = "blogplugin"
        String description = "Makes a list of words into an enum"

        @InputFile
        File wordsFile

        @OutputDirectory
        File outDir

        @Input
        String enumClassName

        @Input
        String outputPackageName

        @TaskAction
        def makeWordsIntoEnums() {
            Builder wordsEnumBuilder = enumBuilder(enumClassName).addModifiers(Modifier.PUBLIC)
            wordsFile.readLines().each {
                wordsEnumBuilder.addEnumConstant(it).build()
            }
            TypeSpec wordsEnum = wordsEnumBuilder.build();
            JavaFile javaFile = JavaFile.builder(outputPackageName, wordsEnum).build();
            javaFile.writeTo(outDir)
        }
    }

Modify the Plugin to pass those values in, the same way as we do for the words filename and output directory, so it looks like

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class BlogPlugin implements Plugin<Project> {

    @Override
    void apply(Project target) {
        target.extensions.create('bpplugin', BlogPluginExtension)

        target.afterEvaluate {
            target.tasks.create(name: "showDevices", type: ShowDevicesTask)

            target.android.applicationVariants.all { variant ->
                File inputWordFile = new File(target.projectDir, target.extensions.bpplugin.words)
                File outputDir = new File(target.buildDir, "generated/source/wordsToEnum/${variant.dirName}")
                def task = target.tasks.create(name: "wordsToEnum${variant.name.capitalize()}", type: WordsToEnumTask) {
                    outDir = outputDir
                    wordsFile = inputWordFile
                    enumClassName = target.extensions.bpplugin.enumClass
                    outputPackageName = target.extensions.bpplugin.outputPackage
                }
                variant.registerJavaGeneratingTask task, outputDir
            }
        }
    }
}

Now you can change the values in the bpplugin block of the build.gradle and the change will take effect as soon as you sync the project.

There is one more little gotcha about this though. Changing the inputs doesn't clear out the old outputs. Try changing the enumClass to something else in the build.gradle and syncing your project. If you look in the build/generated/source/wordsToEnum folder you'll find two .java files; one with the new name you set and one with the old name. The simple way to deal with this is to clean your project and rebuild if you change anything in the build.gradle.

Or you could put in a outDir.deleteDir() at the start of your task. :)

So that's it. Another step on the road to Gradle awesomeness. I'd like to thank insomnia, without which this post might not have gotten written until the sun was up, and coffee, without which there would probably be a lot more (or a lot less) typos. Please leave comments - either below or on my Twitter feed - especially if there's anything that makes no sense, is blatantly wrong, or you'd like covered in a later blog post.

I've uploaded the IntelliJ project for these posts to Github. Each part will be in its own branch. This one is at https://github.com/afterecho/gradle-plugin-tutorial/tree/part-three

Darren @ Æ


Comments

comments powered by Disqus