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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Write a Symbol Processor with Kotlin Symbol Processing
25 mins
- Getting Started
- Project Structure
- Exploring Code Generating Techniques
- KAPT
- Kotlin Compiler Plugin
- KSP
- Getting Familiar with Annotations
- Defining your own Annotation
- Annotating the Fragment
- Addressing Symbol Processors
- Adding a Processor
- Defining a Provider
- Registering the Provider
- Processing Annotations
- Filtering Annotations
- Validating Symbols
- Adding Hierarchy Checks
- Validating Annotation Data
- Using the Validator
- Generating the Fragment Factory
- Creating a Visitor
- Using KotlinPoet for Code Generation
- Creating the Factory Generator
- Generating the Factory Code
- Updating the Visitor to Generate Code
- Integrating Processed Code
- Where to Go From Here?
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.
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:
- 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.
- 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. -
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:
- You’re extracting the
packageName
that represents the package for the factory class you’re building. - You’re also storing the
factoryName
that is the fragment name with a “Factory” suffix — DetailFragmentFactory. - 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.
- This adds the import for Android’s Bundle class.
- This statement begins the companion object definition so you access this factory statically, i.e.
DetailFragmentFactory.create(...)
- 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.
- This signifies the beginning of
create
function definition which is added inside the companion object. - You're defining the
create
function to return the annotated fragment type. - The
create
function accepts a single parameter that would be the Parcelable class instance. - 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. - 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:
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:
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.
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:
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.
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.