Hiltの実行時エラーを防ぐKSPプラグインを作ってみた

Android Advent Calendar 2023の24日目の記事です。

みなさん、Hiltは使ってますか？

Hiltとは、公式が出しているDIライブラリで、
私はHiltがないと、もうアプリ開発できないぐらいにお世話になっています。

https://developer.android.com/training/dependency-injection/hilt-android?hl=ja

今回は、Hiltをもっと使いやすくするKSPプラグインを作ってみました！

## Hiltってどんなの？

まずは、Hiltの説明から。

例えば、
こんな感じで自前のクラスを作ったとして、

```kotlin
class InjectTarget {
    fun doSomething() {
        println("doSomething")
    }
}
```

こちらのクラスをHiltを使って、ViewModelにInjectしたいとなったら、
まず、コンストラクタに`@Inject`アノテーションを付与したのちに、

```kotlin
class InjectTarget @Inject constructor() {
    fun doSomething() {
        println("doSomething")
    }
}
```

`@HiltViewModel`アノテーションがついた、ViewModelにInjectします。

```kotlin
@HiltViewModel
class SampleViewModel @Inject constructor(
    private val injectTarget: InjectTarget
) : ViewModel() {

    fun doSomething() {
        injectTarget.doSomething()
    }
}
```

めちゃくちゃ簡単で便利なDIライブラリなのですが、一つだけ不満な点がありました。

## HiltのAnnotationを忘れてクラッシュするケース

Hiltでは、Androidクラスと呼ばれる、
以下のいずれかに該当するクラスに、フィールドインジェクションするとき、
かならず決まったアノテーションをつける必要があります。

Androidクラス
 - Application（@HiltAndroidApp）
 - ViewModel（@HiltViewModel）
 - Activity（@AndroidEntryPoint）
 - Fragment（@AndroidEntryPoint）
 - View（@AndroidEntryPoint）
 - Service（@AndroidEntryPoint）
 - BroadcastReceiver（@AndroidEntryPoint）

例えば、以下のような、コードを書いてしまうと、
実行時にクラッシュします。

```kotlin
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var injectTarget: InjectTarget

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            LaunchedEffect(Unit) {
                injectTarget.doSomething()
            }

            HiltKspPluginTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Greeting("Android")
                }
            }
        }
    }
}
```

エラーの内容は、こんな感じです。


```
Process: com.example.hilt_ksp_plugin, PID: 19419
kotlin.UninitializedPropertyAccessException: lateinit property injectTarget has not been initialized
at com.example.hilt_ksp_plugin.MainActivity.getInjectTarget(MainActivity.kt:22)
at com.example.hilt_ksp_plugin.MainActivity$onCreate$1$1.invokeSuspend(MainActivity.kt:28)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
```

正しいコードはこちらになります。
```kotlin
@AndroidEntryPoint // ← アノテーションをつけた
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var injectTarget: InjectTarget

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            LaunchedEffect(Unit) {
                injectTarget.doSomething()
            }

            HiltKspPluginTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Greeting("Android")
                }
            }
        }
    }
}
```

自分は結構この、@AndroidEntryPointをつけ忘れることが多く、
つねづね **「必要なアノテーションがついてなかったら、コンパイル時にエラーになってほしいな〜」** と思っていました。

今回は、そんな願望を叶えるべく、
自作のKSPプラグインを作ってみました。

## KSPとは？

KSPとは、`Kotlin Symbol Processing`の略で、
軽量コンパイラプラグインの開発に使用できる API です。 

詳しくはこちらを参照。
https://kotlinlang.org/docs/ksp-overview.html

従来の、`Kotlin Annotation Processing（KAPT）`に比べて、
最大２倍早く実行できると言われています。


## 今回作るプラグインの概要
やることとしては単純で、
プロジェクト内のファイルを探索して、該当のAndroidクラスに、適切なアノテーションが付与されていなかったら、
コンパイルエラーにします。

 - Application（@HiltAndroidApp）
 - ViewModel（@HiltViewModel）
 - Activity（@AndroidEntryPoint）
 - Fragment（@AndroidEntryPoint）
 - View（@AndroidEntryPoint）
 - Service（@AndroidEntryPoint）
 - BroadcastReceiver（@AndroidEntryPoint）

例えば、Activityを継承したMainAcitivtyがあり、
MainAcitivtyに、@AndroidEntryPointアノテーションがついてなかったら、コンパイルエラーにする、
といった具合です。

## プラグインの作成方法

ここから、プラグインの作成方法について、説明していきます。

### 環境セットアップ

まずは、KSP用のモジュールを追加します。
名前は、`hilt-validator`としました。

![スクリーンショット 2023-12-17 16.02.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320402/623b1abc-5210-e6f8-df8c-cf6362f19ff5.png)

モジュールを作成したら、build.gradle.ktsに設定を記述します。

```gradle:(:hilt-validator)/build.gradle.kts
plugins {
    kotlin("jvm")
}

dependencies {
    implementation("com.google.devtools.ksp:symbol-processing-api:1.9.21-1.0.15")
}
```

次に、projectのbuild.gradle.ktsに、下記の設定を追加します。

```gradle:(root)/build.gradle.kts
plugins {
    id("com.android.application") version "8.2.0" apply false
    id("com.android.library") version "8.2.0" apply false
    id("org.jetbrains.kotlin.android") version "1.8.10" apply false
    id("org.jetbrains.kotlin.jvm") version "1.9.21" apply false
    id("com.google.devtools.ksp") version "1.9.21-1.0.15" apply false
    id("com.google.dagger.hilt.android") version "2.44" apply false
}

buildscript {
    dependencies {
        classpath(kotlin("gradle-plugin", version = "1.9.21"))
        classpath("com.google.dagger:hilt-android-gradle-plugin:2.49")
    }
}
```

最後に、アプリモジュールのbuild.gradle.ktsに、下記の設定を追加します。
```gradle:(:demo)/build.gradle.kts
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("com.google.devtools.ksp")
    id("com.google.dagger.hilt.android")
}

dependencies {
    implementation("com.google.dagger:hilt-android:2.49")
    ksp("com.google.dagger:hilt-android-compiler:2.48")

    implementation(kotlin("stdlib-jdk8"))
    implementation(project(":hilt-validator"))
    ksp(project(":hilt-validator"))
}
```

### SymbolProcessorProviderとSymbolProcessorを用意する

セットアップは終わったので、ここからはプラグイン部分を作っていきましょう。
KSPプラグインを作るには、独自のプロセッサを定義する必要があります。

hlit-validatorモジュール内に、下記のクラスを追加します。

```kotlin
class HiltValidateProcessor(
    private val logger: KSPLogger
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        return emptyList()
    }
}

class HiltValidateProcessorProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        return HiltValidateProcessor(environment.logger)
    }
}
```

SymbolProcessorを継承したクラスと、
SymbolProcessorProviderを継承したクラスが必要です。

HiltValidateProcessorProviderは、createメソッドで、
HiltValidateProcessorのインスタンスを返します。

次に、下記のファイルを作成します。
`hilt-validator/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider`

作成したファイルに、HiltValidateProcessorProviderの完全修飾名を記述します。
```:hilt-validator/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
com.example.hilt_validator.HiltValidateProcessorProvider
```

こうすることで、HiltValidateProcessorProviderが、プロジェクトに認識されるようになります。

### Validation処理の作成

Validation処理を実装していきます。

まず、Androidクラスを探索するための、
`MatchResult`クラスと、`AndroidComponentMatcher`クラスを作成します。

```kotlin
sealed interface MatchResult {

    val declaration: KSClassDeclaration

    fun hasAnnotation(annotationName: String): Boolean {
        return declaration.annotations.any { it.shortName.asString() == annotationName }
    }

    data class Application(
        override val declaration: KSClassDeclaration
    ) : MatchResult

    data class Activity(
        override val declaration: KSClassDeclaration
    ) : MatchResult

    data class Fragment(
        override val declaration: KSClassDeclaration
    ) : MatchResult

    data class Service(
        override val declaration: KSClassDeclaration
    ) : MatchResult

    data class ViewModel(
        override val declaration: KSClassDeclaration
    ) : MatchResult

    data class BroadcastReceiver(
        override val declaration: KSClassDeclaration
    ) : MatchResult
}

object AndroidComponentMatcher {

    fun match(declaration: KSClassDeclaration): MatchResult? {

        if (declaration.annotations.map { it.shortName.asString() }.contains("Generated")) {
            // Hiltが生成したクラスは無視する
            return null
        }

        val superClassQualifiedNameList =
            declaration.superTypes.mapNotNull { it.resolve().declaration.qualifiedName?.asString() }

        superClassQualifiedNameList.forEach { superClassQualifiedName ->
            val matchResult = match(declaration, superClassQualifiedName)
            if (matchResult != null) {
                return matchResult
            }
        }

        return null
    }

    private fun match(
        declaration: KSClassDeclaration,
        superClassQualifiedName: String
    ): MatchResult? {
        return when (superClassQualifiedName) {
            "android.app.Application" -> MatchResult.Application(declaration)
            "androidx.activity.ComponentActivity" -> MatchResult.Activity(declaration)
            "androidx.fragment.app.Fragment" -> MatchResult.Fragment(declaration)
            "android.app.Service" -> MatchResult.Service(declaration)
            "androidx.lifecycle.ViewModel" -> MatchResult.ViewModel(declaration)
            "android.content.BroadcastReceiver" -> MatchResult.BroadcastReceiver(
                declaration
            )

            else -> null
        }
    }
}
```

処理内容としては、
`AndroidComponentMatcher#match`に渡された、クラス定義から、
適切なMatchResultを返します。

渡されたクラスが、Androidクラスを継承していないときは、nullが返ります。

次に、`HiltValidateProcessor#process`内の処理を記述していきます。

まず、`resolver#getAllFiles`で、モジュール内のファイルを全て取得します。
```kotlin

class HiltValidateProcessor(
    private val logger: KSPLogger
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        val allFile = resolver.getAllFiles()
        ...
    }
}
```

その後、取得したファイル情報から、ファイルに含まれるクラス情報を抜き出します。
```kotlin
val (classDeclarations, next) =
   allFile.map { it.declarations.filterIsInstance<KSClassDeclaration>() }.flatten()
          .partition { it.validate() }
```

AndroidComponentMatcherを使って、Androidクラスを継承しているクラスを絞り込みます。
```kotlin
val matchResults = classDeclarations.mapNotNull { AndroidComponentMatcher.match(it) }
```

取得したmatchResultを使って、
クラスが適切なアノテーションを付与されているかをみます。

```kotlin
private const val HiltAppAnnotation = "HiltAndroidApp"
private const val AndroidEntryPointAnnotation = "AndroidEntryPoint"
private const val HiltViewModelAnnotation = "HiltViewModel"

        ...
        ...
        matchResults.forEach { matchResult ->
            when (matchResult) {
                is MatchResult.Application -> {
                    if (!matchResult.hasAnnotation(HiltAppAnnotation)) {
                        logger.error(
                            "Application class must have @HiltAndroidApp annotation",
                            matchResult.declaration
                        )
                    }
                }

                is MatchResult.ViewModel -> {
                    if (!matchResult.hasAnnotation(HiltViewModelAnnotation)) {
                        logger.error(
                            "ViewModel class must have @HiltViewModel annotation",
                            matchResult.declaration
                        )
                    }
                }

                is MatchResult.Activity,
                is MatchResult.Fragment,
                is MatchResult.Service,
                is MatchResult.BroadcastReceiver -> {
                    if (!matchResult.hasAnnotation(AndroidEntryPointAnnotation)) {
                        logger.error(
                            "Activity, BroadcastReceiver, Fragment, Service class must have @AndroidEntryPoint annotation",
                            matchResult.declaration
                        )
                    }
                }
            }
        }
```

`matchResult.hasAnnotation`で、適切なアノテーションを付与されているかを、みています。
付与されていない場合は、`logger.error`を呼び出すことで、
指定したメッセージで、コンパイルエラーになります。

コード全文です。

```kotlin
private const val HiltAppAnnotation = "HiltAndroidApp"
private const val AndroidEntryPointAnnotation = "AndroidEntryPoint"
private const val HiltViewModelAnnotation = "HiltViewModel"

class HiltValidateProcessor(
    private val logger: KSPLogger
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        val allFile = resolver.getAllFiles()
        val (classDeclarations, next) =
            allFile.map { it.declarations.filterIsInstance<KSClassDeclaration>() }.flatten()
                .partition { it.validate() }

        val matchResults = classDeclarations.mapNotNull { AndroidComponentMatcher.match(it) }

        matchResults.forEach { matchResult ->
            when (matchResult) {
                is MatchResult.Application -> {
                    if (!matchResult.hasAnnotation(HiltAppAnnotation)) {
                        logger.error(
                            "Application class must have @HiltAndroidApp annotation",
                            matchResult.declaration
                        )
                    }
                }

                is MatchResult.ViewModel -> {
                    if (!matchResult.hasAnnotation(HiltViewModelAnnotation)) {
                        logger.error(
                            "ViewModel class must have @HiltViewModel annotation",
                            matchResult.declaration
                        )
                    }
                }

                is MatchResult.Activity,
                is MatchResult.Fragment,
                is MatchResult.Service,
                is MatchResult.BroadcastReceiver -> {
                    if (!matchResult.hasAnnotation(AndroidEntryPointAnnotation)) {
                        logger.error(
                            "Activity, BroadcastReceiver, Fragment, Service class must have @AndroidEntryPoint annotation",
                            matchResult.declaration
                        )
                    }
                }
            }
        }

        return next
    }
}

class HiltValidateProcessorProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        return HiltValidateProcessor(environment.logger)
    }
}
```

試しに、MainActivityに、@AndroidEntryPointをつけないで、
ビルドしようとすると、エラーになります。

```
[ksp] /demo/src/main/java/com/example/hilt_ksp_plugin/MainActivity.kt:19: Activity, BroadcastReceiver, Fragment, Service class must have @AndroidEntryPoint annotation
```


## 終わりに

いかがだったでしょうか？
今回使用したコードは、こちらのRepositoryにあげているので、参考にしてみてください！
https://github.com/shunm-999/hilt-ksp-plugin