Android developers frequently find themselves writing boilerplate code. It’s typically very simple and quite deterministic. For example, tasks such as preparing an InputStreamReader or binding objects to a view. Boilerplate code such as this gets in the way of the actual problems that you aim to solve in your application and generates a lot of clutter.

Fortunately, This can be alleviated using annotation processing and code generation. Annotations serve as metadata included in your code to help tools infer context that may not always be very obvious. For example, Moshi, a JSON parsing library, uses annotations to provide alternative field names that may be used during deserialization.

@Json(name = “alternate_name”) val myProperty: String = “”

Making your own annotation processor is very easy and can help you automatically generate any required boilerplate code. In this article, I will describe how we can process annotations using kapt as well as generate Kotlin code using the kotlinpoet code generation library.

Our example will focus on creating a naive view binding library similar that allows you to bind views to properties in a given class. It works like this,

@BindField(viewIds = ["name", "amount"], viewName = "itemViewHere")
override fun bind(item: MyType)

Being extremely naive, the annotation processor needs much more information than would be optimal. So the arguments to the annotation processor give the name of the view object and the IDs of the views you want to set the data to. As long as the id and the name of the field in your model data are the same, this annotation processor will work.

Getting Started

Annotation processors can be written in pure Java or Kotlin. This allows for use with both Android or Java/Kotlin compatible projects.

For our demo application, let’s create a few projects for example which will form our complete annotation processor

  1. annotations: This module simply contains annotations that the user will include inside of the consuming project. This prevents users from having to bundle the annotation processor as part of their final application

  2. processor: This module contains actual annotation processor that runs at compile time and generates code.

Both of these projects are pure Java/Kotlin libraries that do not need to contain Android code.

Since we aim to write and generate Kotlin code, let’s add Kotlin support to our project build.gradle file.

buildscript {
    dependencies {
        classpath 'com.android.tools.build:gradle:3.1.0'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.2.31"
    }
}

In our processor module, let’s begin by applying the kotlin plugin as well as kotlin-kapt which contains the kotlin annotations processor configuration. We also need to register our annotation processor as a service. This means that we need to generate a META-INF file that includes the annotation. auto-service does this for you automatically.

apply plugin: 'kotlin'
apply plugin: 'kotlin-kapt'

dependencies {
	implementation project(':annotations')
	implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.2.31"
    
	\\ Code generation library for kotlin, highly recommended
	implementation 'com.squareup:kotlinpoet:0.7.0'

	\\ configuration generator for service providers
	implementation "com.google.auto.service:auto-service:1.0-rc4"
	kapt "com.google.auto.service:auto-service:1.0-rc4"
}

Annotation declaration

First we will have to declare our annotation. Annotations are declared as classes in Kotlin using the annotation keyword. We can add properties to its constructor just like we would any other class. However the types we can use are restricted to primitive types such as Int, Double, String, Enum, other annotations and Arrays of all the previous types. Let’s create an annotation with two parameters.

@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FUNCTION)
annotation class BindField( val viewIds : Array<String>, val viewName : String)

The @Retention annotation is used to indicate at what point the annotation should be discarded. Since we only require this annotation to exist at compile time, we can discard it after the compilation and code generation process. We can instruct the compiler to do this using the AnnotationRetention.SOURCE parameter.

The @Target annotation is used to indicate where this annotation can be used in the implementing source code. We want to be able to annotate functions so we’ll use the intuitively named parameter AnnotationTarget.FUNCTION.

Annotation processing

Now that we’re done creating our annotation, we can move onto to the actual annotation processor. Let’s start by creating a class which overrides the process() function. We will also have to add a few annotations to our class in order to set things up correctly.

@AutoService(Processor::class) // For registering the service
@SupportedSourceVersion(SourceVersion.RELEASE_8) // to support Java 8
@SupportedOptions(BindFieldsProcessor.KAPT_KOTLIN_GENERATED_OPTION_NAME)
class BindFieldsProcessor: AbstractProcessor()

The first is the @AutoService annotation which declares our class as an annotation processor. The generated file is located under META-INF/services/javax.annotation.processing. The contains the fully qualified class name of our class. The @SupportedOptions annotation allows you to specify details for kapt code generation. We’ll set this to kapt.kotlin.generated via a constant which contains the path to a folder where you can manually create .kt files. We will be adding this path to our source directories later in the app module, otherwise the newly generated methods won’t be visible.

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

Now inside of the process function, we need a way to get all elements that have been using our annotation. To do this, we can use RoundEnvironment.getElementesAnnotedWith() which returns a Set of Elements that have been annotated with a given annotation.

roundEnv.getElementsAnnotatedWith(BindField::class.java).forEach { methodElement ->
    if (methodElement.kind != ElementKind.METHOD) {
            processingEnv.messager.errormessage { 
                    "Can only be applied to functions,  element: $methodElement " 
                }
            return false
    }
    ...
}

We can iterate over the set to get all the relevant data and simply pass an error message to the processing environment if the annotated element is anything other than a function.

Details about the Java Elements API is beyond the scope of this article, but there’s a lot of documentation available to help you get up to speed.

Code Generation

Before we can move forward, we need to keep a reference to the folder where we’ll be placing our generated code.

val generatedSourcesRoot: String = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME].orEmpty()
if(generatedSourcesRoot.isEmpty()) {
	processingEnv.messager.errormessage {
    	"Can't find the target directory for generated Kotlin files."
	}
	return
}

This gets a file path from the processing environment where we’ll be storing our generated code. If we’re unable to get one, we simply pass an error message back and return.

Next we can begin processing the file. Our strategy here is to create a function that takes an object as a parameter and the view to in which it is to be binded to. We can generate functions using KotlinPoet an awesome Kotlin code generation library with a nice set of features.

// We need to be able to get properties like name, fields etc from the variable which can be done if it's converted to an Element type
val variableAsElement = processingEnv.typeUtils.asElement(variable.asType())
val fieldsInArgument = ElementFilter.fieldsIn(variableAsElement.enclosedElements)

val annotationArgs = method.getAnnotation(BindField::class.java).viewIds

Let’s take a look at how we can generate functions

val funcBuilder = FunSpec.builder("bindFields")
    	.addModifiers(KModifier.PUBLIC)
    	.addParameter(variable.simpleName.toString(), variableAsElement.asType().asTypeName())
    	.addParameter(method.getAnnotation(BindField::class.java).viewName, ClassName("android.view", "View"))

annotationArgs.forEachIndexed { index, viewId ->
	funcBuilder.addStatement(
        	"%L.findViewById<%T>(R.id.%L).text = %L.%L",
        	method.getAnnotation(BindField::class.java).viewName,
        	ClassName("android.widget", "TextView"),
        	viewId,
        	variable.simpleName,
        	fieldsInArgument[index].simpleName
	)
}

Finally We can add this function to a FileSpec and then generate a File to the correct folder

val file = File(kaptKotlinGeneratedDir as String).apply { mkdir() } 
FileSpec.builder(packageOfMethod, "BindFieldsGenerated")
    .addFunction(funcBuilder.build())
    .build()
    .writeTo(file)

Using your new Annotation Processor

Now that we have a working annotation processor, we can finally include it inside of a project. You can do this by adding the annotation as a compile-time dependency in the project and adding the annotation processor using the kapt configuration.

dependencies {
    compileOnly project(path: ':annotation')
    kapt project(':processor')
}

Now add the Kotlin-generated files folder to your source set. This is done via sourceSets block in your module’s build.gradle. The srcDir is the path to which the files were generated which was indicated by the kapt.kotlin.generated option. If you do not add this block, you’ll be flagged by a method not found error message in the IDE.

android {

    …

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

Now you can sync and make your app module as you normally would.

We can now invoke the annotation processor inside of a viewholder using it to bind views to fields.

class MainItemViewHolder(itemView: View) : BaseViewHolder<Bill>(itemView) {

	@BindField(viewIds = ["name", "amount"], viewName = "itemView")
	override fun bind(item: Bill) {
    		bindFields(item, itemViewHere)
	}

}

We’ve seen how easy it is to make our own annotation processor and how the different components fit together. The full source and sample project is available here.