Annotation Processing in Android: From Scratch (Part 3)

Photo by Callum Hill on Unsplash

Annotation Processing in Android: From Scratch (Part 3)

Using Java Poet to create classes

Introduction

Welcome to the third installment of our series on annotation processing in Android. In the previous part, we embarked on a journey of understanding and implementing a basic annotation processor from scratch, a tool that stands as a powerful ally in a Java developer's toolkit, aiding in reducing boilerplate and fostering reusable patterns through dynamic code generation.

As we venture further, this part aims to elevate your understanding and skills to the next level by diving into the world of Java Poet. Java Poet is a Java API that assists in generating .java source files. Leveraging Java Poet in conjunction with annotation processors can streamline the code generation process, making it more efficient and manageable.

What are we building

We'll be creating a simple annotation processor that will create a builder class that follows the builder pattern of Java to create setter methods for the variables available in our class and in turn creates the object.

The code of this project is available here

Step 0

Add the following dependency to the build.gradle file of annotationprocessor module.

plugins {
    id 'java-library'
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

//This java poet dependency needs to be added
dependencies {
    implementation 'com.squareup:javapoet:1.13.0'
}

Step 1

Let's start by adding a new annotation definition into our project as @Builder.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Builder {

}

Step 2

Now, let's go ahead and write our annotation processor for the builder class as follows.

@SupportedAnnotationTypes("com.example.annotationprocessor.Builder")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class ClassBuilder 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) {
        mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.NOTE, "Builder Processing started ********");
        for (Element element : roundEnv.getElementsAnnotatedWith(Builder.class)) {
            if (element.getKind() == ElementKind.CLASS) {
                TypeElement typeElement = (TypeElement) element;
                try {
                    generateBuilder(typeElement);
                } catch (IOException e) {
                    processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Error generating builder for " + typeElement.getSimpleName() + ": " + e.getMessage());
                    e.printStackTrace();
                }
            }
        }
        return true;
    }

    private void generateBuilder(TypeElement typeElement) throws IOException {
        String className = typeElement.getSimpleName().toString();
        String builderClassName = className + "Builder";
        PackageElement packageElement = (PackageElement) typeElement.getEnclosingElement();
        String packageName = packageElement.getQualifiedName().toString();

        TypeSpec.Builder builderClass = TypeSpec.classBuilder(builderClassName)
                .addModifiers(Modifier.PUBLIC);

        for (Element enclosed : typeElement.getEnclosedElements()) {
            if (enclosed.getKind() == ElementKind.FIELD) {
                VariableElement variableElement = (VariableElement) enclosed;
                String fieldName = variableElement.getSimpleName().toString();
                TypeMirror fieldType = variableElement.asType();

                // Generate setter method for each field
                MethodSpec setter = MethodSpec.methodBuilder("set" + capitalize(fieldName))
                        .addModifiers(Modifier.PUBLIC)
                        .returns(ClassName.get(packageName, builderClassName))
                        .addParameter(TypeName.get(fieldType), fieldName)
                        .addStatement("this.$N = $N", fieldName, fieldName)
                        .addStatement("return this")
                        .build();

                builderClass.addMethod(setter);
                builderClass.addField(TypeName.get(fieldType), fieldName, Modifier.PRIVATE);
            }
        }

        // Generate build() method
        MethodSpec buildMethod = MethodSpec.methodBuilder("build")
                .addModifiers(Modifier.PUBLIC)
                .returns(ClassName.get(packageName, className))
                .addStatement("return new $N(this.age, this.name)", className)
                .build();

        builderClass.addMethod(buildMethod);

        JavaFile javaFile = JavaFile.builder(packageName, builderClass.build()).build();
        javaFile.writeTo(processingEnv.getFiler());
    }

    private String capitalize(String str) {
        if (str == null || str.isEmpty()) {
            return str;
        }
        return Character.toUpperCase(str.charAt(0)) + str.substring(1);
    }
}

Code Explanation

Import Statements and Class Declaration

SupportedAnnotationTypes("com.example.annotationprocessor.Builder")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class ClassBuilder extends AbstractProcessor {
  • @SupportedAnnotationTypes("com.example.annotationprocessor.Builder"): This annotation specifies that this processor is triggered for classes annotated with @Builder from the package com.example.annotationprocessor.

  • @SupportedSourceVersion(SourceVersion.RELEASE_17): This annotation indicates that the processor supports source code written in Java 17.

  • public class ClassBuilder extends AbstractProcessor: The main class ClassBuilder extends AbstractProcessor, making it an annotation processor.

Initialization Method

private ProcessingEnvironment mProcessingEnvironment;

@Override
public synchronized void init(ProcessingEnvironment processingEnvironment) {
    super.init(processingEnvironment);
    mProcessingEnvironment = processingEnvironment;
}
  • A private variable mProcessingEnvironment is declared to hold the processing environment.

  • The init method is overridden to initialize mProcessingEnvironment with the ProcessingEnvironment passed to it.

Process Method

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    mProcessingEnvironment.getMessager().printMessage(Diagnostic.Kind.NOTE, "Builder Processing started ********");
    // ...
    return true;
}
  • The process method is overridden to define the processing logic.

  • A diagnostic message is printed to indicate the start of the processing.

  • It iterates over all elements annotated with @Builder and triggers the builder generation for each class element.

  • It returns true to indicate that the annotations have been processed.

Generate Builder Method

Let's delve deeper into the generateBuilder method line by line:

private void generateBuilder(TypeElement typeElement) throws IOException {

This line defines a private method named generateBuilder that takes a TypeElement as a parameter and throws an IOException. The TypeElement represents a class annotated with @Builder.

PackageElement packageElement = (PackageElement) typeElement.getEnclosingElement();

The enclosing element of the typeElement is retrieved and cast to PackageElement to get details about the package in which the class is defined.

String packageName = packageElement.getQualifiedName().toString();

The fully qualified name of the package is retrieved as a String.

TypeSpec.Builder builderClass = TypeSpec.classBuilder(builderClassName)
                .addModifiers(Modifier.PUBLIC);

A TypeSpec.Builder instance is created to build the class specification for the new builder class. The builder class is given a public access modifier.

for (Element enclosed : typeElement.getEnclosedElements()) {

A loop iterates over all the elements enclosed within the typeElement (i.e., all members of the class, including fields, methods, etc.).

if (enclosed.getKind() == ElementKind.FIELD) {

This line checks if the current enclosed element is a field.

VariableElement variableElement = (VariableElement) enclosed;

The enclosed element is cast to VariableElement to work with field-specific methods.

String fieldName = variableElement.getSimpleName().toString();

The simple name of the field is retrieved as a String.

TypeMirror fieldType = variableElement.asType();

The type of the field is retrieved as a TypeMirror.

MethodSpec setter = MethodSpec.methodBuilder("set" + capitalize(fieldName))
                    .addModifiers(Modifier.PUBLIC)
                    .returns(ClassName.get(packageName, builderClassName))
                    .addParameter(TypeName.get(fieldType), fieldName)
                    .addStatement("this.$N = $N", fieldName, fieldName)
                    .addStatement("return this")
                    .build();

A setter method is created for the field using MethodSpec.methodBuilder. The method:

  • Has a name derived from the field name, prefixed with "set" and capitalized.

  • Is public.

  • Returns an instance of the builder class to allow method chaining.

  • Takes a single parameter: the field with its type.

  • Assigns the parameter value to the field in the builder class.

  • Returns this to allow method chaining.

builderClass.addMethod(setter);

The setter method is added to the builder class specification.

builderClass.addField(TypeName.get(fieldType), fieldName, Modifier.PRIVATE);

A private field with the same name and type as the original field is added to the builder class specification.

MethodSpec buildMethod = MethodSpec.methodBuilder("build")
                .addModifiers(Modifier.PUBLIC)
                .returns(ClassName.get(packageName, className))
                .addStatement("return new $N(this.age, this.name)", className)
                .build();

A build method is created using MethodSpec.methodBuilder. The method:

  • Is named "build".

  • Is public.

  • Returns an instance of the original class.

  • Creates and returns a new instance of the original class, passing the fields from the builder class to the constructor (note that this part of the code is hardcoded to work with fields named "age" and "name", and should be modified to work dynamically with any fields).

builderClass.addMethod(buildMethod);

The build method is added to the builder class specification.

JavaFile javaFile = JavaFile.builder(packageName, builderClass.build()).build();

A JavaFile instance is created to represent the Java file that will contain the generated builder class, specifying the package name and the builder class specification.

javaFile.writeTo(processingEnv.getFiler());

Finally, the JavaFile writes the generated builder class to a file using the Filer from the processingEnv.

This method dynamically generates a builder class for the annotated class, creating setter methods for each field and a build method to create instances of the original class. It leverages the JavaPoet library to create this class programmatically, following the builder pattern to facilitate a more readable and maintainable way to create objects.

Step 3

Now that we have thoroughly understood the code, let's go ahead and add this new annotation processor to our javax.annotation.processing.Processor file in META-INF folder as follows.

com.example.annotationprocessor.MyAnnotationProcessor
com.example.annotationprocessor.ClassBuilder

And add members to the TestClass as follows.

@Builder
@GenerateClass(name = "TestClassBuilder")
public class TestClass {
    private final String name;
    private final int age;

    TestClass(int age, String name){
        this.age = age;
        this.name = name;
        loggerMethod();
    }

    public void loggerMethod(){
        Log.e("Logger", "Updated object with name as " + name + " and age as " + age);
    }
}

Results

Let's go ahead and build our project, after which you should see the following files generated in the output folder

You can go ahead and checkout the new class created at this point, which should look similar to this

You can now use this new builder class to prepare objects of the TestClass without having to create setter methods for every variable.

Conclusion

This method of creating a simple class might seem like an overkill, which is true. This was supposed to be a simple example to explain the concepts behind annotation processing. In real-life scenarios, annotation processing becomes helpful for creating boilerplate code when the amount of such code is huge, and consistency across all classes is important (like Dagger or Butterknife).

As we wrap up this segment of our annotation processing series, we hope you have gained a solid understanding of how to utilize Java Poet to enhance your annotation processors, making them more powerful and versatile.

Thank you for staying with us through this journey. Keep exploring, keep coding!