2
3

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

Last updated at Posted at 2024-07-04

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

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

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

Androidでの運用について

KMPでの運用について

公式サンプル

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

前置き

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

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

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

全体のソースコード

KMP対応ソースコード

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

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

di/ApplicationComponent.kt
@Scope
annotation class ApplicationScope

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

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

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

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

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

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

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

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

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

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

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

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

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

CoilのImageLoaderをDIする

Coilはバージョン3.0.0-alpha07を用いる。

di/CoilImageLoaderComponent.kt
interface CoilImageLoaderComponent {

    @ApplicationScope
    @Provides
    fun provideImageLoader(
        context: Context,
    ): ImageLoader {
        return ImageLoader
            .Builder(context)
            .logger(DebugLogger()) // デバッグ用
            .build()
    }
}

これをApplicationComponentに継承していく。

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)
今回は実験のため以下のような単純な文字列を出し入れできるデータベースを用いる。

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のコンポーネントを定義していく。

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に継承していく。

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コンポーネントを定義していく。AppContentAppRouteFactoryは後述する。

di/UiComponent.kt
interface UiComponent : AppContentComponent, AppRouteFactoryComponent
di/UiComponent.kt
interface AppContentComponent {

    val appContent: AppContent

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

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

    @IntoSet
    @MainActivityScope
    @Provides
    fun bindImageRouteFactory(bind: ImageRouteFactory): AppRouteFactory = bind
}

UiComponentMainActivityComponentに継承

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のセットはここで行う

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画面を用意した。

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の実装のAppContentImplApp

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

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

ui/HomeScreen.kt
@Inject
class HomeViewModel(
    private val repository: DatabaseRepository,
) : ViewModel() {
    // HomeViewModel
}

最終的なMainActivity

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を使えるようになった。
QueryUPSERTを使えるようになったのは非常にありがたい。(以前もSQLDelightやrequery氏のSQLiteドライバで新しいSQLiteを使うことはできた。)

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

参考:

これが

database.withTransaction {
    strings.forEach { string ->
        // Daoの処理
    }
}

こうなった

database.useWriterConnection { transactor ->
    transactor.immediateTransaction {
        // Daoの処理
    }
    database.invalidationTracker.refreshAsync()
}

AndroidX Navigation Compose バージョン 2.8.0-beta04

・ベータ版にてついにSafe Argsに対応した。文字列に代わって@Serializableオブジェクトで画面遷移する。

@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にアップロードしたソースコードにて確認できる。

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

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

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.
2
3
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
2
3