KojectはKotlin向けの新しいDIコンテナライブラリです。 この記事では、Androidのサンプルアプリ「Now in Android」を例に、HiltからKojectへ移行する方法について紹介します。
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を比較すると、以下の表のようになります。 メリット・デメリットを把握した上で、好きな方を選択してください。
ライブラリ | Koject | Dagger |
---|---|---|
マルチプラットフォーム | ○ | ✗ |
Androidサポート | ○ | ◎ |
カスタムスコープ | ✗ | ○ |
コード生成 | ○(KSP) | △(kapt) |
マルチモジュール | ○ | ○ |
Kojectを開始する
Now in AndroidでKojectを利用してみましょう。 GitHubのリポジトリも合わせて確認してください。
まず最初に依存関係を追加します。
アプリケーションモジュールのbuild.gradle.kts
に、以下のようにKojectの依存関係を追加してください。
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
を設定することを忘れないでください。
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のセットアップを以下のようにプラグインでまとめることもできます。
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
アノテーションを削除し、onCreate
でKoject.start()
を呼び出してください。
- Hilt
- Koject
@HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory {
/* ... */
override fun onCreate() {
super.onCreate()
/* ... */
}
/* ... */
}
class NiaApplication : Application(), ImageLoaderFactory {
/* ... */
override fun onCreate() {
super.onCreate()
Koject.start {
application(this@NiaApplication)
}
/* ... */
}
/* ... */
}
ImageLoaderを配布する
Hiltで配布している依存関係を、Kojectで配布するよう変更していきます。
ここでは ImageLoader
を例に紹介します。
Now in AndroidではCoilのImageLoader
をNetworkModule
から配布していました。
ImageLoader
の作成にはOkHttpのCall.Factory
が必要で、それもHiltで配布されています。
また、それぞれ毎回同じインスタンスを利用するよう、シングルトンになっています。
では、Kojectに移行していきます。
@Module
と@InstallIn
は削除します。
Kojectではコンポーネントの指定は必要ありません。
名前は同じですが、dagger.Provides
はcom.moriatsushi.koject.Provides
に、javax.inject.Singleton
はcom.moriatsushi.koject.Singleton
に変更する必要があります。
Kojectでは標準でApplicationContext
が利用されるため、@ApplicationContext
も削除します。
- Hilt
- Koject
@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()
}
object NetworkModule {
/* ... */
@Provides
@Singleton
fun okHttpCallFactory(): Call.Factory = OkHttpClient.Builder()
.addInterceptor(/* ... */)
.build()
@Provides
@Singleton
fun imageLoader(
okHttpCallFactory: Call.Factory,
application: Context,
): ImageLoader = ImageLoader.Builder(application)
.apply { /* ... */ }
.build()
}
ImageLoader
はアプリケーションクラスで利用していました。
Kojectではinject()
を使ってImageLoader
を取得でき、変更は以下のようになります。
- Hilt
- Koject
@HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var imageLoader: Provider<ImageLoader>
override fun onCreate() {
super.onCreate()
/* ... */
}
override fun newImageLoader(): ImageLoader = imageLoader.get()
}
class NiaApplication : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
/* ... */
}
override fun newImageLoader(): ImageLoader = inject()
}
HiltとKojectには多くの共通点があり、変更すべきコードは多くないことがわかると思います。
Repositoryを配布する
他の依存関係も移行していきます。
データの永続化を担うRepositoryはinterfaceと実装しているクラスが分かれています。
Hiltを使った場合、@Inject
で実装クラスを配布し、DataModule
で@Binds
を使ってinterfaceに紐づけていました。
Kojectでは、クラスも@Provides
アノテーションを使って配布します。
プライマリーコンストラクタが自動的に利用されるため、constructor
の記述を削除できます。
DataModule
はinterfaceからobjectに変更し、@Provides
を使って記述します。
コンストラクタに使われている全てのタイプが、Kojectで配布されている必要があります。
- Hilt
- 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
/* ... */
}
@Provides
class OfflineFirstNewsRepository(
private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
) : NewsRepository {
/* ... */
}
object DataModule {
@Provides
fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository,
): NewsRepository = 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
アノテーションを追加します。
- Hilt
- Koject
@HiltViewModel
class TopicViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
getSaveableNewsResources: GetUserNewsResourcesUseCase,
) : ViewModel() {
/* ... */
}
@ViewModelComponent
@Provides
class TopicViewModel(
savedStateHandle: SavedStateHandle,
stringDecoder: StringDecoder,
private val userDataRepository: UserDataRepository,
topicsRepository: TopicsRepository,
getSaveableNewsResources: GetUserNewsResourcesUseCase,
) : ViewModel() {
/* ... */
}
Composable関数から利用する際は、hiltViewModel()
をinjectViewModel()
に置き換えてください。
- Hilt
- Koject
@Composable
internal fun TopicRoute(
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TopicViewModel = hiltViewModel(),
) {
/* ... */
}
@Composable
internal fun TopicRoute(
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TopicViewModel = injectViewModel(),
) {
/* ... */
}
ActivityからViewModelを利用する場合はComponentActivity.lazyViewModels()
を利用してください。
Activity内でHiltが必要なければ、@AndroidEntryPoint
も削除できます。
- Hilt
- Koject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
/* ... */
val viewModel: MainActivityViewModel by viewModels()
/* ... */
}
class MainActivity : ComponentActivity() {
/* ... */
val viewModel: MainActivityViewModel by lazyViewModels()
/* ... */
}
JankStatsを配布する
最後に少し複雑な依存関係の配布方法を紹介します。
JankStatsはアプリのパフォーマンスを計測するためのAndroidXライブラリです。
インスタンスの生成には、Activity
のwindow
が必要で、HiltではActivityComponent
を使って実現していました。
このコンポーネントを使うことで、Activity
のインスタンスを使うことができます。
Kojectにも@ActivityComponent
が用意されています。
Hiltとは異なり、モジュールではなく一つ一つの関数にアノテーションを付与してください。
- Hilt
- Koject
@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)
}
}
object JankStatsModule {
@ActivityComponent
@Provides
fun providesOnFrameListener(): JankStats.OnFrameListener {
return JankStats.OnFrameListener { frameData ->
/* ... */
}
}
@ActivityComponent
@Provides
fun providesWindow(activity: Activity): Window {
return activity.window
}
@ActivityComponent
@Provides
fun providesJankStats(
window: Window,
frameListener: JankStats.OnFrameListener,
): JankStats {
return JankStats.createAndTrack(window, frameListener)
}
}
Activityで利用する際は、ComponentActivity.lazyInject()
を利用してください。
- Hilt
- Koject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var lazyStats: dagger.Lazy<JankStats>
/* ... */
}
class MainActivity : ComponentActivity() {
private val lazyStats: JankStats by lazyInject()
/* ... */
}
全ての変更を確認する
KojectはHiltと共通点も多く、その使い方は簡単に理解できると思います。 しかし、全ての依存関係の配布をKojectに移行するのは、多少苦労します。 KojectはHiltと同様に足りない依存関係をコンパイル時に確認することができますが、十分に動作確認をしながら移行することをおすすめします。
Now in Androidの全ての変更はMigrate from hilt to koject #1から確認できます。 より多くの設定方法や、テスト時の利用方法についても学ぶことができます。 もしKojectを使ってみたいと感じたら、いつでも参考にしてください。
その他、Hiltからの移行に問題が生じた場合Issueからフィードバックを送ってください。 できる限りサポートします。