Skip to main content

HiltからKojectに移行する

Mori Atsushi

KojectはKotlin向けの新しいDIコンテナライブラリです。 この記事では、Androidのサンプルアプリ「Now in Android」を例に、HiltからKojectへ移行する方法について紹介します。

Read in English →

Hilt or Koject

Kojectはマルチプラットフォームに対応しており、iOS等とコードを共通化したい場合、Kojectを使うことでDIコンテナも共通化することができます。 Hilt及びDaggerはKotlin/NativeやKotlin/JSで動作しません。

Androidのみ向けに開発している場合、Hiltは今でも有効な選択肢として考えています。 HiltはAndroid向けに最適化されており、Androidコンポーネントに対するより多くのサポートを得られます。 また、カスタムスコープもKojectでは提供されていません。

一方、機能が少ない分、Kojectのほうがシンプルで理解しやすいとも言えます。 DIコンテナとして標準的な機能はKojectも十分に揃えています。

また、KojectはKSPで動作しており、kaptで動作するHiltと比較してコンパイル時間が短くなる傾向にあります。 DaggerもKSPへの移行を予定していますが、それにはまだ時間がかかります。

KojectとHiltを比較すると、以下の表のようになります。 メリット・デメリットを把握した上で、好きな方を選択してください。

ライブラリKojectDagger
マルチプラットフォーム
Androidサポート
カスタムスコープ
コード生成○(KSP)△(kapt)
マルチモジュール

Kojectを開始する

Now in AndroidでKojectを利用してみましょう。 GitHubのリポジトリも合わせて確認してください。

まず最初に依存関係を追加します。 アプリケーションモジュールのbuild.gradle.ktsに、以下のようにKojectの依存関係を追加してください。

app/build.gradle.kts
plugins {
id("nowinandroid.android.application")

/* ... */

id("com.google.devtools.ksp") version "1.8.0-1.0.9"
}

dependencies {
/* ... */

implementation("com.moriatsushi.koject:koject-android-core:1.3.0")
implementation("com.moriatsushi.koject:koject-android-activity:1.3.0")
ksp("com.moriatsushi.koject:koject-processor-app:1.3.0")
}

/* ... */

ライブラリモジュールでは、koject-processor-appの代わりにkoject-processor-libを利用します。 また、moduleName を設定することを忘れないでください。

{lib}/build.gradle.kts
plugins {
id("nowinandroid.android.library")

/* ... */

id("com.google.devtools.ksp") version "1.8.0-1.0.9"
}

dependencies {
/* ... */

implementation("com.moriatsushi.koject:koject-core:1.3.0")
ksp("com.moriatsushi.koject:koject-processor-lib:1.3.0")
}

ksp {
arg("moduleName", project.name)
}

/* ... */

Now in Androidではbuild-logicディレクトリでセットアップコードを共通化しています。 Kojectのセットアップを以下のようにプラグインでまとめることもできます。

build-logic/conversation/src/main/kotlin/AndroidLibraryKojectConventionPlugin.kt
import com.google.devtools.ksp.gradle.KspExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType

class AndroidLibraryKojectConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.google.devtools.ksp")
apply("com.android.library")
}

val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
"implementation"(libs.findLibrary("koject.core").get())
"ksp"(libs.findLibrary("koject.processor.lib").get())
}

extensions.configure<KspExtension> {
arg("moduleName", name)
allowSourcesFromOtherPlugins = true
}
}
}
}

次にアプリケーションクラスを変更します。 @HiltAndroidAppアノテーションを削除し、onCreateKoject.start()を呼び出してください。

@HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory {
/* ... */

override fun onCreate() {
super.onCreate()
/* ... */
}

/* ... */
}

ImageLoaderを配布する

Hiltで配布している依存関係を、Kojectで配布するよう変更していきます。 ここでは ImageLoader を例に紹介します。

Now in AndroidではCoilImageLoaderNetworkModuleから配布していました。 ImageLoaderの作成にはOkHttpCall.Factoryが必要で、それもHiltで配布されています。 また、それぞれ毎回同じインスタンスを利用するよう、シングルトンになっています。

では、Kojectに移行していきます。 @Module@InstallInは削除します。 Kojectではコンポーネントの指定は必要ありません。 名前は同じですが、dagger.Providescom.moriatsushi.koject.Providesに、javax.inject.Singletoncom.moriatsushi.koject.Singletonに変更する必要があります。 Kojectでは標準でApplicationContextが利用されるため、@ApplicationContextも削除します。

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
/* ... */

@Provides
@Singleton
fun okHttpCallFactory(): Call.Factory = OkHttpClient.Builder()
.addInterceptor(/* ... */)
.build()

@Provides
@Singleton
fun imageLoader(
okHttpCallFactory: Call.Factory,
@ApplicationContext application: Context,
): ImageLoader = ImageLoader.Builder(application)
.apply { /* ... */ }
.build()
}

ImageLoaderはアプリケーションクラスで利用していました。 Kojectではinject()を使ってImageLoaderを取得でき、変更は以下のようになります。

@HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var imageLoader: Provider<ImageLoader>

override fun onCreate() {
super.onCreate()
/* ... */
}

override fun newImageLoader(): ImageLoader = imageLoader.get()
}

HiltとKojectには多くの共通点があり、変更すべきコードは多くないことがわかると思います。

Repositoryを配布する

他の依存関係も移行していきます。

データの永続化を担うRepositoryはinterfaceと実装しているクラスが分かれています。

Hiltを使った場合、@Injectで実装クラスを配布し、DataModule@Bindsを使ってinterfaceに紐づけていました。

Kojectでは、クラスも@Providesアノテーションを使って配布します。 プライマリーコンストラクタが自動的に利用されるため、constructorの記述を削除できます。 DataModuleはinterfaceからobjectに変更し、@Providesを使って記述します。

コンストラクタに使われている全てのタイプが、Kojectで配布されている必要があります。

class OfflineFirstNewsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
) : NewsRepository {
/* ... */
}
@Module
@InstallIn(SingletonComponent::class)
interface DataModule {
@Binds
fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository,
): NewsRepository

/* ... */
}

Kojectでは、@Bindsアノテーションを使って以下のように書くことで、簡単にスーパータイプとして配布できます。 この場合、Moduleクラスが必要なくなります。

@Provides
@Binds
class OfflineFirstNewsRepository(
private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
) : NewsRepository {
/* ... */
}

ViewModelを配布する

ViewModelの移行方法を紹介します。 KojectでもAndroidXのViewModelを簡単に利用することができます。

@Injectアノテーションと@HiltViewModelアノテーションを削除し、@Providsアノテーションと@ViewModelComponentアノテーションを追加します。

@HiltViewModel
class TopicViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
getSaveableNewsResources: GetUserNewsResourcesUseCase,
) : ViewModel() {
/* ... */
}

Composable関数から利用する際は、hiltViewModel()injectViewModel()に置き換えてください。

@Composable
internal fun TopicRoute(
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TopicViewModel = hiltViewModel(),
) {
/* ... */
}

ActivityからViewModelを利用する場合はComponentActivity.lazyViewModels()を利用してください。 Activity内でHiltが必要なければ、@AndroidEntryPointも削除できます。

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
/* ... */

val viewModel: MainActivityViewModel by viewModels()

/* ... */
}

JankStatsを配布する

最後に少し複雑な依存関係の配布方法を紹介します。

JankStatsはアプリのパフォーマンスを計測するためのAndroidXライブラリです。 インスタンスの生成には、Activitywindowが必要で、HiltではActivityComponentを使って実現していました。 このコンポーネントを使うことで、Activityのインスタンスを使うことができます。

Kojectにも@ActivityComponentが用意されています。 Hiltとは異なり、モジュールではなく一つ一つの関数にアノテーションを付与してください。

@Module
@InstallIn(ActivityComponent::class)
object JankStatsModule {
@Provides
fun providesOnFrameListener(): JankStats.OnFrameListener {
return JankStats.OnFrameListener { frameData ->
/* ... */
}
}

@Provides
fun providesWindow(activity: Activity): Window {
return activity.window
}

@Provides
fun providesJankStats(
window: Window,
frameListener: JankStats.OnFrameListener,
): JankStats {
return JankStats.createAndTrack(window, frameListener)
}
}

Activityで利用する際は、ComponentActivity.lazyInject()を利用してください。

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var lazyStats: dagger.Lazy<JankStats>

/* ... */
}

全ての変更を確認する

KojectはHiltと共通点も多く、その使い方は簡単に理解できると思います。 しかし、全ての依存関係の配布をKojectに移行するのは、多少苦労します。 KojectはHiltと同様に足りない依存関係をコンパイル時に確認することができますが、十分に動作確認をしながら移行することをおすすめします。

Now in Androidの全ての変更はMigrate from hilt to koject #1から確認できます。 より多くの設定方法や、テスト時の利用方法についても学ぶことができます。 もしKojectを使ってみたいと感じたら、いつでも参考にしてください。

その他、Hiltからの移行に問題が生じた場合Issueからフィードバックを送ってください。 できる限りサポートします。