Kotlin Symbol Processing
Introduction
Kotlin Symbol Processing (KSP) is a powerful compile-time API designed to process Kotlin code and generate new code during compilation. It serves as an alternative to traditional annotation processing in Java (using the Java Annotation Processing API or KAPT) but is specifically optimized for Kotlin. KSP was created by Google to address the limitations of using KAPT with Kotlin, offering better performance and a more Kotlin-friendly API.
In this tutorial, you'll learn:
- What KSP is and why it's beneficial
- How KSP compares to other annotation processing options
- How to set up and use KSP in your projects
- Creating your own KSP processors
- Real-world applications of KSP
Why Use Kotlin Symbol Processing?
Before diving into KSP, let's understand why it's an important tool in a Kotlin developer's toolkit:
- Performance: KSP is significantly faster than KAPT (sometimes up to 2x faster build times)
- Kotlin-centric: Designed specifically for Kotlin, it understands Kotlin-specific language features
- Multiplatform support: While KAPT only works with JVM, KSP can work across different Kotlin platforms
- Incremental processing: Better support for incremental compilation
KSP vs KAPT: Understanding the Difference
KAPT (Kotlin Annotation Processing Tool) was the original way to use Java annotation processors with Kotlin code. However, KAPT has several limitations:
- It converts Kotlin code to Java stubs, which is inefficient
- It doesn't understand Kotlin-specific features well
- It's slower than native annotation processing
KSP addresses these issues by working directly with Kotlin's code model, providing a more efficient and Kotlin-friendly API.
Setting Up KSP in Your Project
Let's start by adding KSP to a Kotlin project:
For a Gradle Project:
// In your project's build.gradle
plugins {
kotlin("jvm") version "1.6.10" // Your Kotlin version
id("com.google.devtools.ksp") version "1.6.10-1.0.4" // KSP version should match Kotlin version
}
dependencies {
implementation(kotlin("stdlib"))
// If using a library that has KSP processors
ksp("com.example:library-ksp-processor:1.0.0")
}
Creating a KSP Processor:
To create a KSP processor, you need to create a separate module that will contain your processor code:
- Create a new module for your processor
- Add KSP API dependencies
- Implement the
SymbolProcessor
interface - Register your processor using the service loader mechanism
Let's look at a simplified example:
// In your processor module
dependencies {
implementation("com.google.devtools.ksp:symbol-processing-api:1.6.10-1.0.4")
}
class MyProcessor : SymbolProcessor {
lateinit var codeGenerator: CodeGenerator
lateinit var logger: KSPLogger
override fun init(
options: Map<String, String>,
kotlinVersion: KotlinVersion,
codeGenerator: CodeGenerator,
logger: KSPLogger
) {
this.codeGenerator = codeGenerator
this.logger = logger
}
override fun process(resolver: Resolver): List<KSAnnotated> {
// Process code here
// Return any deferred symbols that couldn't be processed
return emptyList()
}
override fun finish() {
// Finalize processing
}
override fun onError() {
// Handle errors
}
}
Then register your processor:
# Create a file at src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessor
com.example.MyProcessor
Creating Your First KSP Processor: A Simple Example
Let's create a basic KSP processor that generates a companion factory method for classes annotated with @Factory
.
First, we'll define our annotation:
// In your shared module
package com.example
annotation class Factory
Now, let's implement our processor:
class FactoryProcessor : SymbolProcessor {
private lateinit var codeGenerator: CodeGenerator
private lateinit var logger: KSPLogger
override fun init(
options: Map<String, String>,
kotlinVersion: KotlinVersion,
codeGenerator: CodeGenerator,
logger: KSPLogger
) {
this.codeGenerator = codeGenerator
this.logger = logger
}
override fun process(resolver: Resolver): List<KSAnnotated> {
// Find all classes with the @Factory annotation
val factoryClasses = resolver
.getSymbolsWithAnnotation("com.example.Factory")
.filterIsInstance<KSClassDeclaration>()
.toList()
if (factoryClasses.isEmpty()) {
return emptyList()
}
factoryClasses.forEach { classDecl ->
generateFactoryExtension(classDecl)
}
return emptyList()
}
private fun generateFactoryExtension(classDecl: KSClassDeclaration) {
val packageName = classDecl.packageName.asString()
val className = classDecl.simpleName.asString()
val file = codeGenerator.createNewFile(
dependencies = Dependencies(false, classDecl.containingFile!!),
packageName = packageName,
fileName = "${className}Factory"
)
file.writer().use { writer ->
writer.write("""
package $packageName
object ${className}Factory {
fun create(): $className {
return $className()
}
}
""".trimIndent())
}
}
}
Usage Example:
With this processor, when you annotate a class like:
package com.example
@Factory
class User(val name: String, val age: Int) {
constructor() : this("Unknown", 0)
}
KSP will generate:
package com.example
object UserFactory {
fun create(): User {
return User()
}
}
Real-World Applications of KSP
1. JSON Serialization
One common use of KSP is to generate serialization code. Here's a simplified example of how you might create a JSON serialization processor:
// Define annotation
@Target(AnnotationTarget.CLASS)
annotation class JsonSerializable
// Processor implementation (simplified)
class JsonSerializationProcessor : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation("com.example.JsonSerializable")
.filterIsInstance<KSClassDeclaration>()
symbols.forEach { classDecl ->
// Generate toJson() and fromJson() extension functions
generateJsonMethods(classDecl)
}
return emptyList()
}
private fun generateJsonMethods(classDecl: KSClassDeclaration) {
// Generate serialization code...
}
}
2. Dependency Injection
KSP can be used to implement compile-time dependency injection similar to Dagger:
// Define annotations
@Target(AnnotationTarget.CLASS)
annotation class Component
@Target(AnnotationTarget.FUNCTION)
annotation class Provides
// A processor would scan these annotations and generate the appropriate factory code
3. Data Binding
KSP can generate code to bind UI elements to data models:
// Define annotation
@Target(AnnotationTarget.FIELD)
annotation class BindView(val id: Int)
// A processor would scan these annotations and generate view binding code
Working with the KSP API
KSP provides a rich API for exploring code:
- Resolver: The entry point to the code model
- KSNode: The base interface for all Kotlin symbols
- KSClassDeclaration: Represents a class or interface
- KSPropertyDeclaration: Represents a property
- KSFunctionDeclaration: Represents a function
Example: Exploring Class Properties
fun processClass(classDecl: KSClassDeclaration) {
// Get all properties in the class
val properties = classDecl.getAllProperties()
properties.forEach { property ->
val name = property.simpleName.asString()
val type = property.type.resolve().declaration.qualifiedName?.asString()
logger.info("Property: $name of type $type")
// Check for annotations
val annotations = property.annotations.map {
it.annotationType.resolve().declaration.qualifiedName?.asString()
}
logger.info("Annotations: $annotations")
}
}
Best Practices for KSP Processors
- Minimize symbol resolution: Resolution is expensive, so cache results when possible
- Use incremental processing: Support it by properly specifying dependencies
- Generate clean code: Include helpful comments in generated code
- Handle errors gracefully: Provide meaningful error messages
- Test thoroughly: Use the KSP testing utilities to ensure your processor works correctly
Common Pitfalls and Solutions
1. Missing Type Information
Problem: Type resolution fails with unexpected nulls
Solution: Make sure you check if types are resolved before accessing them:
val type = property.type.resolve()
if (type.isError) {
logger.error("Could not resolve type for ${property.simpleName.asString()}")
return
}
2. Incremental Processing Issues
Problem: Changes don't trigger reprocessing
Solution: Properly specify dependencies in createNewFile
:
codeGenerator.createNewFile(
dependencies = Dependencies(aggregating = true, *sourcesFiles.toTypedArray()),
packageName = packageName,
fileName = fileName
)
3. Registration Issues
Problem: Processor isn't being picked up
Solution: Verify your service file is correctly placed at:
src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessor
Summary
Kotlin Symbol Processing (KSP) is a powerful tool for code generation that offers significant advantages over traditional annotation processing:
- It's faster and more efficient than KAPT
- It works natively with Kotlin syntax and features
- It supports multiplatform projects
- It provides a rich API for exploring and manipulating code
Through this tutorial, you've learned:
- How to set up KSP in your projects
- Creating basic KSP processors
- Exploring the code model with KSP API
- Real-world applications of KSP
- Best practices and common pitfalls
As you become more comfortable with KSP, you'll find it's an invaluable tool for creating compile-time code generation solutions that improve both developer experience and application performance.
Additional Resources
Exercises
- Create a KSP processor that generates
toString()
methods for classes marked with a custom annotation. - Extend your processor to handle data validation by generating validation methods based on field annotations.
- Build a simple dependency injection framework using KSP.
- Create a KSP processor that generates builder patterns for annotated classes.
- Implement a processor that generates event handling code for annotated methods.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)