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クラス
- Application(@HiltAndroidApp)
- ViewModel(@HiltViewModel)
- Activity(@AndroidEntryPoint)
- Fragment(@AndroidEntryPoint)
- View(@AndroidEntryPoint)
- Service(@AndroidEntryPoint)
- BroadcastReceiver(@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")
}
}
}
}
}
エラーの内容は、こんな感じです。
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クラスに、適切なアノテーションが付与されていなかったら、
コンパイルエラーにします。
- Application(@HiltAndroidApp)
- ViewModel(@HiltViewModel)
- Activity(@AndroidEntryPoint)
- Fragment(@AndroidEntryPoint)
- View(@AndroidEntryPoint)
- Service(@AndroidEntryPoint)
- BroadcastReceiver(@AndroidEntryPoint)
例えば、Activityを継承したMainAcitivtyがあり、
MainAcitivtyに、@AndroidEntryPointアノテーションがついてなかったら、コンパイルエラーにする、
といった具合です。
プラグインの作成方法
ここから、プラグインの作成方法について、説明していきます。
環境セットアップ
まずは、KSP用のモジュールを追加します。
名前は、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に、下記の設定を追加します。
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に、下記の設定を追加します。
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の完全修飾名を記述します。
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