Write a Symbol Processor with Kotlin Symbol Processing

Learn how to get rid of the boilerplate code within your app by using Kotlin Symbol Processor (KSP) to generate a class for creating Fragments By Anvith Bhat.

Login to leave a rating/review
Download materials
Save for later
Share

Learn how to get rid of the boilerplate code within your app by using Kotlin Symbol Processor (KSP) to generate a class for creating Fragments

Kotlin Symbol Processing (KSP) enables you to add code generation capability across your app. KSP leverages annotations to build lightweight compiler plug-ins. You can use it to generate boilerplate or add powerful functionalities to your code.

Note: This tutorial assumes you’re familiar with Android development and Android Studio. If not, go to Beginning Android Development and Kotlin for Android.

This topic also requires basic knowledge of annotations. If you’re not familiar with this topic, read Annotations: Supercharge Your Development tutorial first.

In this tutorial, you’ll build a symbol processor that generates a factory class for Fragment. The factory class lets you pass data to the Fragment via a Bundle during instantiation.

You’ll also learn other details:

  • Configuring an annotation.
  • How KSP views your code.
  • Passing arguments to the processor and logging traces.

Getting Started

Click Download Materials at the top or bottom of this tutorial and then open the starter project in Android Studio.

Project Structure

Built Starter Project

You’ll now notice three modules:

  • Annotation: This holds your annotation class.
  • Processor: This contains KSP code generation logic.
  • App: This is the Android app that consumes the generated files.

These modules are preconfigured with dependencies. The overall dependency structure is as shown below.

Annotation-Processor-App dependencies

Both :app and :processor module include the :annotation module. Furthermore, the :app module also depends on the :processor module.

Build and run.

Shuffle Screen

The image above is the ShuffleFragment. Clicking the button selects a random pokemon.

Detail Screen

This is the DetailFragment that shows the details of the pokemon. The shuffled pokemon is passed to the DetailFragment via a Bundle. Our aim is to replace createDetailFragment function in DetailFragment with a generated one.

Before you jump into updating the project, it’s helpful to understand what KSP and code generation is about.

Exploring Code Generating Techniques

You can generate code for Kotlin sources in three ways:

KAPT

Kotlin Annotation Processing Tool — or kapt — is a code generation solution that makes Java’s annotationProcessor work for Java and Kotlin files. While it’s easy to transition to, it relies on extracting Java entities from Kotlin source files that the processor can understand. This makes it slower for Kotlin files.

Kotlin Compiler Plugin

Kotlin Compiler Plugins are modules that have access to low-level APIs of the Kotlin compiler. Most frequently, they’re used to generate code; however, they can also modify existing bytecode and provide richer functionalities to existing code.

A good example is the Parcelize plug-in that generates Parcelable implementations for data classes. This approach has a few drawbacks, including the compiler APIs changing frequently and maintenance getting difficult. Additionally, these compiler APIs aren’t documented well, so working with them gets harder.

KSP

KSP tries to bridge the gap between writing compiler plug-ins and maintainability. Think of it as a layer protecting your code generator from compiler API changes. That also means some functionality of compiler plug-ins would not be available. KSP is Kotlin First, which means it recognizes Kotlin syntax. That makes it faster because it doesn’t rely on extracting Java entities.

Now that you know more about KSP, let’s start by updating the project with annotations.

Getting Familiar with Annotations

Annotations are the entry points within your source code. Most code generation tools rely on it. KSP works on the same foundation.

Defining your own Annotation

You’ll begin by defining an annotation class. That serves as a way to look up Fragments that need a factory. Head to the :annotation module in your project and add a new file called FragmentFactory.kt.

add ksp annotation file
Next, add the annotation declaration to it:

package com.yourcompany.fragmentfactory.annotation

import kotlin.annotation.AnnotationRetention.SOURCE
import kotlin.reflect.KClass
@Target(AnnotationTarget.CLASS) //1
@Retention(SOURCE) //2
annotation class FragmentFactory(val type: KClass<*>, val parcelKey: String)//3

Let’s go over this step by step:

  1. This declaration indicates your annotation should be used on classes. Because you’re interested in Fragments only, this would be the correct choice. Other options are on a class PROPERTY and a FUNCTION.
  2. SOURCE value for retention means you wish FragmentFactory to be available only at compilation time and not within your APK.
  3. The type parameter provides you the class type of the object that needs to be parcelled. The parcelKey is the key that would be used for storing the serialized data in the Bundle.

Annotating the Fragment

Open DetailFragment and add the content below just above the fragment declaration.

import com.yourcompany.fragmentfactory.DetailFragment.Companion.KEY_POKEMON
import com.yourcompany.fragmentfactory.annotation.FragmentFactory
@FragmentFactory(Pokemon::class,KEY_POKEMON)

This makes the DetailFragment discoverable to your processor via the annotation. You’re now ready to write the processor that reads this annotation.

Addressing Symbol Processors

KSP invokes Symbol Processors during the compilation phase. All the logic of filtering KSP tokens and code generation happens within them.

Adding a Processor

You’ll start by navigating to the package com.yourcompany.fragmentfactory.processor in the :processor module of the project. Next, create a FragmentFactoryProcessor.kt file in it.

add fragment factory processor

Follow up by adding the processor declarations to it.

package com.yourcompany.fragmentfactory.processor

import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.symbol.KSAnnotated

class FragmentFactoryProcessor(
  private val logger: KSPLogger,
  codeGenerator: CodeGenerator
) : SymbolProcessor {

  override fun process(resolver: Resolver): List<KSAnnotated> {
    logger.info("FragmentFactoryProcessor was invoked.")
    return emptyList()
  }
}

Your symbol processor will extend the SymbolProcessor class and implement a process method. Kotlin symbol processing happens in multiple rounds. In each round, process() can return a list of symbols that aren’t available or will be processed in future rounds. This is called deferred processing and enables multiple processors to play well with each other when one is dependent on the output of another.

Defining a Provider

In KSP, Provider is just a factory of your processor. You’ll generally return an instance of your processor here. Go ahead and add a new file called FragmentFactoryProcessorProvider.kt under the existing package com.yourcompany.fragmentfactory.processor. This will reside in the :processor module as well.

Add Fragment Factory provider

Next, add the following code to it:

package com.yourcompany.fragmentfactory.processor

import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.processing.SymbolProcessorProvider

class FragmentFactoryProcessorProvider : SymbolProcessorProvider {

  override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
    return FragmentFactoryProcessor(
      logger = environment.logger,
      codeGenerator = environment.codeGenerator
    )
  }
}

The create function is invoked whenever KSP needs to create an instance of your SymbolProcessor. This gives you access to the environment which provides the default logger. The codeGenerator provides methods for creating and managing files. Furthermore, only the files that are created from it are available to KSP for incremental processing and compilations.

Registering the Provider

The last step to set up your processor is to register its provider. This is done by defining its qualified reference in a special file in the src/main/resources/META-INF/services directory. So navigate to the folder as shown below.

Navigate to Meta Services folder

Create a file named com.google.devtools.ksp.processing.SymbolProcessorProvider with the content below:

com.yourcompany.fragmentfactory.processor.FragmentFactoryProcessorProvider

KSP will use this reference to locate your provider. This is similar to how Android locates your activity path by reading the AndroidManifest.xml file.

Next, move to the app/build.gradle file and add the following line in the dependencies section:

 ksp(project(":processor"))

Include Processor as KSP dependency in app module

This allows the processor to process :app module’s source files.

Kudos, you’ve just configured your first SymbolProcessor. Now, open the Terminal tab in Android Studio and run the following command:

./gradlew clean installDebug --info

You should see a build output similar to the one below:

Processor linked confirmation

You’ll also see a ksp directory inside the build folder of your :app module.

KSP Folder is present in generated folder

Processing Annotations

Before you begin processing and filtering annotations, it’s important to understand how KSP looks at your code. The diagram below shows a bridged version of how KSP models the source code.

How KSP looks at your code

One thing to notice here is how a class declaration statement maps to a KSClassDeclaration node. This will contain more nodes representing the elements that form the body of the class like functions and properties. KSP builds a tree of these nodes from your source code which is then available to your SymbolProcessor. All the classes you define in Android and pretty much every Kotlin entity is available as a list of symbols to the processor.

Filtering Annotations

Since you’re only concerned about Fragments annotated with FragmentFactory you’d want to filter through all the symbols provided. Start by adding the following imports to the FragmentFactoryProcessor class:

import com.google.devtools.ksp.validate
import com.yourcompany.fragmentfactory.annotation.FragmentFactory

Next, replace the process function in the same class with the following code:

 override fun process(resolver: Resolver): List<KSAnnotated> {
    var unresolvedSymbols: List<KSAnnotated> = emptyList()
    val annotationName = FragmentFactory::class.qualifiedName

    if (annotationName != null) {
      val resolved = resolver
        .getSymbolsWithAnnotation(annotationName)
        .toList()     // 1
      val validatedSymbols = resolved.filter { it.validate() }.toList()     // 2
      validatedSymbols
        .filter {
          //TODO: add more validations
          true
        }
        .forEach {
          //TODO: visit and process this symbol
        }     // 3
      unresolvedSymbols = resolved - validatedSymbols     //4
    }
    return unresolvedSymbols
  }

Here’s a summary of the code above:

  1. The getSymbolsWithAnnotation fetches all the symbols annotated with the FragmentFactory annotation. You can also use getClassDeclarationByName, getDeclarationsFromPackage when your processor relies on logic outside annotation targets.
  2. Here you use the default validate function offered by KSP to filter symbols in the scope that can be resolved. This is done internally using a KSValidateVisitor that visits each declaration and resolves all type parameters.
  3. This statement attempts to process each of the valid symbols for the current round. You’ll add the processing code in a bit, but for now, the placeholder comments will do the job.
  4. Finally, you return all the unresolved symbols that would need more rounds. In the current example, this would be an empty list because all the symbols should resolve in the first round.

Your class will now look similar to this:

Processor filtering skeleton code

Validating Symbols

KSP offers a validator out of the box that ensures a symbol is resolvable. However, as a common use case, you’ll need to validate the inputs of your annotation.

Start by creating a file called SymbolValidator.kt in the com.yourcompany.fragmentfactory.processor.validator package.

add validator file

Now add the following code so you’ve got a validator ready to go:

package com.yourcompany.fragmentfactory.processor.validator

import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSAnnotation
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSType
import com.google.devtools.ksp.validate
import com.yourcompany.fragmentfactory.annotation.FragmentFactory

class SymbolValidator(private val logger: KSPLogger) {

  fun isValid(symbol: KSAnnotated): Boolean {
    return symbol is KSClassDeclaration //1
        && symbol.validate() //2
  }

}

The validator exposes an isValid function that

  1. Check whether the symbol was annotated on a class.
  2. Ensures it’s resolvable using the default validator that KSP provides. You’ll soon update this with more checks.

Adding Hierarchy Checks

One of the first validations would be to check whether the annotation target should be a Fragment. Another would be to ensure that the bundled class is a Parcelable. Both these conditions require you to verify the hierarchy.

So add an extension function for it in the SymbolValidator class:


private fun KSClassDeclaration.isSubclassOf(
    superClassName: String, //1
): Boolean {
    val superClasses = superTypes.toMutableList() //2
    while (superClasses.isNotEmpty()) { //3
      val current = superClasses.first()
      val declaration = current.resolve().declaration //4
      when {
        declaration is KSClassDeclaration 
         && declaration.qualifiedName?.asString() == superClassName -> { //5
          return true
        }
        declaration is KSClassDeclaration -> { 
          superClasses.removeAt(0) //6
          superClasses.addAll(0, declaration.superTypes.toList())
        }
        else -> { 
          superClasses.removeAt(0) //7
        }
      }
    }
    return false //8
  }

That seems like a lot of code, but what it’s doing is straight-forward:

  1. The function accepts a fully qualified class name as superClassName.
  2. This statement retrieves all the superclasses of the current class.
  3. You initiate a loop that exits when there are no superclasses to process.
  4. This resolves a class declaration. In KSP, resolving a symbol retrieves more qualified data about it. This is a costly affair, so it’s always done explicitly.
  5. This checks whether the first superclass’s fully qualified name matches. If so, it exits and returns true.
  6. If it doesn’t match and it’s another class, you remove the current class from the list and add the supertypes of that to the current list of supertypes.
  7. If it’s not a class, then remove the current class from the list.
  8. The code terminates and returns false when it’s traveled to the top of the class hierarchy and there are no matches.

Add a function to retrieve FragmentFactory annotation from the class declaration token immediately below isSubclassOf function:

private fun KSClassDeclaration.getFactoryAnnotation(): KSAnnotation {
    val annotationKClass = FragmentFactory::class
    return annotations.filter {
      it.annotationType
     .resolve()
     .declaration.qualifiedName?.asString() == annotationKClass.qualifiedName
    }.first()
}

The code above loops through all annotations on the class and finds the one whose qualified name matches that of FragmentFactory.

Validating Annotation Data

Now that you’ve got a way to extract the annotation, it’s time to validate the data tagged with it. You’ll start by verifying whether the class to be bundled is a Parcelized class.
Append the code below right after the getFactoryAnnotation function in SymbolValidator:

private fun KSClassDeclaration.isValidParcelableData(): Boolean {
    val factorAnnotation = getFactoryAnnotation()
    val argumentType = (factorAnnotation.arguments.first().value as? KSType) 
    //1
    val argument = argumentType?.declaration as? KSClassDeclaration
    val androidParcelable = "android.os.Parcelable" //2
    if (argument == null || !argument.isSubclassOf(androidParcelable)) { //3
      logger.error(
         "FragmentFactory parameter must implement $androidParcelable"
      ) //4
      return false
    }
    val parcelKey = (factorAnnotation.arguments[1].value as? String) //5
    if (parcelKey.isNullOrBlank()) { //6
      logger.error("FragmentFactory parcel key cannot be empty")//7
      return false
    }
    return true //8
}

Here’s what this does.

  1. The argumentType stores the type of the first argument passed to the annotation.
  2. You’ll use the qualified class name of Parcelable to check hierarchy.
  3. You check whether the argument passed is a class declaration. Also, check whether it’s a subclass of Parcelable.
  4. If the check fails, you log an error.
  5. You retrieve the parcelKey parameter.
  6. You ensure that this key isn’t empty.
  7. If that fails, log an error notifying that the parcelKey needs to be supplied.
  8. Because all checks pass, you return true.
Note: Logging an error using the KSP Logger terminates processing after the current round. Other SymbolProcessors can still continue with the current round, however once the round is over, KSP terminates all processing.

The last check required is to determine that the annotated class is a Fragment. Add the code below to the end of SymbolValidator class:

private fun KSClassDeclaration.isFragment(): Boolean {
  val androidFragment = "androidx.fragment.app.Fragment"
  return isSubclassOf(androidFragment)
}

This uses a fully qualified name check for the Fragment class.

Phew! That’s a lot of validations. It’s time to collate them. Replace the isValid function defined in the SymbolValidator:

fun isValid(symbol: KSAnnotated): Boolean {
    return symbol is KSClassDeclaration
        && symbol.validate()
        && symbol.isFragment()
        && symbol.isValidParcelableData()
}

Your validator is complete.

Using the Validator

In order to use the SymbolValidator, append the following statement within the FragmentFactoryProcessor class:

private val validator = SymbolValidator(logger)

and then replace the //TODO: add more validations block (including the true statement immediately below it.) with

validator.isValid(it)

Your FragmentFactoryProcessor class should now look like the image shown below.

using the validator

Generating the Fragment Factory

Now that your processor is set up, it’s time to process the filtered symbols and generate the code.

Creating a Visitor

The first step is to create a visitor. A KSP visitor allows you to visit a symbol and then process it. Create a new class FragmentVisitor in a package
com.yourcompany.fragmentfactory.processor.visitor and add the below code:

package com.yourcompany.fragmentfactory.processor.visitor

import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.symbol.*

class FragmentVisitor(
  codeGenerator: CodeGenerator
) : KSVisitorVoid() { //1

  override fun visitClassDeclaration(
   classDeclaration: KSClassDeclaration,
   data: Unit
  ) {
    val arguments = classDeclaration.annotations.iterator().next().arguments
    val annotatedParameter = arguments[0].value as KSType //2
    val parcelKey = arguments[1].value as String //3
  }

}

Here’s what this class is about:

  1. Every KSP visitor would extend KSVisitor. Here you’re extending a subclass of it KSVisitorVoid that is a simpler implementation provided by KSP. Every symbol that this visitor visits is signified by a call to visitClassDeclaration.
  2. You’ll create an instance of annotatedParameter in the generated code and parcel it in the bundle. This is the first argument of the annotation.
  3. parcelKey is used in the Fragment’s bundle to parcel the data. It’s available as the second argument of your annotation.

You can use this and update your FragmentFactoryProcessor:

private val visitor = FragmentVisitor(codeGenerator)

Replace the //TODO: visit and process this symbol comment with the below code:

it.accept(visitor, Unit)

The accept function internally invokes the overriden visitClassDeclaration method. This method would also generate code for the factory class.

Using KotlinPoet for Code Generation

Kotlin Poet provides a clean API to generate Kotlin code. It supports adding imports based on KSP tokens making it handy for our use case. This is easier to work with than dealing with a large block of unstructured string.

Creating the Factory Generator

Let’s start by adding a FragmentFactoryGenerator class in the com.yourcompany.fragmentfactory.processor package:

package com.yourcompany.fragmentfactory.processor

import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSType
import com.squareup.kotlinpoet.*
import com.squareup.kotlinpoet.ksp.*

@OptIn(KotlinPoetKspPreview::class)
class FragmentFactoryGenerator(
  private val codeGenerator: CodeGenerator
) {

  fun generate(
    fragment: KSClassDeclaration, 
    parcelKey: String, 
    parcelledClass: KSType
  ) {

    val packageName = fragment.packageName.asString()  //1
    val factoryName = "${fragment.simpleName.asString()}Factory"  //2
    val fragmentClass = fragment.asType(emptyList())
                       .toTypeName(TypeParameterResolver.EMPTY)  //3
    //TODO: code generation logic 
  }

}

Here’s what’s happening in the code above:

  1. You’re extracting the packageName that represents the package for the factory class you’re building.
  2. You’re also storing the factoryName that is the fragment name with a “Factory” suffix — DetailFragmentFactory.
  3. Lastly, fragmentClass is a KotlinPoet reference of your annotated fragment. You’ll direct KotlinPoet to add this to your return statements and to construct instances of the fragment.

Generating the Factory Code

Next you’ll generate the factory class. This is a fairly big chunk of code that you’ll go over in steps. Start by adding the code below to the class generation logic immediately after the line //TODO: code generation logic:

 val fileSpec = FileSpec.builder(
      packageName = packageName, fileName = factoryName
    ).apply {
      addImport("android.os", "Bundle")  //1
      addType(
        TypeSpec.classBuilder(factoryName).addType(
          TypeSpec.companionObjectBuilder() //2
           // Todo add function for creating Fragment
          .build()
        ).build()
      )
    }.build()

    fileSpec.writeTo(codeGenerator = codeGenerator, aggregating = false) //3

Let’s go through what’s happening here.

  1. This adds the import for Android’s Bundle class.
  2. This statement begins the companion object definition so you access this factory statically, i.e. DetailFragmentFactory.create(...)
  3. Using KotlinPoet's extension function, you can directly write to the file. The aggregating flag indicates whether your processor's output is dependent on new and changed files. You'll set this to false. Your processor isn't really dependent on new files being created.

Next, you'll create the function enclosed in the companion object that generates the Fragment instance. Replace
// Todo add function for creating Fragment with:

.addFunction( //1
    FunSpec.builder("create").returns(fragmentClass) //2
      .addParameter(parcelKey, parcelledClass.toClassName())//3
      .addStatement("val bundle = Bundle()")
      .addStatement(
        "bundle.putParcelable(%S,$parcelKey)", 
        parcelKey
      )//4
      .addStatement(
        "return %T().apply { arguments = bundle }",
        fragmentClass
      )//5
      .build()
)

That’s a bit of code, so let’s walk through it step by step.

  1. This signifies the beginning of create function definition which is added inside the companion object.
  2. You're defining the create function to return the annotated fragment type.
  3. The create function accepts a single parameter that would be the Parcelable class instance.
  4. This adds the statement that puts the Parcelable object within the bundle. For simplicity, the parameter name of create and the bundle key are the same: parcelKey, which is why it's repeated twice.
  5. You're adding an apply block on the fragment's instance here and subsequently setting the fragment arguments as the bundle instance.

Your code should now look similar to this:
FragmentFactory class Generator complete code

Updating the Visitor to Generate Code

In order to use the generator, create an instance of it within FragmentVisitor:

 private val generator = FragmentFactoryGenerator(codeGenerator)

Next, add the below code to the end of visitClassDeclaration:

generator.generate(
  fragment = classDeclaration,
  parcelKey = parcelKey,
  parcelledClass = annotatedParameter
)

That will invoke the code generation process.

Build and run the app. You should be able to see the generated DetailFragmentFactory.kt file in the app/build/generated/ksp/debug/kotlin/com/raywenderlich/android/fragmentfactory folder. Inspect the code. It should look similar to the screenshot below:

Generated Factory File

Integrating Processed Code

When you update the code in ShuffleFragment to use the Factory that was just generated:

DetailFragmentFactory.create(dataProvider.getRandomPokemon())

You'll be greeted with the error below.

Error while including Factory in the app

Basically, the generated files aren't a part of your Android code yet. In order to fix that, update the android section in the app's build.gradle:

sourceSets.configureEach {
    kotlin.srcDir("$buildDir/generated/ksp/$name/kotlin/")
}

Your updated build.gradle should look like this:
Add sourceset to identify KSP code

The name parameter ensures you configure the right build variant with its corresponding source set. In your Android app, this would basically be either debug or release.

Finally, build and run. You should now be able to use DetailFragmentFactory seamlessly.

KSP SourceSet Error Resolved

Where to Go From Here?

You can download the final version of this project using the Download Materials button at the top or bottom of this tutorial.

Kudos on making it through. You've managed to build a slick SymbolProcessor and along the way learned about what goes into code generation.

You can improve on this example by writing a generator that can unbundle the arguments into Fragment's fields. This would get rid of the entire ceremony involved in parceling and un-parceling data within your source code.

To learn more about common compiler plug-ins and powerful code generation APIs, consider exploring the JetBrains Plugin Repository.

I hope you've enjoyed learning about KSP. Feel free to share your thoughts in the forum discussion below.