Kotlin Symbol Processing (KSP) は、軽量なコンパイラプラグインの開発に使えるAPI です
本記事では、「sealed class/interface を継承・実装するobject をリストアップする拡張プロパティを自動生成する」というテーマでAndroid プロジェクトでKSP を試してみます
環境
Android Studio Dolphin | 2021.3.1 Beta 1
Kotlin 1.6.21
KSP 1.6.21-1.0.5
試してみる
具体的には、以下のような ListSealed
アノテーションを用意したとき、
@Target(AnnotationTarget.CLASS)
annotation class ListSealed
以下のように、ListSealed
アノテーションを付加した sealed class/interface に対し、続くコードブロックのような拡張プロパティを生成します
@ListSealed
sealed interface MySealedInterface {
object Hoge : MySealedInterface
object Fuga : MySealedInterface
companion object
}
object Poyo : MySealedInterface
val io.github.warahiko.kspsample.MySealedInterface.Companion.objects
: List<io.github.warahiko.kspsample.MySealedInterface>
get() = listOf(
io.github.warahiko.kspsample.MySealedInterface.Hoge,
io.github.warahiko.kspsample.MySealedInterface.Fuga,
io.github.warahiko.kspsample.Poyo,
)
モジュールの追加
Android Studio のメニューから File > New > New Module...
を選択して作成するのが簡単です
Java or Kotlin Library
を選択し、適当にライブラリ名などを入れていきます
今回は listsealed
という名前のモジュールにしています
build.gradle の編集
以下を参考にしながらKSP に関する設定をしていきます
org.jetbrains.kotlin.jvm
に関する設定はモジュールの追加時にされているはずなので、それ以外についてみていきます
buildscript {
...
+ dependencies {
+ classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21'
+ }
}
...
+dependencies {
+ implementation("com.google.devtools.ksp:symbol-processing-api:1.6.21-1.0.5")
+}
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
+ id 'com.google.devtools.ksp' version '1.6.21-1.0.5'
}
android {
...
// Android Studio にKSP での生成結果を認識させる
+ applicationVariants.all {
+ kotlin.sourceSets {
+ getByName(name) {
+ kotlin.srcDir("$buildDir/generated/ksp/$name/kotlin")
+ }
+ }
+ }
}
dependencies {
...
+ implementation(project(':listsealed'))
+ ksp(project(':listsealed'))
}
SymbolProcessor, Provider の用意
次に、実際に処理を行う SymbolProcessor
, SymbolProcessorProvider
についてです
SymbolProcessor
はKSP におけるメインロジックを担う process()
メソッドをもつinterface であり、これを継承したクラスを用意します
ここで、CodeGenerator
はファイルの生成および管理を行い、KSPLogger
はログ出力を行うためのinterface です
class ListSealedProcessor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger,
) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
return emptyList()
}
}
SymbolProcessorProvider
は SymbolProcessor
を生成するための create()
メソッドをもつinterface であり、これを継承したクラスも用意します
class ListSealedProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return ListSealedProcessor(
environment.codeGenerator,
environment.logger,
)
}
}
また、このSymbolProcessorProvider を com.google.devtools.ksp.processing.SymbolProcessorProvider
というファイルに完全修飾名で記述しておく必要があります
このファイルはKSP モジュールの resources/META-INF/services/
ディレクトリに配置します
(プロジェクトルートから見ると listsealed/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
)
io.github.warahiko.listsealed.ListSealedProcessorProvider
SymbolProcessor の実装
最初に、 Resolver#getSymbolsWithAnnotation()
メソッドを用いて処理する必要があるシンボルをリストアップします
今回処理するのは sealed class/interface であるため、 そのリストをKSClassDeclaration
でフィルタリングします
override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver
.getSymbolsWithAnnotation("io.github.warahiko.listsealed.ListSealed")
.filterIsInstance<KSClassDeclaration>()
...
}
次に、処理すべきシンボルを KSAnnotated#validate()
メソッドで判断します
現在処理できないシンボルのリストは process()
メソッドの返り値となります
どのようにして処理すべきかが決まるかは以下を参照してください(また、記事の最後に記載するリンクも参考になります)
override fun process(resolver: Resolver): List<KSAnnotated> {
...
val (processable, next) = symbols.partition { it.validate() }
...
return next
}
処理すべきリストについて、さらに絞っていきます
今回処理する sealed 修飾子がついていない場合、また拡張プロパティを生やすためのCompanion オブジェクトを持っていない場合、error を吐くようにします
これにより、誤ったところにアノテーションをつけていても、ビルド時に落とすことができます
processable.forEach { symbol ->
if (Modifier.SEALED !in symbol.modifiers) {
logger.error(...)
return@forEach
}
if (symbol.declarations.all { (it as? KSClassDeclaration)?.isCompanionObject != true }) {
logger.error(...)
return@forEach
}
generateList(symbol)
}
最後に、シンボルから得られる情報から CodeGenerator#createNewFile()
メソッドを用いてファイル生成を行います
第二、第三引数にパッケージ名と生成するファイル名を指定してやり、返ってくる OutputStream
に書き込んでやることでファイル生成が完了します
第一引数に渡す Dependencies
については、Incremental processing に関わる引数になります(こちらも、記事の最後に記載するリンクが参考になります)
private fun generateList(classDeclaration: KSClassDeclaration) {
val packageName = classDeclaration.packageName.asString()
val className = classDeclaration.simpleName.asString()
codeGenerator.createNewFile(
Dependencies(aggregating = false, classDeclaration.containingFile!!),
packageName,
"${className}ListSealed",
).use { stream ->
val code: String = ...
stream.write(code.toByteArray())
}
}
生成内容については、得られるサブクラス情報から、object をリストアップしています
object かどうかの判断には KSClassDeclaration#classKind
が使えます
val qualifiedClassName = classDeclaration.qualifiedName?.asString() ?: return
val code = buildString {
appendLine(
"""
package $packageName
val $qualifiedClassName.Companion.objects: List<$qualifiedClassName>
""".trimIndent()
)
val sealedSubclasses = classDeclaration.getSealedSubclasses()
.filter { it.classKind == ClassKind.OBJECT }
if (sealedSubclasses.none()) {
appendLine("${indent}get() = emptyList()")
return@buildString
}
appendLine("${indent}get() = listOf(")
sealedSubclasses
.forEach { subclass ->
val qualifiedSubclassName = subclass.qualifiedName?.asString() ?: return@forEach
appendLine("$indent$indent$qualifiedSubclassName,")
}
appendLine("$indent)")
}
今回作成したSymbolProcessor 全体は以下になります
ビルドする
あとは実際にアノテーション付加・ビルドを行うことで、最初に挙げたようなファイルが自動生成されます
詰まったところ
Could not resolve エラーでビルドが失敗する
KSP モジュール側での build.gradle で、 java に関する設定?があると以下のエラーでビルドが失敗するようです(理由はわかりませんでした)
Could not resolve: com.google.devtools.ksp:symbol-processing-api:1.6.21-1.0.5
今回は、以下のように設定を削除することでビルドを成功させることができました
plugins {
- id 'java-library'
id 'org.jetbrains.kotlin.jvm'
}
-java {
- sourceCompatibility = JavaVersion.VERSION_1_7
- targetCompatibility = JavaVersion.VERSION_1_7
-}
ビルドに成功するのにファイルが生成されない
KSP モジュールでの build.gradle
で、 org.jetbrains.kotlin.jvm
とすべきところが org.jetbrains.kotlin.android
となっていたのが原因でした
こちらも理由はわかりませんでしたが、ビルド自体が問題なく通ってしまっていたため注意が必要かもしれません
所感
- 一度できてしまえば案外簡単に書けるので楽しい
- Annotation とProcessor はモジュール分けるといいかもしれない
- 生成部はKotlinPoet を使ってみたい
- (テーマについてはreflection 使えば?というところはなくはない)
- companion object なしでもできると嬉しい...こちらを待っていればいいんでしょうか?
参考
KSPを使ってコード生成してみる
Kotlin Symbol Processing (KSP) を使ったコード生成 / DroidKaigi 2021
DroidKaigi 2021 - Kotlin Symbol Processing (KSP) を使ったコード生成 / Kenji Abe [JA]
KSPのProcessor実装でハマったこと備忘録