My first Kotlin Symbol Processing Tool for Android

SeongUg Steve Jung
4 min readFeb 20, 2021

--

Google recently posted new Annotation Processing Tool for Kotlin: KSP

According to the post, Kotlin Symbol Processing Tool is 2x faster than existed APT. It also supports simplified api but powerful.

Currently you can see many works related to KSP in AOSP. By all logs, I guess Google is rooting KSP more to settle down itself.

Eventually I decided tobuilt a specific KSP tool in my team : Grouping Moshi JsonAdapter Factory whichis for generated Moshi JsonAdapters.

Disclaimer: I am not professional Annotation Processing tool builder. I don’t know how much it is easier than APT. Also even I don’t understand KSP fully. All code are based on Kotlin and Gradle Kotlin DSL in my posting

Background of requirements

When we communicate with server, we deliver many payloads. the payload type is mostly JSON. we sent and received lots of JSON payloads via HTTP.
For JSON, we are using Moshi
For HTTP, we are using Retrofit

In our case, we have lots of data classes to be mapped and each data classes generated JsonAdapter. But it required to write JsonAdapterFactory to inject Moshi. So we have to write them manually.

and then inject above to Moshi and Retrofit like this

Every single time, we have to write JsonAdapterFactory manually and add it to Moshi builder.

Yeah, It is very unproductive works. So I decided to build our APT to generate JsonAdapterFactory automatically. But KSP seems more simple than APT

1. KSP module config

In KSP tool module, you simply add KSP api dependency

implementation("com.google.devtools.ksp:symbol-processing-api:1.4.30-1.0.0-alpha02")

2. SymbolProcessor

We are going to use SymbolProcessor as Google provided sample project.

The SymbolProcessor has 4 interfaces

fun init(
options: Map<String, String>,
kotlinVersion: KotlinVersion,
codeGenerator: CodeGenerator,
logger: KSPLogger
)
fun process(resolver: Resolver): List<KSAnnotated>fun finish()fun onError() {}

the lifecycle order is init(), process() and then finish if no error.

What we are doing is this

  1. in init() : Read Gradle script property. Set codeGenerator to member field
  2. in process() : Collect @JsonClass(generateAdapter = true)annotated class
  3. in finish() : Generate Grouped JsonAdapterFactory

3. Init()

As explained, we set necessary arguments to member field.

4. process()

During processing, we look up @JsonClass annotated class and will check either it has generateAdapter = true .

5. CustomKSVisitor.kt

KSVisitor is to help reading each KSNode information.

KSVisitor has lots of interfaces. it will visit only each scoped functions.

class KSVisitor<D, R> {
fun visitFile(file: KSFile, data: D): R
fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: D): R
fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: D): R
// and so on.. argument, property getter/setter
}

In #4, we filter out if symbol is KSClassDeclaration and accept CustomVisitor to look up inside. So the CustomVisitor only visit visitClassDeclaration() . If we filter out KSFunctionDeclaration , then the visitor calls only visitFunctionDeclaration() .

In CustomKSVisitor, we will only filter out @JsonClass(generateAdapter = true) and collect class information to targets which is cache in SymbolProcessor

6. finish()

Before finishing KSP, we will generate GroupedJsonAdapterFactory.

First step, we should decide file package and name. Seconds step is to decide dependency files to identify incremental build.

val packageName = "com.example"
val outputStream: OutputStream = codeGenerator.createNewFile(
dependencies = Dependencies(
true,
*targets.map { it.containingFile!! }
.toTypedArray()
),
packageName = packageName,
fileName = "GroupJsonAdapterFactory"
)

In sample, I put dependencies on collected classes’ files. codeGenerator.createNewFile() returns FileOutputStream. we will use the outputStream to write file

Last part to writing file.

outputStream.write("""
|package $packageName
|
|import com.squareup.moshi.JsonAdapter
|import com.squareup.moshi.Moshi
|import com.squareup.moshi.Types
|// import target classes & target classes json adapter
|class GroupJsonAdapterFactory: JsonAdapter.Factory {
| override fun create(): JsonAdapter<*> {
| // declare logics
| }
|}
""".trimMargin().toByteArray())
outputStream.close()

7. Resources/META-INF.services

In META-INF.services, we should expose our SymboleProcessor explicitly.

com.example.CustomSymbolProcessor

It is done for KSP module. when you compile with this KSP module, then you can see generated file of GoupJsonAdapterFactory

Let’s move on consumer module

7. Consumer module

Add KSP classpath and use ksp like kapt.

8. Wth??? My IDEA isn’t aware of KSP generated file

Yes currently, KSP generated files isn’t indexed on IDE. To use it, you should do extra work in Gradle script.

This is a known issue now.
Description : https://github.com/google/ksp#development-status
Issue : https://github.com/google/ksp/issues/37

So you should declare sourceSets manually. KSP is still in alpha. I hope Google fixes it.

Tip

If you are doing multi module project, Duplicated class name to generate file is not good idea. So we use this

// consumer build.gradle.kts
ksp {
arg("projectName", project.name)
}
// SymbolProcessor.kt
private lateinit var projectName: String
fun init(options: Map<String, String>) {
projectName = options["projectName"]
}
fun finish() { outputStream.write("""
|class ${projectName}JsonAdapterFactory
""")
}

Then you can generate different class name on each module.

Conclusion

I have no idea KSP is really faster or easier than APT. Because I never write APT code generator before. but KSP is really simple and fast enough to use. And I am happy to use.

Check out Repo

Reference

Google/KSP
Github : https://github.com/google/ksp
Sample for Android : google/ksp/playground-android
KSP adoption in AOSP : https://android-review.googlesource.com/q/ksp

Caution.

If you are using KSP in consumer module with Android DataBinding, Android DataBinding may not work. At least it happens in my project.

Update::

com.sqaureup.moshi.internal.Util.generatedAdapter() is covering my work and internally it has ProguardConfiguration to prevent obfuscation of class also. Please refer this only for KSP usages. lol

--

--