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
Version
- Kotlin 1.6, Android 6.0, Android Studio 2021.2.1

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.
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
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.
Both :app and :processor module include the :annotation module. Furthermore, the :app module also depends on the :processor module.
Build and run.
The image above is the ShuffleFragment
. Clicking the button selects a random pokemon.
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.
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:
-
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 aFUNCTION
. -
SOURCE
value for retention means you wish FragmentFactory to be available only at compilation time and not within your APK. -
The
type
parameter provides you the class type of the object that needs to be parcelled. TheparcelKey
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.
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.
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.
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"))
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:
You’ll also see a ksp
directory inside the build folder of your :app module.
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.
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:
- The
getSymbolsWithAnnotation
fetches all the symbols annotated with the FragmentFactory annotation. You can also usegetClassDeclarationByName
,getDeclarationsFromPackage
when your processor relies on logic outside annotation targets. - 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 aKSValidateVisitor
that visits each declaration and resolves all type parameters. - 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.
- 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:
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.
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
- Check whether the symbol was annotated on a class.
- 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:
- The function accepts a fully qualified class name as
superClassName
. - This statement retrieves all the superclasses of the current class.
- You initiate a loop that exits when there are no superclasses to process.
- 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.
- This checks whether the first superclass’s fully qualified name matches. If so, it exits and returns true.
- 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.
- If it’s not a class, then remove the current class from the list.
- 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.
- The
argumentType
stores the type of the first argument passed to the annotation. - You’ll use the qualified class name of
Parcelable
to check hierarchy. - You check whether the argument passed is a class declaration. Also, check whether it’s a subclass of Parcelable.
- If the check fails, you log an error.
- You retrieve the
parcelKey
parameter. - You ensure that this key isn’t empty.
- If that fails, log an error notifying that the
parcelKey
needs to be supplied. - Because all checks pass, you return true.
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.
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.
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:
- 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.
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.
- 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.
Comments