How Annotations Work in Kotlin. Part 2
Hi! Today with you is Maxim Kruglikov from Surf Android Team, and we continue the article about annotations in Kotlin, in which we will look at the code base Moshi as an example of how a real library uses annotation processing, reflection, and lint. In the first we told about these three mechanisms – we recommend that you watch it first.
Introduction to Moshi
Moshi is a popular library for parsing JSON to/from Java or Kotlin classes. We chose it for this example because it is a relatively small library, its API includes several annotations, and uses both annotation processing and reflection.
You can connect it like this:
implementation(“com.squareup.moshi:moshi-kotlin:1.15.0”)
The simplest example of parsing JSON into a BookModel instance:
data class BookModel(
val title: String,
@Json(name = "page_count") val pageCount: Int,
val genre: Genre,
) {
enum class Genre {
FICTION,
NONFICTION,
}
}
private val moshi = Moshi.Builder().build()
private val adapter = moshi.adapter<BookModel>()
private val bookString = """
{
"title": "Our Share of Night",
"page_count": 588,
"genre": "FICTION"
}
"""
val book = adapter.fromJson(bookString)
Moshi provides several annotations to customize how classes are converted to/from JSON. In the example above, the annotation @Json
with the name parameter tells the adapter to use page_count as the key in the JSON string, even though the field is called pageCount.
Moshi works with the concept of adapter classes. An adapter is a type-safe mechanism for serializing a given class into a JSON string and deserializing the JSON string back into the desired type. By default, Moshi has built-in support for the core Java data types, primitives, collections, and strings, as well as the ability to adapt other classes by writing them out field by field.
Moshi can generate adapters either at compile time using annotation processing, or at runtime using reflection, depending on what dependencies we include. Let's look at both cases.
Moshi with annotation processing
To have Moshi generate adapter classes at compile time using annotation processing, you need to add or kapt(“com.squareup.moshi:moshi-kotlin-codegen:1.15.0”)
for capt, or ksp(“com.squareup.moshi:moshi-kotlin-codegen:1.15.0”)
for ksp.
Moshi will generate an adapter for each class labeled @JsonClass (generateAdapter = true)
. For example, like this:
@JsonClass(generateAdapter = true)
data class BookModel(
val title: String,
@Json(name = "page_count") val pageCount: Int,
val genre: Genre,
) { ... }
Once the application is built, Moshi will generate a file BookModelJsonAdapter
in the catalog /build/generated/source/kapt/
. All generated adapters inherit from JsonAdapter and override its functions. toString()
, fromJSON()
And toJSON()
to work with a specific type.
And now when calling:
private val adapter = moshi.adapter<BookModel>()
Moshi.adapter()
will return us the generated BookModelJsonAdapter.
Most of Moshi's code generation logic is in AdapterGenerator
. AdapterGenerator
uses KotlinPoet to create an instance FileSpec
with a new adapter class.
Capt.
To create an annotation processor in kapt, you need to inherit from AbstractProcessor. How does Moshi extend it in JsonClassCodegenProcessor to handle the @JsonClass annotation?
The code below is related to class handling @Json
copied directly from the Moshi codebase.
@AutoService(Processor::class) // 1
public class JsonClassCodegenProcessor : AbstractProcessor() {
...
private val annotation = JsonClass::class.java
...
// 2
override fun getSupportedAnnotationTypes(): Set<String> = setOf(annotation.canonicalName)
...
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
...
// 3
for (type in roundEnv.getElementsAnnotatedWith(annotation)) {
...
val jsonClass = type.getAnnotation(annotation) // 3a
// 3b
if (jsonClass.generateAdapter && jsonClass.generator.isEmpty()) {
// 3c
val generator = adapterGenerator(type, cachedClassInspector) ?: continue
val preparedAdapter = generator
.prepare(generateProguardRules) { … }
.addOriginatingElement(type)
.build()
preparedAdapter.spec.writeTo(filer) // 3d
preparedAdapter.proguardConfig?.writeTo(filer, type) // 3e
}
return false // 4
}
}
Nessesary to use
@Autoservice
for registrationJsonClassCodeGenProcessor
in the compiler.The function needs to be redefined
getSupportedAnnotationTypes()
to announce support for our annotation processor@JsonClass
.IN
process()
it is necessary to go through all the elementsTypeElements
marked@JsonClass
and for each of them:Get
JsonClass
for the current type;Use fields
generateAdapter
and generator fromJsonClass
to understand whether an adapter should be generated;Create
AdapterGenerator
for the current type;Write down
FileSpec
generatedAdapterGenerator
to file usingFiler
;Write configuration
Proguard
generatedAdapterGenerator
to file usingFiler
.
Return false
at the end process()
to indicate that this processor did not use the set TypeElements
passed into it. This allows other processors to also use Moshi annotations.
KSP
Annotation processors in KSP inherit from SymbolProcessor
. KSP also requires a class that implements SymbolProcessorProvider
as an entry point for instantiation SymbolProcessor
. Let's see how JsonClassSymbolProcessorProvider
from Moshi processes @JsonClass
.
@AutoService(SymbolProcessorProvider::class) // 1
public class JsonClassSymbolProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return JsonClassSymbolProcessor(environment) // 2
}
}
private class JsonClassSymbolProcessor(
environment: SymbolProcessorEnvironment,
) : SymbolProcessor {
private companion object {
val JSON_CLASS_NAME = JsonClass::class.qualifiedName!!
}
...
override fun process(resolver: Resolver): List<KSAnnotated> {
// 3
for (type in resolver.getSymbolsWithAnnotation(JSON_CLASS_NAME)) {
...
// 3a
val jsonClassAnnotation = type.findAnnotationWithType<JsonClass>() ?: continue
val generator = jsonClassAnnotation.generator
// 3b
if (generator.isNotEmpty()) continue
if (!jsonClassAnnotation.generateAdapter) continue
try {
val originatingFile = type.containingFile!!
val adapterGenerator = adapterGenerator(logger, resolver, type) ?: return emptyList()// create an AdapterGenerator for the current type
// 3c
val preparedAdapter = adapterGenerator
.prepare(generateProguardRules) { spec ->
spec.toBuilder()
.addOriginatingKSFile(originatingFile)
.build()
}
// 3d
preparedAdapter.spec.writeTo(codeGenerator, aggregating = false)
// 3e
preparedAdapter.proguardConfig?.writeTo(codeGenerator, originatingFile)
} catch (e: Exception) {
logger.error(...)
}
}
return emptyList() // 4
}
}
It is necessary to use
@Autoservice
for registrationJsonClassSymbolProcessorProvider
in the compiler.Should be redefined
JsonClassSymbolProcessorProvider.create()
to return the instanceJsonClassSymbolProcessor
.IN
process()
need to go through all of themKsAnnotated
symbols marked with@JsonClass
and for each of them:Get
JsonClass
for the current symbol.Use fields
generateAdapter
Andgenerator
fromJsonClass
to understand whether an adapter should be generated;Create
AdapterGenerator
for the current type.Write down
FileSpec
generatedAdapterGenerator
to file usingCodeGenerator
.Write generated
AdapterGenerator
Proguard configuration for the current type to a file usingCodeGenerator
.
Return empty list at the end
process()
to indicate that the processor does not leave any symbols for later rounds.
Moshi also registers a Json class code generation processor in the incremental.annotation.processors file so that it works with incremental processing.
JsonClassCodegenProcessor
And JsonClassCodegenProcessor
turned out to be very short and readable: you can create a very useful custom annotation processor without a lot of code. And since most of the code generation logic is in an API independent of the main one AdapterGenerator
adding KSP support to Moshi didn't require much additional effort. The steps for adding both annotation processors were almost identical.
Moshi with reflection
You can achieve the same behavior when parsing JSON using reflection. To do this, you need to add the following dependency:
implementation(“com.squareup.moshi:moshi-kotlin:1.15.0”)
No need to mark anymore BookModel
by using @JsonClass
because this annotation is only needed for code generation. Instead, you need to add KotlinJsonAdapterFactory
when creating Moshi.
KotlinJsonAdapterFactory
— is a general-purpose adapter factory that can use reflection to create at runtime JsonAdapter
for any Kotlin class.
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
Now when it is called Moshi.adapter()
it returns an adapter for BookModel
created with the help of KotlinJsonAdapterFactory
:
private val adapter = moshi.adapter<BookModel>()
When calling Moshi.adapter<T>()
iterates through all available adapters and adapter factories until it finds one that supports T. Moshi comes with several built-in factories, including ones for primitives (int, float, and others) and enum, but we can add our own using MoshiBuilder().add()
. In this example KotlinJsonAdapterFactory
– the only added custom factory.
Here's how KotlinJsonAdapterFactory
processes annotation @Json
and its field jsonName
.
public class KotlinJsonAdapterFactory : JsonAdapter.Factory {
override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
val rawType = type.rawType
val rawTypeKotlin = rawType.kotlin
val parametersByName = constructor.parameters.associateBy { it.name }
try {
val generatedAdapter = moshi.generatedAdapter(type, rawType) // 1
if (generatedAdapter != null) {
return generatedAdapter
}
} catch (e: RuntimeException) {
if (e.cause !is ClassNotFoundException) {
throw e
}
}
// 2
val bindingsByName = LinkedHashMap<String, KotlinJsonAdapter.Binding<Any, Any?>>()
for (property in rawTypeKotlin.memberProperties) { // 3
val parameter = parametersByName[property.name]
var jsonAnnotation = property.findAnnotation<Json>() // 3a
...
// 3b
val jsonName = jsonAnnotation?.name?.takeUnless { it == Json.UNSET_NAME } ?: property.name
...
val adapter = moshi.adapter<Any?>(...)
bindingsByName[property.name] = KotlinJsonAdapter.Binding(
jsonName, // 3c
adapter,
property as KProperty1<Any, Any?>,
parameter,
parameter?.index ?: -1,
)
}
val bindings = ArrayList<KotlinJsonAdapter.Binding<Any, Any?>?>()
...
for (bindingByName in bindingsByName) {
bindings += bindingByName.value.copy(propertyIndex = index++)
}
return KotlinJsonAdapter(bindings, …).nullSafe() // 4
}
}
You need to check for the existence of the adapter generated by the annotation processor using
Moshi.generatedAdapter()
. If the generated adapter is not found, you need to proceed to creating a new one using reflection.Need to create
bindingsByName
— match the names of the parameters with theirBinding
'ami.Binding
contains information about the parameter name in JSON format corresponding to the adapter.It is necessary to study all the properties of a given type and for each of them:
Find annotation
@Json
for the current property;If found, set
jsonName
in fieldname
annotations (egpage_count
) as a fieldjsonName
. If it does not exist, then use the property name (eg,pageCount
) asjsonName
.Use
jsonName
while creatingBinding
'a for the current property.
Return new
KotlinJsonAdapter
with filledBinding
'ami
Now when you call toJson()
or fromJson()
Moshi will use jsonName
from bindings as a JSON field name.
Lint checks in Moshi
Moshi doesn't have lint checks by default. But luckily, Slack has publicly published some of its own Moshi-related checks just in case. These are “Prefer List over Array” And “Constructors in Moshi classes cannot be private“.
The code for these Moshi-related checks is contained in MoshiUsageDetector
. As an example of working with the UAST tree from the lint API, we will talk about the implementation of the “Prefer List over Array” rule. The rule is declared as ISSUE_ARRAY
in the companion object MoshiUsageDetector
and indicates that Moshi does not support arrays.
class MoshiUsageDetector : Detector(), SourceCodeScanner {
override fun getApplicableUastTypes() = listOf(UClass::class.java) // 1
override fun createUastHandler(context: JavaContext): UElementHandler { // 2
return object : UElementHandler() {
override fun visitClass(node: UClass) {
...
// 3
val jsonClassAnnotation = node.findAnnotation(FQCN_JSON_CLASS)
if (jsonClassAnnotation == null) return // 4
...
val primaryConstructor =
node.constructors
.asSequence()
.mapNotNull { it.getUMethod() }
.firstOrNull { it.sourcePsi is KtPrimaryConstructor }
...
for (parameter in primaryConstructor.uastParameters) { // 5
val sourcePsi = parameter.sourcePsi
if (sourcePsi is KtParameter && sourcePsi.isPropertyParameter()) {
val shouldCheckPropertyType = ...
if (shouldCheckPropertyType) {
// 5a
checkMoshiType(
context,
parameter.type,
parameter,
parameter.typeReference!!,
...
)
}
}
}
}
}
}
private fun checkMoshiType(
context: JavaContext,
psiType: PsiType,
parameter: UParameter,
typeNode: UElement,
...
) {
if (psiType is PsiPrimitiveType) return
if (psiType is PsiArrayType) { // 6
...
context.report(
ISSUE_ARRAY,
context.getLocation(typeNode),
ISSUE_ARRAY.getBriefDescription(TextFormat.TEXT),
quickfixData =
fix()
.replace()
.name("Change to $replacement")
...
.build()
)
return
}
... // 7
}
companion object {
private const val FQCN_JSON_CLASS = "com.squareup.moshi.JsonClass"
...
private val ISSUE_ARRAY =
createIssue(
"Array",
"Prefer List over Array.",
"""
Array types are not supported by Moshi, please use a List instead…
"""
.trimIndent(),
Severity.WARNING,
)
...
}
}
Function
getApplicableUastTypes()
returnsUClass
to run the detector on all classes in the source code.createUastHandler()
returnsUElementHandler
which goes into each node of the class. The remaining steps are performed invisitClass()
.Need to find annotation
@JsonClass
in the current class.A return should be executed if the annotation is not found.
You need to go through the main parameters of the node constructor and for each of them:
Call
checkMoshiType()
for a parameter if it passes multiple checks.
IN
checkMoshiType()
you need to call the report method if the specified type is an array.Function
checkMoshiType()
makes several recursive calls that are not included in the article for the sake of brevity.
According to step 4, all checks are performed only for classes annotated with @JsonClass
. It means that MoshiUsageDetector
will only work with source code that uses the Moshi version for annotation processing.
Conclusion
In this article, you will find several code snippets that may be useful to you. There was less code than you might expect from a library: writing a custom annotation processor, reflection code, or lint rules turned out to be less difficult than you might think.
We hope that the examples in this article will motivate you to explore this topic further and not be afraid to create your own annotations.
More useful information about Android is available in the Surf Android Team Telegram channel.
Cases, best practices, news and vacancies for the Android Surf team in one place. Join us!