Iβve always been curious on what is behind an annotation. As much as they made my angry, believe me they are so fun. This is my experience as a beginner on the Annotation Processing.
I will give my own definition for an annotation:
An annotation is just a way to mark a class, field, another annotation, method etc. Why? It just tells that the marked component has a special attribute. But how do you handle it?
The way to handle an Annotation is through generating code at compile time. This has always intrigued me. Why? This depends on the use case. Have you ever thought how Dagger2 knows what dependencies you are using? Or perhaps how Butterknife knows how to bind views or set an onClickListener
? Yep, generating code at compile time. The process of generating code at compile time to handle the annotations is called Annotation Processing . I will do a simple use case, explaining all whatβs happening.
The use case:
I hate to type:
activity.getSupportFragmentManager()
.beginTransaction()
.replace(R.id.someId,SomeFragment.newInstance())
.commit()
for each fragment that I need to start. Someone here would be right to say that what do we need the annotations for, but this article is for βacademicalβ reasons, not for providing some best practice or refactor.
Setup:
First of all, you must know that creating an annotation and handling it, you need separated modules. Thatβs because we need to tell gradle
that there is some code that needs to be imported and the other code that works as a compiler. To create a new module in Android Studio go to File->New->Module and select Java Library.
Note, Android Library is not necessary in this case. Choose Android library when you are creating a custom view class or whatever.
So letβs go :
I have created 2 more modules except from my current Android app module. I named my modules Browser since they I will basically navigate through app fragments.
Letβs start with the easy one: Create the desired annotation:
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.CLASS)
annotation class BindFragment
There are 2 important things here: The @Target
which does the check if I have annotated my desired component, in my case a CLASS
. If I annotate a method or a filed it will show an error, and the @Retention
which determines how an annotation is stored in binary output (from Kotlin documentation). There are 3 kinds of AnnotationRetention
s:
For the moment, Iβm just letting it to source because I donβt need any of those other options.
Go to the browser_compiler
and open its' build.gradle
to import these libraries:
dependencies {
//other dependencies here
implementation "me.eugeniomarletti.kotlin.metadata:kotlin-metadata:1.4.0"
implementation project(':browser') //importing the other module in here, in order to handle it.
implementation 'com.squareup:kotlinpoet:1.2.0'
implementation 'com.google.auto.service:auto-service:1.0-rc4'
kapt 'com.google.auto.service:auto-service:1.0-rc4'
}
Note: donβt forget to add the apply plugin: βkotlin-kaptβ ,otherwise, forget about a successful build.
Create a class and extend AbstractProcessor
(overriding the methods of course). Since I am using kotlin metadata library, I am extending the KotlinAbstractProcessor
but there is no difference between them, except from accessing some fields directly on this case.
class BrowserProcessor : KotlinAbstractProcessor() {
private val annotation = BindFragment::class.java
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
return false
}
override fun getSupportedAnnotationTypes(): Set<String> = setOf(annotation.canonicalName)
override fun getSupportedSourceVersion(): SourceVersion = SourceVersion.latest()
}
Work:
The work will happen in the process method. This is where the magic happens:
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
roundEnv.getElementsAnnotatedWith(annotation).forEach { annotatedElement ->
if (annotatedElement.kind == ElementKind.CLASS) {
val pack = elementUtils.getPackageOf(annotatedElement).toString()
val annotatedClassName = annotatedElement.simpleName.toString()
startClassGeneration(pack, annotatedClassName)
} else {
messager.printMessage(Diagnostic.Kind.ERROR, "Cannot annotate anything but a class")
}
}
return false
}
Our elementUtils.getPackageOf(annotatedElement).toString()
will just provide us the annotated class package name, while the annotatedElement.simpleName().toString()
will just provide us the annotated class name. Now letβs jump to the startClassGeneration
method. I am using the KotlinPoet library to generate kotlin code on the compile time, but it is not mandatory, you can also type simple strings as long as it is correctly used without and without compile errors. This is the code:
private fun startClassGeneration(pack: String, annotatedClassName: String) {
val fileName = "${annotatedClassName}Browser"
val contextPackager = ClassName("android.support.v7.app", "AppCompatActivity")
val callerMethod = FunSpec.builder("start$annotatedClassName")
.addParameter("activity", contextPackager)
.addParameter("resourceIdToBeReplaced", Int::class)
.returns(Int::class)
.addCode(
"""
return activity.getSupportFragmentManager()
.beginTransaction()
.replace(resourceIdToBeReplaced,$annotatedClassName.newInstance())
.commit()
""".trimIndent()
).build()
val generatedClass = TypeSpec.objectBuilder(fileName).addFunction(callerMethod).build()
val generatedFile = FileSpec.builder(pack, fileName).addType(generatedClass).build()
val kaptKotlinGeneratedDir = options[KOTLIN_DIRECTORY_NAME]
generatedFile.writeTo(File(kaptKotlinGeneratedDir, "$fileName.kt"))
}
Details are important:
We must annotate this generator class with the @AutoService(Processor::class)
. It just creates a registration file for this custom processor. Not providing it will force you to include your processor manually in the META-INF
directory. That way it can be accessed through all the project.
Full processor file:
@AutoService(Processor::class)
class BrowserProcessor : KotlinAbstractProcessor() {
private val annotation = BindFragment::class.java
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
roundEnv.getElementsAnnotatedWith(annotation).forEach { annotatedElement ->
if (annotatedElement.kind == ElementKind.CLASS) {
val pack = elementUtils.getPackageOf(annotatedElement).toString()
val annotatedClassName = annotatedElement.simpleName.toString()
startClassGeneration(pack, annotatedClassName)
} else {
messager.printMessage(Diagnostic.Kind.ERROR, "Cannot annotate anything but a class")
}
}
return false
}
override fun getSupportedAnnotationTypes(): Set<String> = setOf(annotation.canonicalName)
override fun getSupportedSourceVersion(): SourceVersion = SourceVersion.latest()
private fun startClassGeneration(pack: String, annotatedClassName: String) {
val fileName = "${annotatedClassName}Browser"
val contextPackager = ClassName("android.support.v7.app", "AppCompatActivity")
val callerMethod = FunSpec.builder("start$annotatedClassName")
.addParameter("activity", contextPackager)
.addParameter("resourceIdToBeReplaced", Int::class)
.returns(Int::class)
.addCode(
"""
return activity.getSupportFragmentManager()
.beginTransaction()
.replace(resourceIdToBeReplaced,$annotatedClassName.newInstance())
.commit()
""".trimIndent()
).build()
val generatedClass = TypeSpec.objectBuilder(fileName).addFunction(callerMethod).build()
val generatedFile = FileSpec.builder(pack, fileName).addType(generatedClass).build()
val kaptKotlinGeneratedDir = options[KOTLIN_DIRECTORY_NAME]
generatedFile.writeTo(File(kaptKotlinGeneratedDir, "$fileName.kt"))
}
companion object {
const val KOTLIN_DIRECTORY_NAME = "kapt.kotlin.generated"
}
}
The moment of truth:
Go to your build.gradle
module app and import both your modules, the browser
module as an implementation and the browser_compiler
as the processor using kapt
. Note to include kotlin-kapt
here also.
implementation project(':browser')
kapt project(':browser_compiler')
Annotate your Fragments with your created annotation:
@BindFragment //Hello Annotation :)
class FirstFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_first, container, false)
}
companion object{
fun newInstance() = FirstFragment()
}
}
Annotate your second fragment, which you will navigate after a button click in the first fragment:
@BindFragment
class SecondFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_second, container, false)
}
companion object {
@JvmStatic
fun newInstance() = SecondFragment()
}
}
Hit BUILD on your IDE and go to MainActivity
to open the FirstFragment
.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
FirstFragmentBrowser.startFirstFragment(this , R.id.fl_fragment_holder) //it worked
}
}
And the FirstFragment
, on the button click will have this code:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
button.setOnClickListener {
SecondFragmentBrowser.startSecondFragment(activity as AppCompatActivity , R.id.fl_fragment_holder)
}
}
Done!
Conclusion:
There is plenty to learn on generating code at compile time and this includes me too. I want to start creating a real library, once I feel confident on this.
Happy processing!
You can find me on my personal blog:
Top comments (0)