Core Capabilities of Kotlin Symbol Processing
KSP functions as a compiler plugin operating during the pre-compilation phase. Key constraints and features include:
- Acts as a drop-in alternative to kapt and Java annotation processors.
- Executes before semantic analysis and bytecode generation.
- Supports read-only accesss to source symbols; only new files can be written.
- Iterative: supports multiple rounds where output from one round feeds into the next.
- Configurable via Gradle DSL without modifying existing source trees.
Project Configuration
Add the KSP plugin to the root build.gradle.kts:
plugins {
id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false
id("org.jetbrains.kotlin.jvm") version "1.9.20" apply false
}
Create a dedicated library module for the processor. In its build.gradle.kts:
plugins {
`java-library`
kotlin("jvm")
}
dependencies {
implementation("com.google.devtools.ksp:symbol-processing-api:1.9.20-1.0.14")
}
Processor Foundation
Implement the processing interface and factory:
class RouteScannerProcessor(
private val generator: CodeGenerator,
private val logger: KSPLogger
) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> = emptyList()
}
class RouteScannerProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return RouteScannerProcessor(environment.codeGenerator, environment.logger)
}
}
Register the provider at src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider with the fully qualified name of RouteScannerProvider.
Enable the processor in target modules:
plugins {
id("com.google.devtools.ksp")
}
dependencies {
ksp(project(":navigation-ksp-module"))
}
Streamlining Compose Navigation
Standard Jetpack Compose navigation requires manual routing inside NavHost. Common friction points include:
- Forgetting to register destination composibles leads to runtime crashes.
- String-based routes introduce typo vulnerabilities and lack compile-time safety.
- Arguement serialization via complex query parameters or bundle parsing becomes verbose.
Annotation-Driven Architecture
Define a sealed interface hierarchy to represent typed navigation payloads:
sealed interface ScreenPath {
data class UserProfile(val userId: String) : ScreenPath
data class SettingsScreen(val theme: String) : ScreenPath
}
Create a marker annotation that binds a composable function to a specific path type:
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class AutoRoute(val pathType: KClass<out ScreenPath>)
Leverage fully qualified names to generate type-safe route strings without manual configuration. Each annotated method receives a standard NavBackStackEntry parameter.
Processing Logic
The symbol processor scans for @AutoRoute annotations, extracts target types, and generates two artifacts using KotlinPoet:
- An extension function on
NavGraphBuilderto auto-register destinations. - An object containing inline constants for each registered route.
class RouteScannerProcessor(
private val generator: CodeGenerator,
private val logger: KSPLogger
) : SymbolProcessor {
private var hasRun = false
override fun process(resolver: Resolver): List<KSAnnotated> {
if (hasRun) return resolver.getSymbolsWithAnnotation("com.app.navigator.annotation.AutoRoute").toList()
val targets = resolver.getSymbolsWithAnnotation("com.app.navigator.annotation.AutoRoute")
.filterIsInstance<KSFunctionDeclaration>()
.toList()
if (targets.isEmpty()) {
hasRun = true
return emptyList()
}
val graphExtensionBuilder = FileSpec.builder("com.app.navigator.generated", "NavGraphExtensions")
val constantBuilder = FileSpec.builder("com.app.navigator.constants", "RouteConstants")
val processedNodes = mutableListOf<KSNode>()
val registerBlock = FunSpec.builder("registerAllScreens")
.addModifiers(KModifier.PUBLIC)
.receiver(ClassName("androidx.navigation.compose", "NavGraphBuilder"))
targets.forEach { funcDecl ->
funcDecl.annotations.find { it.shortName.getShortName() == "AutoRoute" }?.let { ann ->
val routeTypeInfo = ann.arguments.first().value as KSType
val className = routeTypeInfo.toClassName()
processedNodes.add(funcDecl.containingFile)
// Generate route registration
val screenFunc = MemberName(className.packageName.asString(), className.simpleName)
registerBlock.addStatement(
"%M(%L) { %M(it) }",
MemberName("androidx.navigation.compose", "composable"),
className.canonicalName,
screenFunc
)
// Generate constant mapping
constantBuilder.addProperty(
PropertySpec.builder(className.simpleName, ClassName("kotlin", "String"))
.receiver(ClassName("com.app.navigator.constants", "RouteConstants"))
.initializer("const %P", className.canonicalName)
.build()
)
}
}
graphExtensionBuilder.addFunction(registerBlock.build()).build().writeTo(generator, true, processedNodes)
constantBuilder.build().writeTo(generator, true, processedNodes)
hasRun = true
return targets.filter { !it.validate() }
}
}
Generated Outputs
The processor produces production-ready code eliminating boilerplate:
NavGraphExtensions.kt
public fun NavGraphBuilder.registerAllScreens() {
composable("com.app.navigator.model.UserProfile"){ UserProfileScreen(it) }
composable("com.app.navigator.model.SettingsScreen"){ SettingsScreenContent(it) }
}
RouteConstants.kt
public object RouteConstants {
public const val UserProfile: String = "com.app.navigator.model.UserProfile"
public const val SettingsScreen: String = "com.app.navigator.model.SettingsScreen"
}
Integration Point
Apply the generated extension directly within the navigation host declaration:
NavHost(
navController = navigationController,
startDestination = RouteConstants.UserProfile
) {
registerAllScreens()
}
This pattern shifts routing maintenance from manual string management to a compile-safe, annotation-driven workflow. Type validation occurs during build time, and argument handling remains decoupled through strongly-typed sealed interfaces.