Annotation Processing: Supercharge Your Development

Annotation processing is a powerful tool for generating code for Android apps. In this tutorial, you’ll create one that generates RecyclerView adapters. By Gordan Glavaš.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Registering Your Processor

You must register your processor with javac so that the compiler knows to invoke it during compilation. To do this, you must create a special file.

Select the Project view in Android Studio:

Switching to the Project view

Expand autoadapter-processor ▸ src ▸ main. Add a new directory named resources. Then add a subdirectory to resources and name it META-INF. Yes, the capitalized letters matter.

Finally, add another subdirectory to META-INF and name it services. In there, add an empty file and name it javax.annotation.processing.Processor.

Your final file structure should look like this:

The location of the META-INF folder

Open javax.annotation.processing.Processor and put the fully qualified name of your processor as its content. If you’ve followed the steps above, it should be the following:

com.raywenderlich.android.autoadapter.processor.Processor

With that done, you can switch back to the Android view.

Switching back to the Android view

The compiler is now aware of your custom processor and will run it during its pre-compilation phase.

Extracting Annotation Metadata

Now to put the processor to use. Since annotations add metadata, the first task of the annotation processor is to extract that metadata from annotations into something it can work with. You’ll keep track of the data gathered from the annotations in a couple of model classes.

  • The resulting processor will have no error checking or reporting.
  • It’s only capable of mapping String fields of models to TextViews in their respective layouts.
Note: To keep this tutorial compact, we had to cut some corners:

Coding Model Classes

In autoadapter-processor‘s main source package, create a new subpackage and name it models. Then add a class to it named ViewHolderBindingData.kt. Put this as its content:

data class ViewHolderBindingData(
   val fieldName: String, // 1
   val viewId: Int // 2
)

This model stores information that will be extracted from the ViewHolderBinding annotation:

  1. The name of the model field annotated with ViewHolderBinding.
  2. The view ID parameter of the ViewHolderBinding annotation.

In the same package, add a class named ModelData.kt. This will be the model class containing all the information required to generate the adapter:

data class ModelData(
   val packageName: String, // 1
   val modelName: String, // 2
   val layoutId: Int, // 3
   val viewHolderBindingData: List<ViewHolderBindingData> // 4
)

Here’s what happening:

  1. You need to know the package name so that the Adapter source file lives in the same package as the source model.
  2. You’ll use the name of the model to construct the name for the Adapter class.
  3. The layout ID parameter will be extracted from the AdapterModel annotation.
  4. The list of ViewHolderBindingData instances is for the fields of the model class.

With the classes holding all this metadata ready, it’s time to use the processor to extract the data.

Processing the Annotations

The first step is for the processor to identify all code elements annotated with AdapterModel.

Open Processor.kt and replace the TODO in process with the following:

roundEnv.getElementsAnnotatedWith(AdapterModel::class.java) // 1
   .forEach { // 2
     val modelData = getModelData(it) // 3
     // TODO more to come here
   }

Here’s what you’re doing with this code:

  1. Extract all code elements annotated with AdapterModel.
  2. Iterate through all those elements.
  3. Extract model data for each element.

getModelData will extract all the relevant information from the annotated code element and build up the models you’ve created in the previous section. Add this method just after process:

private fun getModelData(elem: Element): ModelData {
 val packageName = processingEnv.elementUtils.getPackageOf(elem).toString() // 1
 val modelName = elem.simpleName.toString() // 2
 val annotation = elem.getAnnotation(AdapterModel::class.java) // 3
 val layoutId = annotation.layoutId // 4
 val viewHolderBindingData = elem.enclosedElements.mapNotNull { // 5
   val viewHolderBinding = it.getAnnotation(ViewHolderBinding::class.java) // 6
   if (viewHolderBinding == null) {
     null // 7
   } else {
     val elementName = it.simpleName.toString()
     val fieldName = elementName.substring(0, elementName.indexOf('$'))
     ViewHolderBindingData(fieldName, viewHolderBinding.viewId) // 8
   }
 }
 return ModelData(packageName, modelName, layoutId, viewHolderBindingData) // 9
}

Add any missing imports that the IDE suggests; these are for your classes that you’ve created earlier.

Okay, there’s a lot going on here. Here’s a detailed breakdown:

  1. Extracts the package name from the element.
  2. Gets the class name of the model that the annotation was present on.
  3. Gets the annotation itself.
  4. Extracts the layoutId parameter from the annotation.
  5. Iterates through all the element’s enclosed (child) elements. The top-level element here is the model class, and its children are its properties.
  6. Checks if the child element is annotated with ViewHolderBinding.
  7. If it isn’t, skips it.
  8. Otherwise, collects the child element’s name and viewId from its annotation.
  9. Packs all this info into a ModelData instance.

Generating Source Code

Your processor is set up and knows how to get the information it needs, so now it’s time to put it to use generating some code for you!

Specifying the Output Folder

Source code files that AP creates have to live in a special folder. This folder’s path is kapt/kotlin/generated.

To tell the processor to put its generated files there, start by opening Processor.kt and adding this at the bottom, inside the class:

companion object {
 const val KAPT_KOTLIN_GENERATED_OPTION_NAME = "kapt.kotlin.generated"
}

Then add the following as the first line in process:

val kaptKotlinGeneratedDir = 
   processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME]
   ?: return false

This code checks if your processor is able to locate the necessary folder and write files to it. If it can, it’ll give you the path to use. Otherwise, the processor will abort and return false from the process method.

Using KotlinPoet

You’ll use KotlinPoet to generate source code files. KotlinPoet is a library that exposes a powerful and simple application programming interface (API) for generating Kotlin source files.

Open autoadapter-processor/build.gradle and add this dependency:

implementation 'com.squareup:kotlinpoet:1.4.4'

The specific nature of the output generated code necessitates that you do this in a single go. There’s really no way of breaking it into smaller, buildable chunks. But hang tight!

Writing Code Generation Basics

Add a new package in autoadapter-processor, and name it codegen. Then add a new file to it, and name it AdapterCodeBuilder.kt:

class AdapterCodeBuilder(
    private val adapterName: String,
    private val data: ModelData
) {
}

The constructor has two parameters: the name of the class you’re writing and the corresponding ModelData. Start building this class by adding some constants to its body:

private val viewHolderName = "ViewHolder" // 1
private val viewHolderClassName = ClassName(data.packageName, viewHolderName) // 2
private val viewHolderQualifiedClassName = ClassName(data.packageName, 
   adapterName + ".$viewHolderName") // 3
private val modelClassName = ClassName(data.packageName, data.modelName) // 4
private val itemsListClassName = ClassName("kotlin.collections", "List") // 5
   .parameterizedBy(modelClassName)
private val textViewClassName = ClassName("android.widget", "TextView") // 6

ClassName is a KotlinPoet API class that wraps a fully qualified name of a class. It will also create the necessary imports at the top of the generated source files for you.

Note: Android Studio may not be able to find the parameterizedBy extension function to import. If this happens, add this import manually:
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy

Here’s what you’ll use these constants for:

  1. ViewHolder will be a nested class, RecyclerView.ViewHolder, implementation inside the generated adapter.
  2. Its ClassName contains both its package and its name.
  3. You need the fully qualified name as RecyclerView.Adapter is parameterized.
  4. The ClassName for the model class this adapter is being created for.
  5. The adapter’s sole field is a List of items to render, all of which are of “model” type.
  6. You need TextView to be able to bind String to them later on.

KotlinPoet uses TypeSpec to define class code. Add this method to AdapterCodeBuilder while accepting all the suggested imports:

fun build(): TypeSpec = TypeSpec.classBuilder(adapterName) // 1
   .primaryConstructor(FunSpec.constructorBuilder() // 2
       .addParameter("items", itemsListClassName)
       .build()
   )
    // 3
   .superclass(ClassName("androidx.recyclerview.widget.RecyclerView", "Adapter")
       .parameterizedBy(viewHolderQualifiedClassName)
   )
   .addProperty(PropertySpec.builder("items", itemsListClassName) // 4
       .addModifiers(KModifier.PRIVATE)
       .initializer("items")
       .build()
   )
// TODO More to come here
   .build()

Once again, here’s what’s happening above:

  1. You’re building a type whose name is adapterName.
  2. It has a primary constructor with a single parameter named items of type itemsListClassName.
  3. Your adapter extends RecyclerView.Adapter, and ViewHolder is of type viewHolderQualifiedClassName.
  4. The adapter has a private property named items, which is initialized by the constructor parameter with the same name. This will result in a private val inside the generated adapter.

Writing Base Methods

As you probably know, a RecyclerView.Adapter requires you to override three methods: getItemCount, onCreateViewHolder and onBindViewHolder.

A clever trick is to create a private extension function on TypeSpec.Builder so that you can insert pieces of code neatly into the builder method call chain you’ve created above.

Add the following extension function to specify these base methods:

private fun TypeSpec.Builder.addBaseMethods(): TypeSpec.Builder = apply { // 1
 addFunction(FunSpec.builder("getItemCount") // 2
     .addModifiers(KModifier.OVERRIDE) // 3
     .returns(INT) // 4
     .addStatement("return items.size") // 5
     .build()
 )

// TODO MORE
}

Here’s what’s happening:

  1. addBaseMethods is an extension on TypeSpec.Builder that performs the following actions on it.
  2. Add a new method to the class named getItemCount.
  3. The method overrides an abstract method.
  4. It returns an Int.
  5. It contains a single return statement, returning the size of list.

Next, add the code generating the other two required adapter methods inside addBaseMethods by replacing TODO MORE with the following:

addFunction(FunSpec.builder("onCreateViewHolder")
    .addModifiers(KModifier.OVERRIDE)
    .addParameter("parent", ClassName("android.view", "ViewGroup")) // 1
    .addParameter("viewType", INT)
    .returns(viewHolderQualifiedClassName)
    .addStatement("val view = " +
        "android.view.LayoutInflater.from(parent.context).inflate(%L, " +
        "parent, false)", data.layoutId) // 2
    .addStatement("return $viewHolderName(view)")
    .build()
)

addFunction(FunSpec.builder("onBindViewHolder")
    .addModifiers(KModifier.OVERRIDE)
    .addParameter("viewHolder", viewHolderQualifiedClassName)
    .addParameter("position", INT)
    .addStatement("viewHolder.bind(items[position])")
    .build()
)

Most of this code is straightforward given the previous examples, but here are a few things worth pointing out:

  1. addParameter adds parameters to function definitions. For example, the onCreateViewHolder method you’re overriding has two parameters: parent and viewType.
  2. KotlinPoet has its own string formatting flags. Be sure to check them out.

The bodies of these three methods are the usual boilerplate for an adapter that contains a single item type and whose data comes from a single list.

Adding the ViewHolder

The last thing your custom adapter needs is a ViewHolder subclass implementation. Just as before, add TypeSpec.Builder inside AdapterCodeBuilder:

private fun TypeSpec.Builder.addViewHolderType(): TypeSpec.Builder = addType(
   TypeSpec.classBuilder(viewHolderClassName)
       .primaryConstructor(FunSpec.constructorBuilder()
           .addParameter("itemView", ClassName("android.view", "View"))
           .build()
       )
       .superclass(ClassName(
           "androidx.recyclerview.widget.RecyclerView", 
           "ViewHolder")
       )
       .addSuperclassConstructorParameter("itemView")
       // TODO binding
       .build()
)

This code is very similar to what you did before. You added a new class to the existing one with its name, constructor and superclass specified. You passed along a parameter named itemView to the superclass constructor.

You’ll need one method in this class to bind the model fields to ViewHolder views. Add this method below addViewHolderType:

private fun TypeSpec.Builder.addBindingMethod(): TypeSpec.Builder = addFunction(
    FunSpec.builder("bind") // 1
      .addParameter("item", modelClassName)
      .apply {
        data.viewHolderBindingData.forEach { // 2
        addStatement("itemView.findViewById<%T>(%L).text = item.%L",
            textViewClassName, it.viewId, it.fieldName) // 3
      }
   }
   .build()
)
  • Finds a TextView for the given viewId.
  • Sets its text property to the model instance’s property.
  1. The new method’s name is bind. It takes a single parameter, a model instance, to bind.
  2. Iterate through the collected ModelData‘s viewHolderBindingData list.
  3. For each model property annotated with ViewHolderBindingData, output a statement that:

Add this new method to the ViewHolder class definition by replacing TODO binding in addViewHolderType with this:

.addBindingMethod()

Tying It All Together

To complete AdapterCodeBuilder, go back to build and replace the TODO in its body with the following:

.addBaseMethods()
.addViewHolderType()

And that’s it! AdapterCodeBuilder can now generate a source code file based on collected ModelData.

The last step is to plug it into the processor. Open Processor.kt and replace the TODO in process with this:

val fileName = "${modelData.modelName}Adapter" // 1
FileSpec.builder(modelData.packageName, fileName) // 2
   .addType(AdapterCodeBuilder(fileName, modelData).build()) // 3
   .build()
   .writeTo(File(kaptKotlinGeneratedDir)) // 4
  1. The filename of the adapter class will be whatever the name of the model class is, suffixed by “Adapter”.
  2. Create a new file in the same package as the model class, and name it fileName.
  3. Add a new type to it by running an AdapterCodeBuilder. This adds the adapter as the content of the file, using all the code you’ve written before!
  4. Write the generated file to the kaptKotlinGeneratedDir folder.

All done! Your processor is now fully functional. It is capable of finding code elements annotated with your custom annotations, extracting data from them, and then generating new source files based on that info. Sounds cool, but you won’t know for certain until you run it.