iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🌟

Trying out KSP

に公開
2021/11/28

I've been playing around with KSP and faced various challenges, so I'm writing this memo.
There's also a bit of an anticlimax where I couldn't achieve what I initially set out to do.

Modules using KSP

build.gradle

plugins {
    id 'com.android.library'
    id 'kotlin-android'
    id 'kotlin-kapt'
    id 'dagger.hilt.android.plugin'

    id "com.google.devtools.ksp" version "1.5.31-1.0.0"
}

dependencies {
    implementation project(":Wasabi")
    ksp project(":Wasabi")
}

That's all.
The module named "Wasabi" is where the KSP processing logic is written.

KSP Core Module

build.gradle

plugins {
    id 'org.jetbrains.kotlin.jvm'
    id "com.google.devtools.ksp" version "1.5.31-1.0.0"
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib"
    implementation "com.google.devtools.ksp:symbol-processing-api:1.5.31-1.0.0"
}

That's really it.
Since it's not related to Android, no Android-specific information is included.

META-INF/services


The files within the module are structured like this.
You place files under META-INF/services to specify where the Provider and Processor are located.
This was a point where I struggled because the file names were different from what I saw in official docs and other sources, and it wouldn't work.

Prepare files named com.google.devtools.ksp.processing.SymbolProcessorProvider and javax.annotation.processing.Processor.

The contents of each file are:

sobaya.lib.wasabi.WasabiProvider
sobaya.lib.wasabi.WasabiProcessor

respectively.

Provider

package sobaya.lib.wasabi

import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.processing.SymbolProcessorProvider

class WasabiProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        return WasabiProcessor(environment.codeGenerator, environment.logger)
    }
}

This simply returns an instance of the Processor.

Processor

override fun process(resolver: Resolver): List<KSAnnotated> {
    if (invoked) {
        return emptyList()
    }

    val functions = ArrayList<String>()
    val viewModels = getViewModels(resolver)
    val states = getStates(resolver)

    states.forEach { (viewModelPath, fieldName) ->
        val viewModel = viewModels.find {viewModelPath.contains(it) }
        viewModel?.let {
            functions.add(makeFunction(viewModel, fieldName))
        }
    }

    val file = codeGenerator.createNewFile(
        Dependencies(false),
        "sobaya.wasabi",
        "StateExtensions"
    )

    file.write(functions.joinToString("\n").toByteArray())
    file.close()

    invoked = true
    return emptyList()
}

Since it's long, I've only included the main part for now.

functions will contain the strings for the functions you want to create with KSP.

codeGenerator.createNewFile(
        Dependencies(false),
        "sobaya.wasabi",
        "StateExtensions"
    )

The strings in functions are written to the file specified above.
In this example, it's written to the sobaya.wasabi.StateExtensions.kt file.
Although omitted here, you can also create text files etc. by specifying an extension in the fourth argument.

Extracting information from class annotations

val viewModelSymbol = resolver.getSymbolsWithAnnotation("sobaya.lib.wasabi.ViewModel")

        viewModelSymbol.filterIsInstance<KSClassDeclaration>().forEach {
            viewModels.add("${it.packageName.asString()}.${it.simpleName.asString()}")
        }

Pass your custom annotation to getSymbolsWithAnnotation to get the locations where the annotation is applied.
filterIsInstance<KSClassDeclaration>: Since we are applying it to classes this time, filter by KSClassDeclaration.
it.packageName.asString() for the package name.
it.simpleName.asString() for the class name.

Extracting information from field annotations

val stateSymbol = resolver.getSymbolsWithAnnotation("sobaya.lib.wasabi.State")

stateSymbol.filterIsInstance<KSPropertyDeclaration>().forEach { it ->
    it.annotations.forEach {
        val filePath = (it.parent?.location as FileLocation).filePath
        val file = File(filePath)
        val fieldName = it.parent.toString()
        states.add(file.path.replace("/", ".") to fieldName)
    }
}

Retrieving annotation information with getSymbolsWithAnnotation is the same as before.
KSPropertyDeclaration: Since we want information about variables this time, we filter by this.

By checking the contents of KSPropertyDeclaration.annotations:
(it.parent?.location as FileLocation).filePath allows you to get which file it's written in and where.
it.parent.toString() allows you to get the variable name.
Example)

@Test
val test = Data()

In this case, test can be retrieved.

Types can also be retrieved, but I forgot to note it down because I didn't use it this time.

Finally


When you rebuild the project, the specified files will be automatically created.

This time, I intended to create a feature to make it easier to manage States in ViewModels using data classes, where writing things like:

state.value = state.value.copy(list = it)

is tedious, and I wanted to simplify it.
However, I failed completely.

But being able to add functionality at build time is interesting, so depending on the idea, the possibilities are endless.

Below is the source code where I achieved what I wanted to do, although I haven't solved issues like code cleanup or files being created under the debug directory.
https://github.com/sobaya-0141/Sample2109/pull/1

References

https://github.com/google/ksp
https://speakerdeck.com/star_zero/droidkaigi-2021
https://star-zero.medium.com/kspを使ってコード生成してみる-1c2f421aaaa9

Discussion