LoginSignup
4
4

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

Last updated at Posted at 2023-12-23

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

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

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

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

Hiltってどんなの?

まずは、Hiltの説明から。

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

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

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

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

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

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

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

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

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

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

Androidクラス

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

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)

正しいコードはこちらになります。

@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)に比べて、
最大2倍早く実行できると言われています。

今回作るプラグインの概要

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

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

プラグインの作成方法

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

環境セットアップ

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

スクリーンショット 2023-12-17 16.02.36.png

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

(: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に、下記の設定を追加します。

(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に、下記の設定を追加します。

(: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モジュール内に、下記のクラスを追加します。

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クラスを作成します。

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で、モジュール内のファイルを全て取得します。


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() }

AndroidComponentMatcherを使って、Androidクラスを継承しているクラスを絞り込みます。

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

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

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を呼び出すことで、
指定したメッセージで、コンパイルエラーになります。

コード全文です。

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

4
4
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
4
4