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 @Jsoncopied 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
 }
}
  1. Nessesary to use @Autoservice for registration JsonClassCodeGenProcessor in the compiler.

  2. The function needs to be redefined getSupportedAnnotationTypes()to announce support for our annotation processor @JsonClass.

  3. IN process() it is necessary to go through all the elements TypeElementsmarked @JsonClassand for each of them:

    1. Get JsonClass for the current type;

    2. Use fields generateAdapter and generator from JsonClassto understand whether an adapter should be generated;

    3. Create AdapterGenerator for the current type;

    4. Write down FileSpecgenerated AdapterGenerator to file using Filer;

    5. Write configuration Proguardgenerated AdapterGenerator to file using Filer.

Return false at the end process()to indicate that this processor did not use the set TypeElementspassed 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
 }
}
  1. It is necessary to use @Autoservice for registration JsonClassSymbolProcessorProvider in the compiler.

  2. Should be redefined JsonClassSymbolProcessorProvider.create()to return the instance JsonClassSymbolProcessor.

  3. IN process() need to go through all of them KsAnnotated symbols marked with @JsonClassand for each of them:

    1. Get JsonClass for the current symbol.

    2. Use fields generateAdapter And generator from JsonClassto understand whether an adapter should be generated;

    3. Create AdapterGenerator for the current type.

    4. Write down FileSpecgenerated AdapterGenerator to file using CodeGenerator.

    5. Write generated AdapterGenerator Proguard configuration for the current type to a file using CodeGenerator.

  4. 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 AdapterGeneratoradding 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 @JsonClassbecause 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 BookModelcreated 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
 }
}
  1. 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.

  2. Need to create bindingsByName — match the names of the parameters with their Binding'ami. Binding contains information about the parameter name in JSON format corresponding to the adapter.

  3. It is necessary to study all the properties of a given type and for each of them:

    1. Find annotation @Json for the current property;

    2. If found, set jsonName in field name annotations (eg page_count) as a field jsonName. If it does not exist, then use the property name (eg, pageCount) as jsonName.

    3. Use jsonName while creating Binding'a for the current property.

  4. Return new KotlinJsonAdapter with filled Binding'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,
     )
   ...
 }
}
  1. Function getApplicableUastTypes() returns UClass to run the detector on all classes in the source code.

  2. createUastHandler() returns UElementHandlerwhich goes into each node of the class. The remaining steps are performed in visitClass().

  3. Need to find annotation @JsonClass in the current class.

  4. A return should be executed if the annotation is not found.

  5. You need to go through the main parameters of the node constructor and for each of them:

    1. Call checkMoshiType() for a parameter if it passes multiple checks.

  6. IN checkMoshiType() you need to call the report method if the specified type is an array.

  7. 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!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *