たくさんの自由帳

KSP を使ってコード生成をしてみる

投稿日 : | 0 日前

文字数(だいたい) : 5215

どうもこんにちは。
Windows 11にするときにAndroid Studioも入れなおしたんですが、何の設定をしてたか忘れちゃってその都度直してたんですが、たぶん思い出せたかも。自分用にそのうち書きます。

本題

コードを動的にいじりたくて、KSPを調べてたんだけど、結局要件を満たせなさそうで使わないかも。それでKSPの使い方だけでもメモ。

KSP

ことりん しんぼる ぷろせっしんぐ

アノテーションをつけたKotlinコードに対してビルド時にアクセスできる。この時に追加でコードを生成したりできる。
これだけ見るとめっちゃ難しそうに見えるが、KSPはそんなに難しくなかった。Pluginの方はたぶんつらい。

KSPのがずっと簡単なのだが、既存のコードを書き換える機能とかは持ち合わせてない。

AndroidRoom@Queryに渡したSQLを元に実際にContentValuesSQLiteに問い合わせるコードをKSPで作っているらしい。

KSP でコードを自動生成してみたい

というわけで今回は簡単にコードを生成してみようと思います。思ったよりも簡単だった。

といっても特には思いつかなかったので、振り絞って考えてみました。
任意のUI Statedata classを元に、LoadingSuccessErrorを自動生成してみようかと。もっといい例が欲しかった。

// これから
@GenerateUiState // 生成してほしいので
data class DetailScreen(
   val name: String,
   val description: String
)
// これを作りたい
sealed interface GenerateDetailScreen {

   data object Loading: GenerateDetailScreen

   data class Success(
      val name: String,
      val description: String
   ) : GenerateDetailScreen

   data class Error(val throwable: Throwable): GenerateDetailScreen
}

環境

KSP作るのにIDEAを使います。Android Studioでできるかは分からないです

なまえあたい
IDEAIntelliJ IDEA 2025.3.1.1
Kotlin2.3.0
Gradle9.2.1
JDK25 (私は Temurin 派)

Java 25でも、Gradle 9.2.1Kotlin 2.3.0の組み合わせなら動きました。両方25に対応してた。

手順

  • 適当なKotlinプロジェクトを作る
  • アノテーションを保持するモジュールKSPの処理をするモジュールを作る
  • KSP書く
  • 動作確認モジュールを作って動かしてみる

てきとうに Kotlin プロジェクトを作る

適当に作ってください

new project

srcは使わない予定なので消しちゃう。
exampleを別に作ります。

src を消す

annotation と processor モジュールを作る

annotationモジュールはその名の通りアノテーションを定義するための。
processorKSPでやりたいことを書くモジュールになります。

右クリックで新しいモジュールをいい感じに作ってください。なんかJDKとかKotlinのバージョンがーって言ってくるけど無視して作るぞ。
#環境 通りのバージョンであれば動くはずなので。

menu

モジュール作成

できた!

あと、パッケージ通りにフォルダを作ってくれないので自分で作りました。KSPを試す分には関係ない部分ですが、、、

パッケージ通りにフォルダを作った

アノテーション書く

最初かあるMain.ktとかは使わないので消して、annotationパッケージにアノテーションを置く.ktファイルを作りました。
GenerateUiState.kt

@Target(AnnotationTarget.CLASS)
annotation class GenerateUiState

アノテーション

これだけです。つぎはKSP側を

KSP API を入れる

processorフォルダのbuild.gradle.ktsに、さっき作ったアノテーションksp開発用のライブラリを入れます。
KSPKotlinのバージョンに対応したのをいれてね。

dependencies {
   // 省略...

   implementation(project(":annotation")) // アノテーションを取り込む
   implementation("com.google.devtools.ksp:symbol-processing-api:2.3.4") // ksp も取り込む
}

プロセッサーを書く下準備

GenerateUiStateProvider.ktを作りました。あとでGenerateUiStateProcessorresources フォルダの方もやりますが

まずはProviderの方ですが、SymbolProcessorProviderを実装したクラスを作ります。
create関数を実装するように言われます。ここでProcessorを返すわけですが、この後作るのでエラーになっても仕方ない。

class GenerateUiStateProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        return GenerateUiStateProcessor(
            options = environment.options,
            logger = environment.logger,
            codeGenerator = environment.codeGenerator
        )
    }
}

これらの引数ですが、optionsはなんかbuild.gradle.ktsから指定したものを受け取れるらしいです。
loggerはわからず、codeGeneratorはその名の通りKotlinコードを自動生成する際に使います。

とりあえずGenerateUiStateProcessorを作りますか。後で中身を埋めていきます。

クラス作成

忘れないうちに次にやることは、resourcesフォルダにファイルを置くことです。
resourcesフォルダにMETA-INFフォルダを作り、その中にservicesフォルダを作り、その中にcom.google.devtools.ksp.processing.SymbolProcessorProviderという名前のテキストファイルを作ります。

ファイルを作る

ファイルの中身ですが、GenerateUiStateProviderの名前を完全修飾名で書くだけです。パッケージ名+クラス名
入力が面倒な場合はCtrl + SpaceWindowsの場合)でコード補完を呼び出して、一つだけ候補が表示されるのでそれを入力すればよいです。

コード補完

プロセッサーを書く

GenerateUiStateProcessorクラスを作ったらSymbolProcessorを実装するようにします。
process関数を実装するように言われます。ここでアノテーションが付いたKotlinコードへアクセスできるという魂胆であります。

class GenerateUiStateProcessor(
    options: Map<String, String>,
    logger: KSPLogger,
    private val codeGenerator: CodeGenerator
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        // TODO このあとすぐ
    }
}

じゃあ書きますって言われてもわからんと思うので、とりあえず 冒頭通りUiState を生成するコードを先に貼ります。
解説はこの後すぐ

class GenerateUiStateProcessor(
    options: Map<String, String>,
    logger: KSPLogger,
    private val codeGenerator: CodeGenerator
) : SymbolProcessor {

    /** ファイルが二回作られるのを防ぐ */
    private var invoked = false

    override fun process(resolver: Resolver): List<KSAnnotated> {
        // アノテーションがついているクラスを探す
        val symbols = resolver
            .getSymbolsWithAnnotation(GenerateUiState::class.qualifiedName!!) // implementation(project(":annotation")) したのでアクセス可。名前をべた書きしてもよかったかも・・・
            .filterIsInstance<KSClassDeclaration>() // クラスに限定する、Function とかもあります

        if (!invoked) {
            invoked = true

            // ファイルを作る
            symbols.forEach { symbol ->

                // 自動生成するクラス名
                val generatedClassName = "Generated${symbol.qualifiedName!!.getShortName()}"

                // クラスの引数を val hoge: String にする
                val parameterKotlinCode = symbol
                    .getAllProperties()
                    .joinToString(separator = ", ") {
                        "val ${it.simpleName.getShortName()}: ${it.type.resolve().declaration.qualifiedName!!.asString()}"
                    }

                // ファイルを作る
                // ビルドすると IDEA / AndroidStudio から参照可能になる
                val file = codeGenerator.createNewFile(
                    dependencies = Dependencies(false, *resolver.getAllFiles().toList().toTypedArray()),
                    packageName = "io.github.takusan23.kspuistategenerator",
                    fileName = generatedClassName
                )

                // 自動生成する Kotlin コード
                val generatedKotlinCode = """
                package io.github.takusan23.kspuistategenerator

                sealed interface $generatedClassName {

                    data object Loading : $generatedClassName

                    data class Success(
                        $parameterKotlinCode
                    ) : $generatedClassName

                    data class Error(val throwable: Throwable) : $generatedClassName
                }
                """.trimIndent()

                // 書き込む
                file.write(generatedKotlinCode.toByteArray())
            }

        }

        val unableToProcess = symbols.filterNot { it.validate() }.toList()
        return unableToProcess
    }
}

解説

まああんまりよくわかってないんですが

// アノテーションがついているクラスを探す
val symbols = resolver
   .getSymbolsWithAnnotation(GenerateUiState::class.qualifiedName!!) // implementation(project(":annotation")) したのでアクセス可。名前をべた書きしてもよかったかも・・・
   .filterIsInstance<KSClassDeclaration>() // クラスに限定する、Function とかもあります

symbolの中身はまあfilterしたのでKSClassDeclarationになるわけです。
ここに、どのクラスに対してアノテーションを付けたか。みたいな情報が得られます。SequenceなのでtoList()した方がデバッグは簡単。

@GenerateUiState data class HomeState // この場合は
println(symbols.map { it.qualifiedName!!.asString() }.toList()) // [io.github.takusan23.io.github.takusan23.kspuistategenerator.example.HomeState] こうなる

クラスが取れたので次はクラスのパラメーターを解析します。これから自動生成するクラスの引数になるので!
getAllProperties()でとれます。

val parameterKotlinCode = symbol
   .getAllProperties()
   .joinToString(separator = ", ") {
      "val ${it.simpleName.getShortName()}: ${it.type.resolve().declaration.qualifiedName!!.asString()}"
   }

これも実行するとこうなります。

// これを渡すと
@GenerateUiState
data class HomeState(
   val title: String, 
   val description: String
)

println(parameterKotlinCode) // こうなる→ val title: kotlin.String, val description: kotlin.String

そしてあとはKotlinのコードを文字列リテラルの中に書いて保存して終わり。なんと文字列で書くことができます。

// ファイルを作る
// ビルドすると IDEA / AndroidStudio から参照可能になる
val file = codeGenerator.createNewFile(
   dependencies = Dependencies(false, *resolver.getAllFiles().toList().toTypedArray()),
   packageName = "io.github.takusan23.kspuistategenerator",
   fileName = generatedClassName
)

// 自動生成する Kotlin コード
val generatedKotlinCode = """
package io.github.takusan23.kspuistategenerator

sealed interface $generatedClassName {

   data object Loading : $generatedClassName

   data class Success(
      $parameterKotlinCode
   ) : $generatedClassName

   data class Error(val throwable: Throwable) : $generatedClassName
}
""".trimIndent()

// 書き込む
file.write(generatedKotlinCode.toByteArray())

if (!invoked) { }の分岐はここのファイル生成が二回目に対応していないために必要だったわけですね。なんで二回目があるのかはわからないですが・・・

動かしてみる

サンプル用のモジュールを作りましょう。exampleって名前にしました。

example モジュールと build.gradle.kts

できたら、build.gradle.ktskspライブラリを入れ、annotationモジュールと、ksp()としてprocessorモジュールを読み込むようにします。
ここからはRoom入れるのと同じ流れです。

plugins {
    kotlin("jvm")
    id("com.google.devtools.ksp") version "2.3.3" // KSP 入れる
}

group = "io.github.takusan23.kspuistategenerator.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation(kotlin("test"))

    // アノテーションと ksp を入れる
    implementation(project(":annotation"))
    ksp(project(":processor"))
}

tasks.test {
    useJUnitPlatform()
}

あとは適当にMain.ktとかでアノテーションを付けたクラスを作り、たぶん一度実行する必要があります。fun main()の横にある実行ボタンを押して実行すればよいです。

@GenerateUiState
data class DetailScreen(
    val name: String,
    val description: String
)

fun main() {
    
}

できた!
生成したクラスはプロジェクト/モジュール名/build/generated/ksp/main/kotlin/パッケージ名/KSPで作ったファイル名にあります!

クラス生成

とらぶるしゅーてぃんぐ

Execution failed for task ':example:kspKotlin'.
> A failure occurred while executing com.google.devtools.ksp.gradle.KspAAWorkerAction
   > com/google/devtools/ksp/impl/KotlinSymbolProcessing$ExitCode

resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProviderのパスが間違えてるとか

中身はCtrl + Spaceの補完で表示される一つだけのやつを入力すればよいはず

そのほか

アノテーション経由でクラスを KSP へ渡しても参照できない

残念ながらKSPが実行される環境では自分の作ったクラスとかは参照できない模様。はえ~

annotation class ClassArg(val clazz: KClass<*>) // KSP ではクラスが存在しないエラーになってしまう

class ExampleB

@ClassArg(ExampleB::class) // 引数に渡しても KSP からは見つけることができない
class ExampleA

ソースコード

どぞ

おわりに

ほかのプロジェクトで使う場合、手っ取り早いのはmavenLocalに公開することだと思います、が、ほかの人がビルドできなくなってしまう。