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?
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.