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 packagecom.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 classClassBuilder
extendsAbstractProcessor
, 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 initializemProcessingEnvironment
with theProcessingEnvironment
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!