23
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

小さいことから試すKSP

Posted at

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 アノテーションを用意したとき、

ListSealed.kt
@Target(AnnotationTarget.CLASS)
annotation class ListSealed

以下のように、ListSealed アノテーションを付加した sealed class/interface に対し、続くコードブロックのような拡張プロパティを生成します

MySealedInterface.kt
@ListSealed
sealed interface MySealedInterface {
    object Hoge : MySealedInterface
    object Fuga : MySealedInterface

    companion object
}

object Poyo : MySealedInterface
MySealedInterfaceListSealed.kt
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 という名前のモジュールにしています

スクリーンショット 2022-06-10 0.25.34.png

build.gradle の編集

以下を参考にしながらKSP に関する設定をしていきます

org.jetbrains.kotlin.jvm に関する設定はモジュールの追加時にされているはずなので、それ以外についてみていきます

(root)/build.gradle
buildscript {
    ...
+    dependencies {
+        classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21'
+    }
}
listsealed/build.gradle
...

+dependencies {
+    implementation("com.google.devtools.ksp:symbol-processing-api:1.6.21-1.0.5")
+}
app/build.gradle
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 です

ListSealedProcessor.kt
class ListSealedProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger,
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        return emptyList()
    }
}

SymbolProcessorProvider は SymbolProcessor を生成するための create() メソッドをもつinterface であり、これを継承したクラスも用意します

ListSealedProcessorProvider.kt
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

com.google.devtools.ksp.processing.SymbolProcessorProvider
io.github.warahiko.listsealed.ListSealedProcessorProvider

SymbolProcessor の実装

最初に、 Resolver#getSymbolsWithAnnotation() メソッドを用いて処理する必要があるシンボルをリストアップします
今回処理するのは sealed class/interface であるため、 そのリストをKSClassDeclaration でフィルタリングします

ListSealedProcessor.kt
override fun process(resolver: Resolver): List<KSAnnotated> {
    val symbols = resolver
        .getSymbolsWithAnnotation("io.github.warahiko.listsealed.ListSealed")
        .filterIsInstance<KSClassDeclaration>()
    ...
}

次に、処理すべきシンボルを KSAnnotated#validate() メソッドで判断します
現在処理できないシンボルのリストは process() メソッドの返り値となります
どのようにして処理すべきかが決まるかは以下を参照してください(また、記事の最後に記載するリンクも参考になります)

ListSealedProcessor.kt
override fun process(resolver: Resolver): List<KSAnnotated> {
    ...
    val (processable, next) = symbols.partition { it.validate() }
    ...
    return next
}

処理すべきリストについて、さらに絞っていきます
今回処理する sealed 修飾子がついていない場合、また拡張プロパティを生やすためのCompanion オブジェクトを持っていない場合、error を吐くようにします
これにより、誤ったところにアノテーションをつけていても、ビルド時に落とすことができます

ListSealedProcessor.kt
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 に関わる引数になります(こちらも、記事の最後に記載するリンクが参考になります)

ListSealedProcessor.kt
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 が使えます

ListSealedProcessor.kt
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実装でハマったこと備忘録

23
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?