Kotlin-InjectによるDI（依存性注入）をやってみる

依存性注入（以降DI）ライブラリはDagger HiltあるいはKoinがよく用いられるが、今回はkotlin-injectを用いてComposeアプリにDIしていく。

## evant氏のkotlin-injectについて軽く

https://github.com/evant/kotlin-inject

KSPによるコード自動生成技術を用いたDIライブラリ。
Kotlin Multiplatform（以下KMP）に対応している。
詳しい使い方はGitHubのREADMEにて確認できる。

### Androidでの運用について

https://github.com/evant/kotlin-inject/blob/main/docs/android.md

### KMPでの運用について

https://github.com/evant/kotlin-inject/blob/main/docs/multiplatform.md

### 公式サンプル

https://github.com/evant/kotlin-inject-samples

### 同ライブラリを用いたサードアプリ

https://github.com/chrisbanes/tivi

## 前置き

Roomによるデータベース管理、Coilによるオンライン画像の表示、AndroidX Navigationによる複数画面の遷移を備えたMVVMアプリを実験として作成していく。

なおこの記事では、KMPは触れずにAndroidのみに焦点をおき、DIによって分離させたコードのテストはやらないことにする。DIや各種ライブラリに関する説明はある程度省略する。

全体のソースコードおよびKMP対応のソースコードは以下のリンクにて確認できる。

### 全体のソースコード

https://github.com/oikvpqya/qiita-kotlin-inject-sample

### KMP対応ソースコード

https://github.com/oikvpqya/qiita-kotlin-inject-sample/pull/3

## ベースのコンポーネントの作成

kotlin-injectではDagger Hiltの```@Singleton```のようなスコープは提供されていないため、自分で定義していく。

```kotlin:di/ApplicationComponent.kt
@Scope
annotation class ApplicationScope
```

次にDagger Hiltにおける```SingletonComponent```を定義する。

```kotlin:di/ApplicationComponent.kt
@ApplicationScope
@Component
abstract class ApplicationComponent(
    @get:Provides val context: Context,
)
```

このコンポーネントをAndroid Appllicationにて起動する。

```kotlin:di/ApplicationComponent.kt
interface ApplicationComponentProvider {
    val component: ApplicationComponent
}
```
```kotlin:MainApplication.kt
class MainApplication : Application(), ApplicationComponentProvider {

    override val component by lazy(LazyThreadSafetyMode.NONE) {
        ApplicationComponent::class.create(this)
    }
}
```

同様にAndroid Activityに対してもスコープとコンポーネントを定義して起動していく。

```kotlin:di/MainActivityComponent.kt
@Scope
annotation class MainActivityScope
```
```kotlin:di/MainActivityComponent.kt
@MainActivityScope
@Component
abstract class MainActivityComponent(
    @get:Provides val activity: Activity,
    @Component val applicationComponent: ApplicationComponent,
)
```
```kotlin:di/ApplicationComponent.kt
val Context.applicationComponent
    get() = (applicationContext as ApplicationComponentProvider).component
```
```kotlin:MainActivity.kt
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val component = MainActivityComponent::class.create(this, applicationComponent)

        setContent {
            // コンテンツを表示する
        }
    }
}
```

このApplicationComponent、MainActivityComponentに各コンポーネントを以下のように継承させていくことでDIしていく。

```kotlin:di/ApplicationComponent.kt
@ApplicationScope
@Component
abstract class ApplicationComponent(
    @get:Provides val context: Context,
) : CoilImageLoaderComponent, RoomDatabaseComponent
```

ここからはDIしたい各コンポーネントを定義していく。

## CoilのImageLoaderをDIする
Coilはバージョン3.0.0-alpha07を用いる。

```kotlin:di/CoilImageLoaderComponent.kt
interface CoilImageLoaderComponent {

    @ApplicationScope
    @Provides
    fun provideImageLoader(
        context: Context,
    ): ImageLoader {
        return ImageLoader
            .Builder(context)
            .logger(DebugLogger()) // デバッグ用
            .build()
    }
}
```
これをApplicationComponentに継承していく。

```kotlin:di/ApplicationComponent.kt
@ApplicationScope
@Component
abstract class ApplicationComponent(
    @get:Provides val context: Context,
) : CoilImageLoaderComponent
```
Coilバージョン3.0.0-alpha07では```ImageLoader```を```@Composable```関数にて、```setSingletonImageLoaderFactory```を用いてセットできる。詳しくは後述する。


## RoomのデータベースをDIする
AndroidX Roomはバージョン2.7.0-alpha04を用いる。
RoomのドライバにBundledSQLiteDriverを用いることで最新のSQLiteを用いることができる。（Ex. UPSERT, ON CONFLICT）
今回は実験のため以下のような単純な文字列を出し入れできるデータベースを用いる。

```kotlin:data/AppDatabase.kt
@Database(
    entities = [
        Table1Entity::class,
    ],
    version = 1,
    exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun table1Dao(): Table1Dao
}


@Dao
interface Table1Dao {

    @Query(
        value = """
        SELECT string FROM table1
    """
    )
    fun get(): Flow<List<String>>

    @Query(
        value = """
        INSERT INTO table1
        VALUES (:string) ON CONFLICT (string) DO NOTHING
    """
    )
    suspend fun insert(string: String)
}


@Entity(
    tableName = "table1",
)
data class Table1Entity(
    @PrimaryKey val string: String,
)
```
Roomのコンポーネントを定義していく。
```kotlin:di/RoomDatabaseComponent.kt
interface RoomDatabaseComponent {

    @ApplicationScope
    @Provides
    fun provideDatabase(
        context: Context,
    ): AppDatabase {
        val path = context.getDatabasePath("app.db").absolutePath
        return Room
            .databaseBuilder<AppDatabase>(
                context = context,
                name = path,
            )
            .setDriver(BundledSQLiteDriver())
            .setQueryCoroutineContext(Dispatchers.IO)
            .build()
    }

    @ApplicationScope
    @Provides
    fun bindDatabaseRepository(bind: DatabaseRepositoryImpl): DatabaseRepository = bind
}
```
これをApplicationComponentに継承していく。

```kotlin:di/ApplicationComponent.kt
@ApplicationScope
@Component
abstract class ApplicationComponent(
    @get:Provides val context: Context,
) : RoomDatabaseComponent
```

## ViewModelをDIする
ここからはViewModelを```@Composable```関数にDIしていくが、かなり四苦八苦する。
AndroidX LifeCycleはバージョン2.8.3、AndroidX Navigationはバージョン2.8.0-beta04を用いる。
まず以下のようなUiコンポーネントを定義していく。```AppContent```、```AppRouteFactory```は後述する。
```kotlin:di/UiComponent.kt
interface UiComponent : AppContentComponent, AppRouteFactoryComponent
```
```kotlin:di/UiComponent.kt
interface AppContentComponent {

    val appContent: AppContent

    @MainActivityScope
    @Provides
    fun bindAppContent(bind: AppContentImpl): AppContent = bind
}
```
```kotlin:di/UiComponent.kt
interface AppRouteFactoryComponent {

    @IntoSet
    @MainActivityScope
    @Provides
    fun bindHomeRouteFactory(bind: HomeRouteFactory): AppRouteFactory = bind

    @IntoSet
    @MainActivityScope
    @Provides
    fun bindImageRouteFactory(bind: ImageRouteFactory): AppRouteFactory = bind
}
```
```UiComponent```を```MainActivityComponent```に継承
```kotlin:di/MainActivityComponent.kt
@MainActivityScope
@Component
abstract class MainActivityComponent(
    @get:Provides val activity: Activity,
    @Component val applicationComponent: ApplicationComponent,
) : UiComponent
```

```AppContent```はアプリのメイン画面を提供するクラス。
後にアプリをKMP化することを考えるとMainActivityの```setContent```に極力コードを書きたくないため、今回はあらかじめ```AppContent```に```＠Composable```関数を分離させた。
後回しにしていたCoilの```ImageLoader```のセットはここで行う
```kotlin:ui/AppContent.kt
interface AppContent {

    @Composable
    fun Content(
        modifier: Modifier,
    )
}


@Inject
class AppContentImpl(
    private val imageLoader: ImageLoader,
    private val routeFactories: Set<AppRouteFactory>,
) : AppContent {

    @OptIn(ExperimentalCoilApi::class)
    @Composable
    override fun Content(
        modifier: Modifier,
    ) {
        // Coil3ではここでImageLoaderをセットする
        setSingletonImageLoaderFactory { imageLoader }

        MaterialTheme {
            Surface {
                // メイン画面
                App(
                    routeFactories = routeFactories,
                    modifier = modifier,
                )
            }
        }
    }
}
```
```AppRouteFactory```はアプリの各画面にViewModelをDIするために用意したクラス。
ここでは実験のため、データベースの動作を確認するHome画面と、単純な画像を表示するImage画面を用意した。
```kotlin:ui/AppRouteFactory.kt
interface AppRouteFactory {

    fun NavGraphBuilder.create(
        navController: NavController,
        modifier: Modifier,
    )
}

fun AppRouteFactory.create(
    navController: NavController,
    navGraphBuilder: NavGraphBuilder,
    modifier: Modifier = Modifier,
) = navGraphBuilder.create(navController, modifier)
```
```AppContent```の実装の```AppContentImpl```の```App```
```kotlin:ui/AppContent.kt
@Composable
fun App(
    routeFactories: Set<AppRouteFactory>,
    modifier: Modifier = Modifier,
) {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = Home, // Home画面
        modifier = modifier,
    ) {
        routeFactories.forEach { it.create(navController, this) }
    }
}
```
Home画面の```HomeRoute```
```kotlin:ui/HomeScreen.kt
@Serializable
object Home

@Inject
class HomeRouteFactory(
    private val viewModelFactory: () -> HomeViewModel,
) : AppRouteFactory {
    override fun NavGraphBuilder.create(navController: NavController, modifier: Modifier) {
        composable<Home> { _ ->
            val viewModel = viewModel { viewModelFactory() }
            HomeRoute(viewModel, navController, modifier)
        }
    }
}

@Composable
fun HomeRoute(
    viewModel: HomeViewModel,
    navController: NavController,
    modifier: Modifier = Modifier,
) {
    // Home画面
}
```
Home画面の```HomeViewModel```
```kotlin:ui/HomeScreen.kt
@Inject
class HomeViewModel(
    private val repository: DatabaseRepository,
) : ViewModel() {
    // HomeViewModel
}
```
最終的な```MainActivity```
```kotlin:MainActivity.kt
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val component = MainActivityComponent::class.create(this, applicationComponent)

        setContent {
            component.appContent.Content(
                modifier = Modifier,
            )
        }
    }
}
```

以上によりViewModelを```@Composable```関数にDIができた。

## その他

### AndroidX Room バージョン 2.7.0-alpha04

・まだアルファ版ではあるものの、KMPに対応し、さらに副次的に最新のSQLiteを使えるようになった。
```Query```で```UPSERT```を使えるようになったのは非常にありがたい。（以前もSQLDelightやrequery氏のSQLiteドライバで新しいSQLiteを使うことはできた。）

・2.6.x以前の```withTransaction```が2.7.xで大幅に仕様変更されてるっぽい？

参考： 

https://issuetracker.google.com/issues/340606803

これが
```kotlin
database.withTransaction {
    strings.forEach { string ->
        // Daoの処理
    }
}
```
こうなった
```kotlin
database.useWriterConnection { transactor ->
    transactor.immediateTransaction {
        // Daoの処理
    }
    database.invalidationTracker.refreshAsync()
}
```

### AndroidX Navigation Compose バージョン 2.8.0-beta04
・ベータ版にてついにSafe Argsに対応した。文字列に代わって```@Serializable```オブジェクトで画面遷移する。
```kotlin
@Serializable
object Home

@Serializable
data class Search(val string: String)


@Composable
fun App {
    NavHost(
        navController = rememberNavController(),
        startDestination = Home,

    ) {
        composable<Home> { _ ->
            /* 画面 */
        }
        composable<Search> { backStackEntry ->
            val string = backStackEntry.toRoute<Search>().string
            /* 画面 */
        }
    }
}
```

・JetBrainsがKMPに対応したものを同じくベータ版で出している。ありがたい。```BackHandler```はいまのところ自分で実装しなければいけない。

### Coil バージョン 3.0.0-alpha07
・KMP対応に伴い、バージョン2.xから大量に仕様変更された。特にSingletonまわりやネットワークまわり。破壊的な仕様変更がまだまだありそうなので注意が必要。

## 総括

kotlin-injectによるDI（依存性注入）をやってみた。

Dagger HiltはKMPへの対応が未定で、KoinはService LocatorによるDIのため、kotlin-injectは大きな選択肢のひとつのはずだが、記事が数えるほどしかなくこの記事を執筆した。

kotlin-injectはDagger HiltやKoinのようにスコープが用意されていないので、特に```@Composable```関数へDIするのが非常に面倒くさかったものの、やりたいことはできた。

記載を省いたコードについてはGitHubにアップロードしたソースコードにて確認できる。

https://github.com/oikvpqya/qiita-kotlin-inject-sample

KMPに対応させたものはこちら。

https://github.com/oikvpqya/qiita-kotlin-inject-sample/pull/3

各種ライブラリの詳細をかなり省略してしまい、ある程度理解している前提の殴り書きの記事となってしまいましたが、なにかあれば気軽に記事にコメントください。

## License
```
Copyright 2024 oikvpqya Yuya
Portions 2024 kotlin-inject, kotlin-inject-sample by evant
 
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```