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.

5 (2) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 3 of 3 of this article. Click here to view the first page.

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.

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.

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.