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š.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Annotation Processing: Supercharge Your Development
25 mins
- Getting Started
- Starting Your First Annotation
- Exploring Annotation Anatomy
- Adding Another Annotation
- Introducing Annotation Processing
- Creating Your First Annotation Processor
- Adding Basic Processor Structure
- Registering Your Processor
- Extracting Annotation Metadata
- Coding Model Classes
- Processing the Annotations
- Generating Source Code
- Specifying the Output Folder
- Using KotlinPoet
- Adding the Processor to Your App
- Where to Go From Here?
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:
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:
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.
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 toTextView
s in their respective layouts.
- The resulting processor will have no error checking or reporting.
- It’s only capable of mapping
String
fields of models toTextView
s 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:
- The name of the model field annotated with
ViewHolderBinding
. - 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:
- You need to know the package name so that the
Adapter
source file lives in the same package as the source model. - You’ll use the name of the model to construct the name for the
Adapter
class. - The layout ID parameter will be extracted from the
AdapterModel
annotation. - 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:
- Extract all code elements annotated with
AdapterModel
. - Iterate through all those elements.
- 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:
- Extracts the package name from the element.
- Gets the class name of the model that the annotation was present on.
- Gets the annotation itself.
- Extracts the
layoutId
parameter from the annotation. - Iterates through all the element’s enclosed (child) elements. The top-level element here is the model class, and its children are its properties.
- Checks if the child element is annotated with
ViewHolderBinding
. - If it isn’t, skips it.
- Otherwise, collects the child element’s name and
viewId
from its annotation. - 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.
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:
-
ViewHolder
will be a nested class,RecyclerView.ViewHolder
, implementation inside the generated adapter. - Its
ClassName
contains both its package and its name. - You need the fully qualified name as
RecyclerView.Adapter
is parameterized. - The
ClassName
for the model class this adapter is being created for. - The adapter’s sole field is a
List
of items to render, all of which are of “model” type. - You need
TextView
to be able to bindString
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:
- You’re building a type whose name is
adapterName
. - It has a primary constructor with a single parameter named
items
of typeitemsListClassName
. - Your adapter extends
RecyclerView.Adapter
, andViewHolder
is of typeviewHolderQualifiedClassName
. - The adapter has a private property named
items
, which is initialized by the constructor parameter with the same name. This will result in aprivate 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:
-
addBaseMethods
is an extension onTypeSpec.Builder
that performs the following actions on it. - Add a new method to the class named
getItemCount
. - The method overrides an abstract method.
- It returns an
Int
. - 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:
-
addParameter
adds parameters to function definitions. For example, theonCreateViewHolder
method you’re overriding has two parameters:parent
andviewType
. - 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()
)
- The new method’s name is bind. It takes a single parameter, a model instance, to bind.
- Iterate through the collected
ModelData
‘sviewHolderBindingData
list. - For each model property annotated with
ViewHolderBindingData
, output a statement that:- Finds a
TextView
for the givenviewId
. - Sets its
text
property to the model instance’s property.
- Finds a
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
- The filename of the adapter class will be whatever the name of the model class is, suffixed by “Adapter”.
- Create a new file in the same package as the model class, and name it
fileName
. - 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! - 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.