Annotation Processing in Android: From Scratch (Part 2)

In part 1 of this series, we learned what is annotation processing and why is it used. We also learned how annotation processors can create boilerplate code during compile time making our development process more efficient and fast.

In today's post, we'll start by implementing an elementary annotation processor whose job will only be to create a simple class during compile time. So let's get started.

Step 1

First of all, we need to create a new Java library module in Android Studio as shown below, this module will hold the annotation processor itself

Step 2

Now that we have created a new Java library module, the next step would be adding a resource folder inside it and then adding the META-INF folder to it, this step is important because of two reasons.

  1. Discovery by the Compiler: The Java compiler needs a way to discover and load annotation processors at compile time. The registration file helps the compiler to identify which classes are annotation processors and should be invoked during the compilation process.

  2. Service Loader Mechanism: Java uses a service provider framework, a part of the Java SPI (Service Provider Interface), to load services dynamically at runtime. The META-INF/services directory is a standard location where service provider configuration files are placed. These files are used by the ServiceLoader utility class to load service implementations dynamically. Annotation processors are essentially services used by the Java compiler, and thus they are loaded using this mechanism.

To create the required file structure:

  1. Go to the main folder - right click - go to new - and then directory. Name this directory as META-INF

  2. After this, create a new folder inside this META-INF folder and name it services.

  3. Now create a new file inside this directory and name it precisely as javax.annotation.processing.Processor.

The directory structure should look something like this.

Step 3

Let's define our annotation class.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface GenerateClass {
    String name() default "GeneratedClass";
}

This code would also stay in the same package where you have the MyAnnotationProcessor class.

Explanation of the code

@Target(ElementType.TYPE)

This line defines the target of the annotation, indicating where this annotation can be applied. ElementType.TYPE means that this annotation can be applied to any type (class, interface, enum, etc.).

@Retention(RetentionPolicy.SOURCE)

This line defines the retention policy of the annotation, which dictates how long the annotation information is kept. RetentionPolicy.SOURCE means that the annotation will be discarded by the compiler and won't be present in the compiled .class files. It will only be available in the source code, which is typical for annotations that are only used at compile-time (like for code generation).

public @interface GenerateClass {
    String name() default "GeneratedClass";
}

Here, we define the annotation interface itself. It is public, meaning it can be accessed from any other class. The annotation has a single attribute named name with a default value of "GeneratedClass".

Step 4

Great, now let's go ahead and write our custom annotation processor.

To do this, extend the MyAnnotationProcessor with AbstractProcessor and then implement the init and process override methods as shown below.

@SupportedAnnotationTypes("com.example.annotationprocessor.GenerateClass")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class MyAnnotationProcessor extends AbstractProcessor {

    private ProcessingEnvironment mProcessingEnvironment;

    @Override
    public synchronized void init(ProcessingEnvironment processingEnvironment) {
        super.init(processingEnvironment);
        mProcessingEnvironment = processingEnvironment;
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        for (TypeElement annotation : annotations) {
            roundEnv.getElementsAnnotatedWith(annotation).forEach(element -> {
                mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.NOTE, "Processing started ********");
                GenerateClass generateClassAnnotation = element.getAnnotation(GenerateClass.class);
                String className = generateClassAnnotation.name();
                generateClass(className);
            });
        }
        return true;
    }

    private void generateClass(String className) {
        try {
            String relativeClassName = "com.example.annotationprocessor."+className;
            mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.NOTE, "Generate class called ******");
            JavaFileObject jfo = mProcessingEnvironment.getFiler().createSourceFile(relativeClassName);
            try (Writer writer = jfo.openWriter()) {
                writer.write("package com.example.annotationprocessor;\n\n");
                writer.write("public class " + className + " {\n\n");
                writer.write("    public void print() {\n");
                writer.write("        System.out.println(\"Hello from " + className + "!\");\n");
                writer.write("    }\n");
                writer.write("}\n");
            }
        } catch (Exception e) {
            mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.NOTE, "Unable to write ******" + e.getMessage());
            e.printStackTrace();
        }
    }
}

Explanation of the code

Let's go through this code step by step:

@SupportedAnnotationTypes("com.example.annotationprocessor.GenerateClass")
@SupportedSourceVersion(SourceVersion.RELEASE_17)

Here, the @SupportedAnnotationTypes annotation is used to specify the annotations that this processor can process. It is set to process annotations of type com.example.annotationprocessor.GenerateClass. The @SupportedSourceVersion annotation specifies the latest Java version supported by this processor, which is Java 17 in this case.

public class MyAnnotationProcessor extends AbstractProcessor {
    private ProcessingEnvironment mProcessingEnvironment;

This part defines a class MyAnnotationProcessor that extends AbstractProcessor, which is a base class for creating annotation processors. It also defines a ProcessingEnvironment variable to store the processing environment, which provides facilities that the processor might need to perform its functions.

@Override
public synchronized void init(ProcessingEnvironment processingEnvironment) {
    super.init(processingEnvironment);
    mProcessingEnvironment = processingEnvironment;
}

The init method is overridden to initialize the mProcessingEnvironment variable with the ProcessingEnvironment instance provided by the compiler. This method is called by the compiler at the start of a processing round.

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    for (TypeElement annotation : annotations) {
        roundEnv.getElementsAnnotatedWith(annotation).forEach(element -> {
            mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.NOTE, "Processing started ********");
            GenerateClass generateClassAnnotation = element.getAnnotation(GenerateClass.class);
            String className = generateClassAnnotation.name();
            generateClass(className);
        });
    }
    return true;
}

In the process method, the processor iterates over all annotations present in the current round and, for each annotation, it iterates over all elements annotated with that annotation. It then retrieves the GenerateClass annotation from the element to get the name attribute value, and calls the generateClass method with this value. It returns true to indicate that it has handled the annotations and no further processing is needed for them.

private void generateClass(String className) {
        try {
            String relativeClassName = "com.example.annotationprocessor."+className;
            mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.NOTE, "Generate class called ******");
            JavaFileObject jfo = mProcessingEnvironment.getFiler().createSourceFile(relativeClassName);
            try (Writer writer = jfo.openWriter()) {
                writer.write("package com.example.annotationprocessor;\n\n");
                writer.write("public class " + className + " {\n\n");
                writer.write("    public void print() {\n");
                writer.write("        System.out.println(\"Hello from " + className + "!\");\n");
                writer.write("    }\n");
                writer.write("}\n");
            }
        } catch (Exception e) {
            mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.NOTE, "Unable to write ******" + e.getMessage());
            e.printStackTrace();
        }
    }

In the generateClass method, a new Java source file is created with the name specified in the GenerateClass annotation. The method writes a simple Java class to this file with a print method that prints a greeting message to the console. If any exception occurs during file creation or writing, it is caught and a message is printed to the console.

Step 5

And you are all set! Now we just need to import our annotation processor and use it into our code. To do this, go to the app level build.gradle file and add the annotation file location as shown below.

dependencies {
    //.....
    //other dependencies

    //annotation processor imported
    annotationProcessor project(':annotationprocessor')
    implementation project(':annotationprocessor')
}

Now, let's go ahead and add this annotation to a TestClass in our project as shown below.

package com.example.fromscratch;

import com.example.annotationprocessor.GenerateClass;

@GenerateClass(name = "TestClassBuilder")
public class TestClass {
}

This test class resides in our main project (where the MainActivity class also resides).

And you are done!

Now build the app and check the build logs.

You should be able to see these custom logs that we added to the MyAnnotationProcessor class as shown above.

Locate And Use Generated Class

By default, the class is generated into the output folder of your main project build/generated/ap_generated_sources/debug

Before you can use this class, we need to tell Gradle to include the created Java source files in the compilation process, along with the source files in the default source directories. To do this, add the following code to your build.gradle file

sourceSets {
        main {
            java.srcDirs += 'build/generated/ap_generated_sources/debug' // or wherever your generated sources are
        }
    }

And that's all!

Now you can use the generated class anywhere in your code as shown below.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        var testClassBuilder = TestClassBuilder() //Created by the annotation processor
    }
}

Conclusion

You can find the project with the code here.

Creating a custom annotation processor can initially seem like a daunting task, especially when diving into the intricacies of Java's annotation processing API. However, as we have seen in this guide, with a systematic approach, it becomes a straightforward process.

We started with the creation of a custom annotation, defining its target and retention policy to suit our needs. Following this, we developed an annotation processor, extending the AbstractProcessor class and overriding essential methods such as init and process. Through this processor, we were able to dynamically generate Java classes based on the attributes defined in our custom annotation, showcasing the power and flexibility that custom annotation processors can bring to a Java project.

Moreover, we delved into the registration of our annotation processor, a crucial step that leverages the Service Provider Interface (SPI) to ensure our processor is correctly identified and utilized by the Java compiler. We also explored how to configure the Gradle build script to include generated sources in the compilation process, a necessary step to seamlessly integrate our processor into the build lifecycle.

As we wrap up, it is evident that custom annotation processors open up a realm of possibilities, allowing for cleaner code, reducing boilerplate, and fostering reusable patterns. Whether it is generating builder classes, creating API clients, or any other code generation task, annotation processors stand as a powerful tool in a Java developer's toolkit.

I'd encourage you to experiment further, perhaps by enhancing the functionality of the GenerateClass annotation or by creating more complex processors that can handle a wider variety of code-generation tasks (which by the way we'd be exploring in our third part of this series).

Till then, happy coding!