Advanced Git, Second Edition

Git is key to great version control and collaboration on software projects.
Stop struggling with Git and spend more time on the stuff that matters!

Home Android & Kotlin Tutorials

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.

5/5 15 Ratings

Version

  • Kotlin 1.3, Android 4.4, Android Studio 3.6

As an Android developer, you’ve probably seen plenty of annotations already: They’re those funny code elements that start with @ and occasionally have parameters attached to them.

Annotations associate metadata with other code elements, allowing you to place more information into your code. One way to make use of annotations is to generate new source files based on that information via annotation processing.

In this tutorial, you’ll develop a set of annotations and an annotation processor that automatically generates RecyclerView.Adapter code for a given model class.

Along the way, you’ll learn:

  • What annotations are and how to create them.
  • What’s an annotation processor and how to write one.
  • How to generate code by using an annotation processor.
  • How to profit from the generated code!
Note: This tutorial assumes you have previous experience developing for Android in Kotlin. If you’re unfamiliar with Kotlin, take a look at this tutorial. If you’re also new to Android development, check out the Getting Started with Android tutorials.

Getting Started

Use the Download Materials button at the top or bottom of this tutorial to download the starter project.

Open the starter project to find a small app named AutoAdapter. MainActivity.kt contains the sole Activity containing a RecyclerView. There’s also a model class in Person.kt. It defines a simple person model with a name and an address:

data class Person(
    val name: String,
    val address: String
)

Your mission — should you choose to accept it — is to write an annotation processor. This annotation processor will automatically generate the adapter for the RecyclerView in MainActivity based on annotating Person.

Starting Your First Annotation

Your first step is to create a new module to hold your annotations. It’s common practice to hold annotations and processors in separate modules — though that’s not a requirement by any means.

Select File ▸ New ▸ New Module…, and then scroll down to select Java or Kotlin library.

Creating a new Java or Kotlin library module

On the next screen, you will:

  1. Name the module autoadapter-annotations.
  2. Set the package to com.raywenderlich.android.autoadapter.annotations.
  3. Set the class name to AdapterModel.
  4. Make sure Kotlin is selected as the language.

Configuring module settings

Press Finish. Then open AdapterModel.kt, which you’ll find in your newly created module!

Currently, AdapterModel is a normal Kotlin class, but turning it into an annotation class is simple. You just need to put the annotation keyword in front of the class. Like this:

annotation class AdapterModel

That’s it! You can now type @AdapterModel elsewhere in the code to annotate other code elements.

Exploring Annotation Anatomy

Even when writing a simple annotation, you can’t go without using other annotations on it! You’ll annotate your annotation classes with two common annotations — yep, that’s an alliterative mouthful. :]

The first common annotation class is @Target. It describes the contexts in which an annotation type is applicable. In other words, it tells which code elements you can place this annotation on. You’ll only use AdapterModel on classes, so add the following code above its declaration:

@Target(AnnotationTarget.CLASS) 

The second common annotation class is @Retention. It tells the compiler how long the annotation should “live.” AdapterModel only needs to be there during the source compilation phase, so you should add this below the @Target annotation:

@Retention(AnnotationRetention.SOURCE)
Note: Another popular value for @Retention is RUNTIME. If you use runtime retention on an annotation, you’ll be able to query for it using reflection. With the SOURCE retention that you’re using for AdapterModel, the annotation won’t make it into the compiled code at all.

Lastly, annotations can have parameters. These allow you to add even more information and fine-tune an annotation’s usage.

The AdapterModel annotation needs a single parameter, the ViewHolder layout ID. Update the declaration like this:

annotation class AdapterModel(val layoutId: Int)

Adding Another Annotation

You need one more annotation to specify how model fields map to views. Add another class to autoadapter.annotations, and name it ViewHolderBinding.kt. Replace the default class declaration with the following:

@Target(AnnotationTarget.PROPERTY) // 1
@Retention(AnnotationRetention.SOURCE) // 2
annotation class ViewHolderBinding(val viewId: Int) // 3

This annotation has the same anatomy as the previous one. However:

  1. Unlike AdapterModel, this one will exclusively target properties.
  2. It only needs to be around during the compilation phase.
  3. Its sole parameter specifies the ID of the view that the annotated property should bind to.

Introducing Annotation Processing

The topic of annotation usage and consumption is broad and deep, yet it boils down to doing more with less. That is, less code (annotations) magically turns into more functionality, and the catalyst for this computational alchemy is annotation processing.

Here’s a quick breakdown of the core concepts. These points should bring you up to speed without going in-depth:

  • Annotation processing is a tool built into javac for scanning and processing annotations at compile time.
  • It can create new source files; however, it can’t modify existing ones.
  • It’s done in rounds. The first round starts when the compilation reaches the pre-compile phase. If this round generates any new files, another round starts with the generated files as its input. This continues until the processor processes all the new files.

This diagram illustrates the process:

The annotation processing loop

Creating Your First Annotation Processor

Time to write your first annotation processor! First, repeat the drill with adding a new Kotlin library module to the project:

  1. Select File ▸ New ▸ New module…
  2. Choose Java or Kotlin library.
  3. Input the module details:
    • Name: autoadapter-processor.
    • Package: com.raywenderlich.android.autoadapter.processor.
    • Class name: Processor.
    • Language: Kotlin.

The processor will need to know about your custom annotations. So, open autoadapter-processor/build.gradle and add the following inside the dependencies block:

implementation project(':autoadapter-annotations')

Sync the project with Gradle files for the change to take effect.

Adding Basic Processor Structure

Open Processor.kt and replace the imports and the class declaration with the following:

import com.raywenderlich.android.autoadapter.annotations.AdapterModel
import javax.annotation.processing.AbstractProcessor
import javax.annotation.processing.RoundEnvironment
import javax.annotation.processing.SupportedSourceVersion
import javax.lang.model.SourceVersion
import javax.lang.model.element.Element
import javax.lang.model.element.TypeElement

@SupportedSourceVersion(SourceVersion.RELEASE_8) // 1
class Processor : AbstractProcessor() { // 2

 override fun getSupportedAnnotationTypes() = 
     mutableSetOf(AdapterModel::class.java.canonicalName) // 3

 override fun process(annotations: MutableSet<out TypeElement>?,
     roundEnv: RoundEnvironment): Boolean { // 4

   // TODO
   return true // 5
 }
}

Wow! That’s a lot of code. Here’s a step-by-step rundown:

  1. @SupportedSourceVersion specifies that this processor supports Java 8.
  2. All annotation processors must extend the AbstractProcessor class.
  3. getSupportedAnnotationTypes() defines a set of annotations this processor looks up when running. If no elements in the target module are annotated with an annotation from this set, the processor won’t run.
  4. process is the core method that gets called in every annotation-processing round.
  5. process must return true if everything went fine.

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.

Note: To keep this tutorial compact, we had to cut some corners:
  • 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.

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

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()
)
  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:
    • Finds a TextView for the given viewId.
    • Sets its text property to the model instance’s property.

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.

Adding the Processor to Your App

Wiring an annotation processor to your app is dead simple. Open app/build.gradle and add these two dependencies to it:

implementation project(':autoadapter-annotations') // 1
kapt project(':autoadapter-processor') // 2
  1. Makes the annotations you’ve created at the start available for use in your app.
  2. Hooks the annotation processor into your compilation process.

You also need to configure the app so that it knows how to use generated files as source sets. In app/build.gradle, add this block right after buildTypes inside the android block:

  sourceSets {
    main {
      java {
        srcDir "${buildDir.absolutePath}/generated/source/kaptKotlin/"
      }
    }
  }

Make sure to sync your project with Gradle files before you proceed!

For the annotation processor to run, at least one element in the codebase must be annotated with one of its supported annotations. Open Person.kt and annotate the class with AdapterModel:

@AdapterModel(R.layout.layout_person)
data class Person

R.layout.layout_person is the layout file that the ViewHolder will inflate.

Next, annotate the model class’s fields with ViewHolderBinding:

  @ViewHolderBinding(R.id.name) val name: String,
  @ViewHolderBinding(R.id.address) val address: String

You’re good to go! Build and run. When it’s done, search for PersonAdapter.kt and open it:

Searching for PersonAdapter

It should look something like this:

package com.raywenderlich.android.autoadapter

import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.Adapter
import kotlin.Int
import kotlin.collections.List

class PersonAdapter(
    private val items: List<Person>
) : Adapter<PersonAdapter.ViewHolder>() {
  override fun getItemCount(): Int = items.size

  override fun onCreateViewHolder(
      parent: ViewGroup, 
      viewType: Int
  ): PersonAdapter.ViewHolder {
    val view = android.view.LayoutInflater.from(parent.context)
        .inflate(2131361822, parent, false)
    return ViewHolder(view)
  }

  override fun onBindViewHolder(
      viewHolder: PersonAdapter.ViewHolder,
      position: Int
  ) {
    viewHolder.bind(items[position])
  }

  class ViewHolder(
      itemView: View
  ) : androidx.recyclerview.widget.RecyclerView.ViewHolder(itemView) {
    fun bind(item: Person) {
      itemView.findViewById<TextView>(2131165330).text = item.name
      itemView.findViewById<TextView>(2131165248).text = item.address
    }
  }
}

The actual numbers for the view and layout IDs will likely differ, but everything else should be the same. Consider how powerful this is: Whenever you add a new model file, you can have a fully functional adapter just by adding a few annotations to the file. This takes a fraction of the time needed to write the class yourself.

It gets even better! If you change anything in the model, the adapter will follow suit. If you remove the model class, the adapter goes as well.

Time to make use of the newly generated adapter file. Open MainActivity.kt and replace the TODO in onCreate with the following:

adapter = PersonAdapter(listOf(
    Person("Big Bird", "123 Seasame Street"),
    Person("Kermit the Frog", "6801 Hollywood Blvd.")
))

Build and run. What you’ll see is a plain old RecyclerView showing a few bits of data. However, it’s powered by your newly generated adapter.

The populated RecyclerView

Where to Go From Here?

You can download the final version of this project using the Download Materials button at the top or bottom of this tutorial.

Congratulations on making it all the through! You’ve learned a lot about annotations and annotation processing, and you created a handy processor that will generate adapter classes for you.

You can read more about the @Target and @Retention annotations in the official Java docs.

Be sure to read more about KotlinPoet to familiarize yourself with all the awesome things it can do.

If you have any questions or comments, please join the forum discussion below!

Average Rating

5/5

Add a rating for this content

15 ratings

More like this

Contributors

Comments