Blog Infos
Author
Published
Topics
, , , ,
Published

This is my second article on Kotlin annotations, where I’ll explore Moshi’s codebase as a case study for how a real-world library leverages annotations using annotation processing, reflection, and lint. Part 1 gives a high-level introduction to the three mechanisms, and I recommend reading it first.

Introduction to Moshi

Moshi is a popular library for parsing JSON to and from Java or Kotlin classes. I chose it for this case study because it’s a relatively small library whose API includes several annotations, and it supports both annotation processing and reflection.

It’s available through the following dependency:

implementation(“com.squareup.moshi:moshi-kotlin:1.15.0”)

And its basic usage to convert a JSON string to a BookModel instance looks like this:

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 a few annotations for customizing how classes are converted to and from JSON. In the example above, the @Json annotation with its name parameter tells the adapter to use page_count as the key in the JSON string, even though the class field is named pageCount.

Moshi works with the concept of adapter classes. An adapter is a typesafe mechanism to serialize a specific class into a JSON string and to deserialize a JSON string back into the correct type. By default, Moshi has built-in support for Java’s core data types, including primitives, Collections, and Strings, and is able to adapt other classes by writing them out field-by-field.

Moshi can either generate adapters at compile time via annotation processing or at runtime via reflection depending on which dependencies we include. I’ll go over both cases.

Moshi with Annotation Processor

To have Moshi generate adapter classes at compile time via annotation processing, we need to add either:
kapt(“com.squareup.moshi:moshi-kotlin-codegen:1.15.0”) for kapt, or

ksp(“com.squareup.moshi:moshi-kotlin-codegen:1.15.0”) for ksp.

Moshi will generate an adapter for each class annotated with @JsonClass(generateAdapter = true), for example:

@JsonClass(generateAdapter = true)
data class BookModel(
  val title: String,
  @Json(name = "page_count") val pageCount: Int,
  val genre: Genre,
) { ... }

When we build the app, Moshi will generate a BookModelJsonAdapter file in the /build/generated/source/kapt/ directory. Any generated adapters will extend JsonAdapter and override its toString()fromJson(), and toJson() functions to work with the specific type.

When we call:

private val adapter = moshi.adapter<BookModel>()

Moshi.adapter() will return the generated BookModelJsonAdapter.

Most of Moshi’s codegen logic lives in AdapterGenerator, which is shared between Moshi’s kapt and KSP implementations. AdapterGenerator uses KotlinPoet to create a FileSpec with the new adapter class.

Kapt

We create annotation processors in kapt by extending AbstractProcessor. Let’s look at how Moshi extends it with JsonClassCodegenProcessor to process the @JsonClass annotation.

I copied the code below directly from the Moshi codebase, keeping only the parts related to handling@JsonClass. Feel free to read the entire file on your own if you’re interested!

@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. Use @Autoservice to register JsonClassCodeGenProcessor to the compiler
  2. Override getSupportedAnnotationTypes() to declare JsonClassCodegenProcessor’s support for @JsonClass annotations
  3. In process(), iterate through all TypeElements annotated with @JsonClassand for each:
    a) Get the JsonClass for the current type
    b) Use the JsonClass’s generateAdapter and generator fields to determine if we should generate an adapter
    c) Create an AdapterGenerator for the current type
    d) Write the AdapterGenerator’s generated FileSpec for the current type to a file using Filer
    e) Write the AdapterGenerator’s generated Proguard config for the current type to a file using Filer
  4. Return false at the end of process() to specify this processor isn’t claiming the set of annotation TypeElements passed into process. This lets other processors use the Moshi annotations as well.
KSP

Annotation processors in KSP extend SymbolProcessor. KSP also requires a class that implements SymbolProcessorProvider as the entry point for instantiating a custom SymbolProcessor. Let’s see how Moshi’s JsonClassSymbolProcessorProvider 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. Use @Autoservice to register JsonClassSymbolProcessorProvider to the compiler
  2. Override JsonClassSymbolProcessorProvider.create() to return instance of JsonClassSymbolProcessor
  3. In process(), iterate through all KsAnnotated symbols annotated with @JsonClass(see Resolver) and for each:
    a) Get the JsonClass for the current symbol
    b) Use the JsonClass’s generateAdapter and generator fields to determine if we should generate an adapter
    c) Create an AdapterGenerator for the current type
    d) Write the AdapterGenerator’s generated FileSpec for the current type to a file using CodeGenerator
    e) Write the AdapterGenerator’s generated Proguard config for the current type to a file using CodeGenerator
  4. Return an empty list at the end of process() to specify this processor isn’t deferring any symbols to later rounds

Moshi also registers JsonClassCodegenProcessor in its incremental/annotation/processors file to have it work with incremental processing.

When reading through Moshi’s codebase, I was pleasantly surprised by how short and readable JsonClassCodegenProcessor and JsonClassSymbolProcessorProvider were. We can build a very useful custom annotation processor without much code! And since the bulk of the codegen logic lives in the API-agnostic AdapterGenerator, adding KSP support to Moshi didn’t require much additional work. The high-level steps for both annotation processors are nearly identical.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

Moshi with Reflection

We can achieve the same JSON parsing behavior with reflection.

We’ll have to add the following dependency:

implementation(“com.squareup.moshi:moshi-kotlin:1.15.0”)

We no longer need to annotate BookModel with @JsonClass, because that annotation is only needed for codegen. Instead, we need to add a KotlinJsonAdapterFactory when building Moshi. KotlinJsonAdapterFactory is a general-purpose adapter factory that can create a JsonAdapter for any Kotlin class at runtime via reflection.

private val moshi = Moshi.Builder()
   .add(KotlinJsonAdapterFactory())
   .build()

Now when we call Moshi.adapter():

private val adapter = moshi.adapter<BookModel>()

It’ll return the BookModel adapter created by KotlinJsonAdapterFactory at runtime.

When invoked, Moshi.adapter<T>() iterates through all the 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, etc) and enums, and we can add additional ones using MoshiBuilder().add(). In this example, KotlinJsonAdapterFactory is the only non-default one we added.

Here’s how KotlinJsonAdapterFactory handles the @Json annotation and its jsonName field.

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. Check for an adapter generated via annotation processor first using Moshi.generatedAdapter(). Continue on to creating a reflective adapter if a generated one isn’t found.
  2. Create bindingsByName, a map of property names to their Bindings. Binding includes information about the property’s JSON name, corresponding adapter, etc.
  3. Iterate through all the properties for given Type and for each:
    a) Search for the @Json annotation on the current property.
    b) If one was found, set jsonName to the annotation’s name field (eg. page_count) as the jsonName field. If not, use the property’s name (eg. pageCount) as jsonName.
    c) Use jsonName when creating the Binding for the current property
  4. Return a new KotlinJsonAdapter with the populated bindings

When we call toJson() or fromJson(), it’ll use jsonName from the bindings as the JSON field name.

Moshi Lint Check

Moshi doesn’t include any lint checks by default, but fortunately for this case study, Slack open sourced some of their custom Moshi-related lint checks, for example “Prefer List over Array” and “Constructors in Moshi classes cannot be private”.

The code for all these Moshi-related checks live in MoshiUsageDetector. I’ll go over the implementation for the “Prefer List over Array” issue as an example of working with lint API’s UAST tree. The issue is declared as ISSUE_ARRAY in MoshiUsageDetector’s companion object, and captures that array types aren’t supported by Moshi.

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. getApplicableUastTypes() returns UClass to run detector on all classes in the source code.
  2. createUastHandler() returns a UElementHandler that visits each class node. The remaining steps happen in visitClass().
  3. Search for a @JsonClass annotation on current class node.
  4. Return early if annotation isn’t found.
  5. Iterate through the node’s primary constructor parameters and for each:
    a) Call checkMoshiType() on the parameter if if it passes a few checks that I won’t go into detail here.
  6. In checkMoshiType(), report ISSUE_ARRAY if the given type is an array.
  7. checkMoshiType() makes a few recursive calls that I didn’t include for the sake of brevity.

Based on step 4, all the checks are only performed on classes annotated with @JsonClass. This means MoshiUsageDetector will only work on source code that uses the annotation processing version of Moshi.

Closing Thoughts

This article ended up including a few walls of code…but less code than I expected from such a useful and widely-used library! Writing a custom annotation processor, reflection code, or lint check isn’t as daunting as we might think, and I hope these real-world examples empower you to go forth and build your own.

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE

Leave a Reply

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

Fill out this field
Fill out this field
Please enter a valid email address.

Menu