Android Annotation Processing: POJO string generator

TL;DR###

If you want a thorough introduction to the details of Annotation Processing, you can read Hans Dorfmann's Introduction and try to get his Maven projects working for Android Studio. Or, you can fork my gradle project and start tinkering immediately in Android Studio.

Introduction###

Annotation processing lets you generate new Java files at build time. This is a useful technique for reducing boilerplate. There is no need to use reflection. Although the generated Java files can use reflection for performance insensitive use cases, if you wish.

In this article, I discuss my sample Android Studio annotation processing project on GitHub that helps implement a simple Object#toString() helper annotation. This project may prove worthwhile, since I wasn't able to find an existing satisfactory Android Studio project for annotation processing.

Project Setup###

When writing an annotation processor, you almost always want three modules.

  1. app module. You'll want to write an Android app to exercise your annotation processor.
  2. api module. This defines your new annotations that can be used by the app module. This is included in the app and compiler binaries.
  3. compiler module. This module's classes are not included in the apps build. Instead, it is used during apps build process. It examines all uses of our annotations in app and generates new Java files that get get included in apps build.

Since the compiler module isn't included in your Android application, you can use libraries like quava without worrying about memory efficiency.

If you don't find gradle configuration exciting, skip the rest of this section. Fork my sample project instead of setting up your project from scratch in order to start tinkering faster.

Unlike IntelliJ, Android Studio doesn't have built in support for configuring annotation processing. Therefore, we need to use the android-apt gradle plugin so that we can configure the compiler module as a build time dependency without including it in the final apk. Additionally, this also sets up the output directory for generated java files (otherwise we could just use the standard provided scope).

Add the following to your project's build.gradle file, in the buildScript dependency section so we can use android-apt.

classpath 'com.neenbedankt.gradle.plugins:android-apt:1.4'

The app's build.gradle looks like the following. Notice the usage of android-apt.

apply plugin: 'com.android.application'
apply plugin: 'com.neenbedankt.android-apt'
...
dependencies {
    compile project(':api')
    apt project(':compiler')
}

The compiler's build.gradle looks like the following. Notice the following

  • This uses the java plugin instead of the Android plugin. After all, this code is going to run on your laptop. Not on an Android phone.
  • Notice the use of JavaVersion.VERSION_1_7. Android Studio may hint you should remove these lines. However, these lines are instrumental to ensure this project isn't compiled as a Java 8 library during exporting to Bintray. And Android Studio doesn't handle Java 8 libraries properly. I encountered frustrating problems without these lines.
apply plugin: 'java'
sourceCompatibility = JavaVersion.VERSION_1_7
targetCompatibility = JavaVersion.VERSION_1_7
dependencies {
  compile project (':api')
  compile 'com.google.auto.service:auto-service:1.0-rc2'
  compile 'com.squareup:javapoet:1.0.0'
}

The compiler's build.gradle depends on a few open source utilities and the api module.

Since the compiler imports the api, the api's build.gradle, displayed below, is also distributed as a java plugin.

apply plugin: 'java'
sourceCompatibility = JavaVersion.VERSION_1_7
targetCompatibility = JavaVersion.VERSION_1_7

If you look at the sample project. You'll see a bit more code inside the gradle files. This is for uploading the project to Bintray. I'll cover this more in the [Publishing to JCenter](TODO: set fragment link) section.

Writing an Annotation Processor###

The first step when implementing an annotation processor is deciding what annotation you are going to support. I've defined the following annotation inside the api module.

/**
 * Create a function in {@link StringUtil} for creating strings.
 */
@Target(value = TYPE)
public @interface StaticStringUtil {}

Notice that I use a @Target annotation when defining my new annotation. @Target(value = TYPE) restricts usage of this annotation to classes. As a result, you cannot annotate method definitions with StaticStringUtil.

Next, we must define a subclass of AbstractProcessor and implement its three methods inside the compiler module. You must include a reference to this AbstractProcessor inside your compiler's META-INF file. Using the @AutoService annotation does this for us automatically.

@AutoService(Processor.class)
public class StaticStringUtilProcessor extends AbstractProcessor {
    @Override
    public Set getSupportedAnnotationTypes() {
        return singleton(StaticStringUtil.class.getCanonicalName());
    }

    @Override
    public SourceVersion getSupportedSourceVersion() {
        return latestSupported();
    }

    @Override
    public boolean process(Set annotations, RoundEnvironment roundEnv) {
        // Examine uses of annotations in the apps' abstract
        // syntax tree, and generate one or more resulting classes.
    }
}

The process() method is the most interesting. Here, you will will analyze all uses of our annotation and then generate java files using JavaPoet. While doing this, you should keep the following in mind

  • Element: Represents anything in a Java file. For example, this could be a class name, method name or = operator.
  • TypeElement: Represents a class or interface element
  • TypeMirror: Represents the type of a class in Java
  • If you encounter an error during processing, do not throw an exception. Instead use the Messeger class to display errors in the build results. Clicking these errors will bring you to the line that caused the error.
  • If you use JavaPoet, you may wish to copy and paste this library into your project instead of adding a library dependency. This can simplify the life of your clients. And it isn't a big deal. It is only a few small files.

Publishing to JCenter###

I am using the bintray-release plugin to upload my api and processor to Bintray so that third party developers can easily use my annotation by adding the following to their app's build.gradle after adding android-apt.

dependencies {
    compile 'com.brianattwell:static_string_util_api:0.3.5'
    apt 'com.brianattwell:static_string_util_compiler:0.3.5'
    provided 'org.glassfish:javax.annotation:10.0-b28'
}

Above, we explicitly provide the org.glassfish:java.annotation dependency for the compiler module as work around to well known issue. This is because this library is a standard part of Java but not Android.

Once you have configured the ext closure inside your project's build.gradle file, you can upload your library to Bintray with the following line.

./gradlew clean build bintrayUpload -PbintrayUser=(bintray username) -PbintrayKey=(bintray key) -PdryRun=false

If you are completely unfamiliar with Bintray and JCenter, I recommend you read through my previous post on the subject, even though it uses a different upload script.

FAQ###

Question: I keep seeing Error: "Bad Service configuration file..."?

Answer I saw this in two situations

  1. An exception was being thrown inside my process() implementation.
  2. After making a change to the processor or api module, I saw this error a couple times. I was able to fix this by removing the build directories.

Question: How do you debug your annotation processor?

Answer You can configure your processor module to connect to a remote debugger by modifying your ~/.gradle/gradle.properties. Alternatively, use Messeger to print debug messages.

Question: Can you perform annotation processing on classes generated by an annotation processor?

Answer Yes. Annotation Processors can work in multiple rounds. This requires you to design your process() implementation so that it can be called multiple times without overwriting generated classes. My example is not designed this way.