This article has participated in the call for good writing activities, click to view: back end, big front end double track submission, 20,000 yuan prize pool waiting for you to challenge!

Earlier this year, Android released the first Alpha version of Kotlin Symbol Processing (KSP). In the past few months, KSP has been updated to Beta3. Currently, the API is basically stable, and it is believed that a stable release is not far away.

Why KSP?

Many people make fun of Kotlin’s compile speed, and KAPT is one of the main culprits.

Many libraries use annotations to simplify template code, such as Room, Dagger, Retrofit, etc. Kotlin code uses KAPT to handle annotations. KAPT essentially works on APT. APT can only handle Java annotations, so you need to make APT parsible stubs (Java code), which slows down Kotlin’s overall compilation speed.

KSP was born in this context. It is implemented based on the Kotlin Compiler Plugin (KCP for short), does not need to generate additional stubs, and compels more than twice as fast as KAPT

KSP with KCP

Kotlin Compiler Plugin provides hook opportunities during the kotlinc process to parse AST and modify bytecode products. Many of Kotlin’s syntactic sugars are implemented by KCP. For example, data Class, @Parcelize, Kotlin-Android-extension, etc., for example, Compose, which is currently popular, uses KCP for its compile-time work.

In theory, THE capabilities of KCP are supersets of KAPT and can replace KAPT to improve compilation speed. However, the development cost of KCP is too high, which involves the use of Gradle Plugin and Kotlin Plugin, etc. API involves some compiler knowledge, which is difficult for common developers to master.

The development of a standard KCP involves the following:

  • Gradle Plugin is used to read Gradle configurations passed to KCP (Kotlin Plugin).
  • Subplugin: provides KCP with customized KP’s Maven library address and other configuration information
  • CommandLineProcessor: Converts parameters to KP identifiable parameters
  • ComponentRegistrar: Register with the various KCP processes
  • Extension: Implement the customized KP function

KSP simplifies this process by eliminating the need for developers to understand how the compiler works, and handling annotations is as cheap as KAPT.

KSP and KAPT

KSP, as the name implies, processes Kotlin’s AST at the Symbols level, accessing elements of types such as classes, class members, functions, and related parameters. It’s analogous to the Kotlin AST in PSI

A Kotlin source file parsed by KSP looks like this:

KSFile
  packageName: KSName
  fileName: String
  annotations: List<KSAnnotation>  (File annotations)
  declarations: List<KSDeclaration>
    KSClassDeclaration // class, interface, object
      simpleName: KSName
      qualifiedName: KSName
      containingFile: String
      typeParameters: KSTypeParameter
      parentDeclaration: KSDeclaration
      classKind: ClassKind
      primaryConstructor: KSFunctionDeclaration
      superTypes: List<KSTypeReference>
      // contains inner classes, member functions, properties, etc.
      declarations: List<KSDeclaration>
    KSFunctionDeclaration // top level function
      simpleName: KSName
      qualifiedName: KSName
      containingFile: String
      typeParameters: KSTypeParameter
      parentDeclaration: KSDeclaration
      functionKind: FunctionKind
      extensionReceiver: KSTypeReference?
      returnType: KSTypeReference
      parameters: List<KSVariableParameter>
      // contains local classes, local functions, local variables, etc.
      declarations: List<KSDeclaration>
    KSPropertyDeclaration // global variable
      simpleName: KSName
      qualifiedName: KSName
      containingFile: String
      typeParameters: KSTypeParameter
      parentDeclaration: KSDeclaration
      extensionReceiver: KSTypeReference?
      type: KSTypeReference
      getter: KSPropertyGetter
        returnType: KSTypeReference
      setter: KSPropertySetter
        parameter: KSVariableParameter
    KSEnumEntryDeclaration
      // same as KSClassDeclaration
Copy the code

This is the Kotlin AST abstraction in KSP. Similarly, THERE is an AST abstraction for Java in APT/KAPT, which can find some corresponding relationships. For example, Java uses Element to describe packages, classes, methods or variables, etc., while KSP uses Declaration

Java/APT Kotlin/KSP Description
PackageElement KSFile Represents a package element. Provides access to information about packages and their members
ExecuteableElement KSFunctionDeclaration A method, constructor, or initializer (static or instance) that represents a class or interface, including annotation type elements
TypeElement KSClassDeclaration Represents a class or interface program element. Provides access to information about types and their members. Note that an enumeration type is a class and an annotation type is an interface
VariableElement KSVariableParameter / KSPropertyDeclaration Represents a field, enum constant, method or constructor parameter, local variable, or exception parameter

Declaration also contains Type information, such as function parameters, return value types, etc. TypeMirror is used to carry Type information in APT. Detailed capabilities in KSP are implemented by KSType.

The development process of KSP is similar to that of KAPT:

  1. Parsing source AST
  2. The generated code
  3. The generated code participates in Kotlin compilation along with the source code

Note that KSP cannot be used to modify the original code, only to generate new code

KSP entry: SymbolProcessorProvider

KSP is implemented through the SymbolProcessor. The SymbolProcessor needs to be created with a SymbolProcessorProvider. So the SymbolProcessorProvider is the entry point for KSP execution

interface SymbolProcessorProvider {
    fun create(environment: SymbolProcessorEnvironment): SymbolProcessor
}
Copy the code

SymbolProcessorEnvironment get some KSP runtime dependency, injected into the Processor

interface SymbolProcessor {
    fun process(resolver: Resolver): List<KSAnnotated> // Let's focus on this
    fun finish(a) {}
    fun onError(a){}}Copy the code

Process () provides a Resolver that resolves symbols on the AST. The Resolver uses the visitor pattern to traverse the AST.

Resolver uses FindFunctionsVisitor to find top-level functions and Class member methods in the current KSFile:

class HelloFunctionFinderProcessor : SymbolProcessor() {...val functions = mutableListOf<String>()
    val visitor = FindFunctionsVisitor()

    override fun process(resolver: Resolver) {
        // Use FindFunctionsVisitor to traverse the AST
        resolver.getAllFiles().map { it.accept(visitor, Unit)}}inner class FindFunctionsVisitor : KSVisitorVoid() {
        override fun visitClassDeclaration(classDeclaration: KSClassDeclaration.data: Unit) {
            // Access the Class node
            classDeclaration.getDeclaredFunctions().map { it.accept(this.Unit)}}override fun visitFunctionDeclaration(function: KSFunctionDeclaration.data: Unit) {
            // Access the function node
            functions.add(function)
        }

        override fun visitFile(file: KSFile.data: Unit) {
            / / access to the file
            file.declarations.map { it.accept(this.Unit)}}}... }Copy the code

KSP API sample

Can you give some examples of how the KSP API works

Access all member methods in a class

fun KSClassDeclaration.getDeclaredFunctions(a): List<KSFunctionDeclaration> {
    return this.declarations.filterIsInstance<KSFunctionDeclaration>()
}
Copy the code

Determines whether a class or method is a local class or method

fun KSDeclaration.isLocal(a): Boolean {
    return this.parentDeclaration ! =null && this.parentDeclaration !is KSClassDeclaration
}
Copy the code

Determines whether a class member is visible to other declarations

fun KSDeclaration.isVisibleFrom(other: KSDeclaration): Boolean {
    return when {
        // locals are limited to lexical scope
        this.isLocal() -> this.parentDeclaration == other
        // file visibility or member
        this.isPrivate() -> {
            this.parentDeclaration == other.parentDeclaration
                    || this.parentDeclaration == other
                    || (
                        this.parentDeclaration == null
                            && other.parentDeclaration == null
                            && this.containingFile == other.containingFile
                    )
        }
        this.isPublic() -> true
        this.isInternal() && other.containingFile ! =null && this.containingFile ! =null -> true
        else -> false}}Copy the code

Get annotation information

// Find out suppressed names in a file annotation:
// @file:kotlin.Suppress("Example1", "Example2")
fun KSFile.suppressedNames(a): List<String> {
    val ignoredNames = mutableListOf<String>()
    annotations.forEach {
        if (it.shortName.asString() == "Suppress"&& it.annotationType.resolve()? .declaration?.qualifiedName?.asString() =="kotlin.Suppress") {
            it.arguments.forEach {
                (it.value as List<String>).forEach { ignoredNames.add(it) }
            }
        }
    }
    return ignoredNames
}
Copy the code

Examples of code generation

Finally, let’s look at a relatively complete example of code generation to replace APT

@IntSummable
data class Foo(
  val bar: Int = 234.val baz: Int = 123
)
Copy the code

We want to process @IntSummable with KSP to generate the following code

public fun Foo.sumInts(a): Int {
  val sum = bar + baz
  return sum
}
Copy the code

Dependencies

Developing KSP requires adding dependencies:

plugins {
    kotlin("jvm") version "1.4.32"
}

repositories {
    mavenCentral()
    google()
}

dependencies {
    implementation(kotlin("stdlib"))
    implementation("Com. Google. Devtools. KSP: symbol - processing - API: 1.5.10 1.0.0 - beta01")}Copy the code

IntSummableProcessorProvider

We need an inbound Provider to build the Processor

import com.google.devtools.ksp.symbol.*

class IntSummableProcessorProvider : SymbolProcessorProvider {

    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        return IntSummableProcessor(
            options = environment.options,
            codeGenerator = environment.codeGenerator,
            logger = environment.logger
        )
    }
}
Copy the code

Can be injected the options for the Processor, by SymbolProcessorEnvironment CodeGenerator, logger, etc required to rely on

IntSummableProcessor

class IntSummableProcessor() : SymbolProcessor {
    
    private lateinit var intType: KSType

    override fun process(resolver: Resolver): List<KSAnnotated> {
        intType = resolver.builtIns.intType
        val symbols = resolver.getSymbolsWithAnnotation(IntSummable::class.qualifiedName!!) .filterNot{ it.validate() } symbols.filter { itis KSClassDeclaration && it.validate() }
            .forEach { it.accept(IntSummableVisitor(), Unit)}return symbols.toList()
    }
}    
Copy the code
  • builtIns.intTypeAccess to thekotlin.IntKSType, is needed later.
  • getSymbolsWithAnnotationGet the annotation asIntSummableThe list of symbols
  • When the symbol is a Class, it is processed using a Visitor

IntSummableVisitor

The interface of the Visitor is generally as follows, with D and R representing the input and output of the Visitor,

interface KSVisitor<D, R> {
    fun visitNode(node: KSNode.data: D): R

    fun visitAnnotated(annotated: KSAnnotated.data: D): R
    
    // etc.
}
Copy the code

Our requirements have no input or output, so we just implement KSVisitorVoid, essentially a KSVisitor

:
,>

inner class Visitor : KSVisitorVoid() {
    
    override fun visitClassDeclaration(classDeclaration: KSClassDeclaration.data: Unit) {
        valqualifiedName = classDeclaration.qualifiedName? .asString()//1. Check the validity
        if(! classDeclaration.isDataClass()) { logger.error("@IntSummable cannot target non-data class $qualifiedName",
                classDeclaration
            )
            return
        }

        if (qualifiedName == null) {
            logger.error(
                "@IntSummable must target classes with qualified names",
                classDeclaration
            )
            return
        }
        
        //2. Parse the Class information
        / /...
        
        //3. Code generation
        / /...
        
    }
    
    private fun KSClassDeclaration.isDataClass(a) = modifiers.contains(Modifier.DATA)
}
Copy the code

Above, we determine whether this Class is a data Class and whether its Class name is valid

Parsing Class information

Next we need to get the relevant information from Class for our code generation:

inner class IntSummableVisitor : KSVisitorVoid() {

    private lateinit var className: String
    private lateinit var packageName: String
    private val summables: MutableList<String> = mutableListOf()

    override fun visitClassDeclaration(classDeclaration: KSClassDeclaration.data: Unit) {
        //1. Check the validity
        / /...
        
        //2. Parse the Class information
        valqualifiedName = classDeclaration.qualifiedName? .asString() className = qualifiedName packageName = classDeclaration.packageName.asString() classDeclaration.getAllProperties() .forEach { it.accept(this.Unit)}if (summables.isEmpty()) {
            return
        }
        
        //3. Code generation
        / /...
    }
    
    override fun visitPropertyDeclaration(property: KSPropertyDeclaration.data: Unit) {
        if (property.type.resolve().isAssignableFrom(intType)) {
            val name = property.simpleName.asString()
            summables.add(name)
        }
    }
}
Copy the code
  • throughKSClassDeclarationTo obtain theclassName.packageName, as well asPropertiesAnd deposit itsummables
  • visitPropertyDeclarationEnsure that the Property must be of type Int, as mentioned earlierintType

Code generation

After collecting the Class information, proceed to code generation. We introduce KotlinPoet to help us generate Kotlin code

dependencies {
    implementation("Com. Squareup: kotlinpoet: 1.8.0 comes with.")}Copy the code
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration.data: Unit) {

    //1. Check the validity
    / /...
      

    //2. Parse the Class information
    / /...
        
        
    //3. Code generation
    if (summables.isEmpty()) {
        return
    }

    val fileSpec = FileSpec.builder(
        packageName = packageName,
        fileName = classDeclaration.simpleName.asString()
    ).apply {
        addFunction(
            FunSpec.builder("sumInts")
                .receiver(ClassName.bestGuess(className))
                .returns(Int: :class)
                .addStatement("val sum = ${summables.joinToString("+")}")
                .addStatement("return sum")
                .build()
        )
    }.build()

    codeGenerator.createNewFile(
        dependencies = Dependencies(aggregating = false),
        packageName = packageName,
        fileName = classDeclaration.simpleName.asString()
    ).use { outputStream ->
        outputStream.writer()
            .use {
                fileSpec.writeTo(it)
            }
    }
}
Copy the code
  • The use of KotlinPoetFunSpecGenerating function code
  • The front SymbolProcessorEnvironment providesCodeGeneratorUsed to create the file and write to the generatedFileSpeccode

conclusion

As can be seen from the IntSummable example, KSP can completely replace APT/KAPT for annotation processing, and the performance is better.

At present, many third-party libraries using APT have added support for KSP

Library Status Tracking issue for KSP
Room Experimentally supported
Moshi Experimentally supported
Kotshi Experimentally supported
Lyricist Experimentally supported
Auto Factory Not yet supported Link
Dagger Not yet supported Link
Hilt Not yet supported Link
Glide Not yet supported Link
DeeplinkDispatch Not yet supported Link

Replacing KAPT with KSP is also very simple, as Moshi does

Of course, you can also use both KAPT and KSP in a project without affecting each other. KSP is increasingly replacing KAPT. If your project also handles annotation requirements, why not try KSP?

github.com/google/ksp