In this post I'm going to explore the world of the transform api, as suggested by Reddit user sahal2080 in this comment

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

Let me get something straight right from the outset; this is not going to be a post about how to manipulate Java class files. Why? Well essentially because I don't know how to. What I'm doing here is introducing the hook by which you can do your own manipulation.

Also an admission. Before I started writing this post I had no clue about the transform api. What you get here is just what I've worked out from faffing about with it. If it works for you...great! If Chet Haase lambasts me and tells you all not to ever, ever do it this way...listen to him.

As in the previous parts, I'm going to make some assumptions. For one I'm going to assume you've read the previous parts. At the very least you need to have the part 3 code open in IntelliJ.

Step 0 - Where it begins

The first bit of trouble I had with the transform api is there's no where that tells you where the bloody thing is. With some guessing and a little investigation of jcenter the magic extra line to add in to the dependencies section of the build.gradle of the plugin is

compile 'com.android.tools.build:transform-api:1.5.0'

Add this in and remember to sync your project.

Perhaps this is a safety thing? Maybe they assume that if you can't find this out for yourself then you shouldn't be even trying this stuff? Who knows? Maybe I'm just not looking in the right places for the documentation.

Step 1 - Transformers - classes in disguise

The transform api page states

To insert a transform into a build, you simply create a new class implementing one of the Transform interfaces, and register it with android.registerTransform(theTransform) or android.registerTransform(theTransform, dependencies).

Looking at the javadoc Transform is an abstract class, not an interface. So create a new groovy class in the plugin project called BlogTransform and make it extend Transform. There are a few Transform classes and the one you want is com.android.build.api.transform.Transform Use IntelliJ to implement the missing methods and you'll end up with something like this:

class BlogTransform extends Transform {
    @Override
    String getName() {
        return null
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return null
    }

    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return null
    }

    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {

    }

Change the implementation of getName() to return a nice name for the transform. In our case as the transform will do nothing of consequence let's make it return the string "BlogDoNothing".

The getInputTypes() should return the InputTypes that the transform will work on. There are two defined; QualifiedContent.DefaultContentType.CLASSES and QualifiedContent.DefaultContentType.RESOURCES

The documentation says that the former "is compiled Java code" and the latter is "standard Java resources". For this example we just need the former, so make the method return

Collections.singleton(QualifiedContent.DefaultContentType.CLASSES)

Similarly there are a number of scopes, such as external libraries, sub-projects, tested code, etc. For this example we are only concerned with our own project so make getScopes() return

Collections.singleton(QualifiedContent.Scope.PROJECT)

To make things simple we will leave inIncremental() returning false. If this is changed then the transform can be called when only part of the build needs updating.

Step 2 - Wiring it up

Before going in to the actual guts and implementing the transform() method, quickly pop back to the BlogPlugin class. To actually register our transformer we add one line to the apply() method:

target.android.registerTransform(new BlogTransform())

Don't put this in the target.afterEvaulate closure. The transform is called for each variant automatically, so there's no need to register a transform per variant.

Step 3 - Setting up

The first parameter of the transform() method is a Context. But this isn't the Context that we all know and love from our beloved Android. This one only has three methods, and for this example don't care about any of them. There's a way to get hold of the LoggingManager, a File object to a directory where we can write temporary files, and the "path" to the name of the task.

Skipping over inputs for a second, we are also not interested in the referencedInputs. If you override the getReferencedScopes() method you can get something here which you might want to refer to for your transforming, but you won't be transforming yourself. Too complicated for us at this point.

Jumping straight to the last parameter - isIncremental - as mentioned above, we are keeping it simple and not concerning ourselves with incremental builds right now.

So that leaves us with two: inputs and outputProvider.

inputs is, quite simply, the thing or things we want to transform. It's a Collection of TransformInput. Each TransformInput has a Collection of DirectoryInput and JarInput. In our trivial case, there appears to only be one TransformInput with one DirectoryInput so that makes it easy.

Calling getFile on the DirectoryInput returns a File object on the directory that holds the compiled class files for our project, which is something like build/intermediates/classes/debug for the debug variant. From here we can traverse the directory structure to access all of our class files.

(As an aside, if we were to implement "incremental" then DirectoryInput has a method - getChangedFiles() which returns us a list of files that have been changed. We can use this to only transform the files that have been updated to save time.)

We don't modify the class files in place. Instead we ask the outputProvider for the location to write our transformed class files. This is done by calling getContentLocation.

The first parameter is a name. As the javadoc says, "For a given set of scopes/types/format it must be unique." We can just put in a simple string here, such as "blogdonothing" as we're only looking at one scope, one type, one format. (Hmm... an idea for a song lyric there.)

The next two parameters we can get from our implementations of getInputTypes and getScopes.

The last parameter determines how our transformed output is stored. There's a choice of two; directory or jar. Let's choose directory.

Step 4 - Do the transform

Now we're ready to go. When transform is called we will have all the compiled class files for our project at our disposal. When we end transform only the classes that are in the output directory will get bundled up into the output APK file. That bears repeating; if you are not transforming a class file you still need to copy it into the output location.

I didn't realise this initially and when I investigated my APK with ClassyShark all of my classes were missing.

For fun let's pretend we're a very simple proguard and we want to strip out the BuildConfig class from our APK. Here is the full BlogTransform class.

 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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class BlogTransform extends Transform {

    @Override
    String getName() {
        return "BlogDoNothing"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return Collections.singleton(QualifiedContent.DefaultContentType.CLASSES)
    }

    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return Collections.singleton(QualifiedContent.Scope.PROJECT)
    }

    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {

        def outDir = outputProvider.getContentLocation("blogdonothing", outputTypes, scopes, Format.DIRECTORY)

        outDir.deleteDir()
        outDir.mkdirs()

        inputs.each {
            it.directoryInputs.each {
                int pathBitLen = it.file.toString().length()
                it.file.traverse {
                    def path = "${it.toString().substring(pathBitLen)}"
                    if (it.isDirectory()) {
                        new File(outDir, path).mkdirs()
                    } else {
                        if (! path.endsWith("BuildConfig.class")) {
                            new File(outDir, path).bytes = it.bytes
                        }
                    }
                }
            }
        }
    }
}

It's not very elegant, but it shows the idea. We start by determining an output directory (line 26) using all the info I mentioned in step 3 (name, type, etc.). Then we delete it and recreate it to make sure there's nothing in it from the last compile (lines 28 and 29).

Next it's time to iterate over the directoryInputs in the inputs (lines 31 and 32). We traverse the directory tree (line 34) and strip off the start of the path to leave us with just the bit from the package name (e.g. com/afterecho/...) (line 35).

If this is a directory then create the same directory in the output location (lines 36 and 37).

If this is a file then copy it as long as it's not called BuildConfig.class (lines 39 and 40).

Step 5 - Wrapping it up

Perform an uploadArchives task and then sync your AndroidStudio project. In the Gradle projects tool window, under "other" there will now be three new tasks: transformClasesWithBlogDoNothingForDebug, transformClasesWithBlogDoNothingForRelease and transformClasesWithBlogDoNothingForDebugAndroidTest

Do an "assembleDebug" and then, from the Project view, look in the app/build/intermediates/transforms directory for a BlogDoNothing. If you dive in there you'll find your class files...minus BuildConfig.class as expected. (The originals are in app/build/intermediates/classes if you want to prove that BuildConfig really does exist.)

If you use ClassyShark to open app/build/outputs/apk/app-debug.apk you can really see that the BuildConfig class is gone. Try changing the condition in the plugin to remove a different class. After you uploadArchives and sync, clean your project and assembleDebug again to see that BuildConfig comes back and whatever class you excluded gets removed. It's important to clean your project if you haven't made any changes to your code as Gradle, helpful as ever, will not call your transform unless something has changed.

So there you have it. A very trivial implementation of the transform api for you to build on. An important thing to note is that although you could add in new classes, methods, etc. at this stage, IntelliJ will not be aware of them (unlike the Words enum from previous posts). So you would only be able to access them through something like reflection. I guess you could have a dummy or stub implementation of a class in your code and replace it with a fully implemented version in a transform during the package assembly. The gradle retrolambda plugin uses the transform to do it's work, replacing the Java 8 bytecode for lambdas etc. with Java 6 or 7 compatible bytecode.

Feel free to leave any comments or let me know if you use what I've written here as the springboard to some wonderful gradle plugin of your own.

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-four

Darren @ Æ


Comments

comments powered by Disqus