Skip to content

Commit 8896d5f

Browse files
authored
Refactor: Replace kotlin-inject with Metro for dependency injection (#155)
Upside: - Metro is a Kotlin compiler plugin, so it generates .class files directly during IR transformation rather than producing source files like KSP. This removes the need for the KSP plugin and seems to also eliminate per iOS target wiring in the build script, this is nice. Downside: - `@Inject` on top-level functions works at compile time, but the generated types are not resolved by the IDE ("Unresolved reference" errors). Wrapper classes with `@Inject` on the constructor and a `@Composable operator fun invoke()` seem to be the recommended approach to avoid this. However, this is not ideal as it adds boilerplate (see: https://zacsweers.github.io/metro/latest/installation/#ide-support). - Metro graphs cannot have constructor parameters, and platform specific modules cannot extend the shared graph with additional bindings (only the reverse direction is supported via `@GraphExtension` it seems). So platform dependencies must be constructed manually. Currently there is only one (`PlatformFileDataSource`), but if more are added this might get messy. kotlin-inject avoided this through component inheritance.
1 parent 566354c commit 8896d5f

28 files changed

Lines changed: 193 additions & 275 deletions

File tree

androidApp/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ val abysnerBuildNumber: String by project.properties
1818
plugins {
1919
alias(libs.plugins.androidApplication)
2020
alias(libs.plugins.compose.compiler)
21+
alias(libs.plugins.metro)
2122
alias(libs.plugins.screenshot)
2223
alias(libs.plugins.kover)
2324
id("screenshot-reference-cleanup")
@@ -116,6 +117,7 @@ screenshotTests {
116117

117118
dependencies {
118119
implementation(project(":composeApp"))
120+
implementation(project(":data"))
119121
implementation(libs.androidx.activity.compose)
120122

121123
screenshotTestImplementation(libs.screenshot.validation.api)

androidApp/src/main/kotlin/org/neotech/app/abysner/AbysnerApplication.kt

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* Abysner - Dive planner
3-
* Copyright (C) 2024 Neotech
3+
* Copyright (C) 2024-2026 Neotech
44
*
55
* Abysner is free software: you can redistribute it and/or modify
66
* it under the terms of the GNU Affero General Public License version 3,
@@ -13,20 +13,26 @@
1313
package org.neotech.app.abysner
1414

1515
import android.app.Application
16+
import dev.zacsweers.metro.createGraphFactory
17+
import org.neotech.app.abysner.data.PlatformFileDataSourceImpl
1618
import org.neotech.app.abysner.di.AppComponent
17-
import org.neotech.app.abysner.di.PlatformComponentImpl
18-
import org.neotech.app.abysner.di.create
1919

2020
class AbysnerApplication: Application() {
2121

2222
private lateinit var appComponent: AppComponent
2323

2424
override fun onCreate() {
2525
super.onCreate()
26-
val platformComponent = PlatformComponentImpl::class.create(this.applicationContext)
27-
appComponent = AppComponent::class.create(platformComponent)
26+
// Metro graphs cannot have constructor parameters, and platform source sets cannot extend
27+
// the shared graph with additional bindings (only the reverse direction is supported via
28+
// @GraphExtension it seems). So platform dependencies must be constructed manually and
29+
// passed through a @DependencyGraph.Factory. Currently, there is only one
30+
// (PlatformFileDataSource), but if more platform-specific bindings are added this might get
31+
// messy? Each one needs a factory parameter, a @Provides function in AppComponent, and
32+
// manual construction at every call site (Android, iOS, JVM).
33+
// kotlin-inject avoided this through component inheritance.
34+
appComponent = createGraphFactory<AppComponent.Factory>().create(PlatformFileDataSourceImpl(this.applicationContext))
2835
}
2936

3037
fun appComponent(): AppComponent = appComponent
3138
}
32-

composeApp/build.gradle.kts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,8 @@
1010
* along with this program. If not, see https://www.gnu.org/licenses/.
1111
*/
1212

13-
import com.google.devtools.ksp.gradle.KspAATask
1413
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
1514
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
16-
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
17-
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget
18-
import org.neotech.gradle.capitalizeFirstCharacter
1915
import java.io.ByteArrayOutputStream
2016

2117
// DMG distribution does not support "-beta", MSI requires at least MAJOR.MINOR.BUILD
@@ -29,7 +25,7 @@ plugins {
2925
alias(libs.plugins.androidKmpLibrary)
3026
alias(libs.plugins.jetbrainsCompose)
3127
alias(libs.plugins.compose.compiler)
32-
alias(libs.plugins.ksp)
28+
alias(libs.plugins.metro)
3329
alias(libs.plugins.kover)
3430
}
3531

@@ -114,7 +110,6 @@ kotlin {
114110
commonMain.dependencies {
115111
implementation(project(":domain"))
116112
implementation(project(":data"))
117-
implementation(libs.kotlinInject.runtimeKmp)
118113
implementation(libs.navigation.compose)
119114
implementation(libs.jetbrains.lifecycle.viewmodel)
120115
implementation(libs.jetbrains.lifecycle.runtime)
@@ -162,17 +157,6 @@ compose.desktop {
162157

163158
dependencies {
164159

165-
// This is the same as repeating:
166-
// add(target, libs.kotlinInject.compilerKsp)
167-
// where `target` is "kspDesktop", "kspAndroid", "kspIosX64" "kspIosArm64" or "kspIosSimulatorArm64"
168-
val kotlinTargets: Sequence<KotlinTarget> = kotlin.targets.asSequence()
169-
kotlinTargets.filter {
170-
// Don't add KSP for common target, only final platforms
171-
it.platformType != KotlinPlatformType.common
172-
}.forEach {
173-
add("ksp${it.targetName.capitalizeFirstCharacter()}", libs.kotlinInject.compilerKsp)
174-
}
175-
176160
androidRuntimeClasspath(libs.jetbrains.compose.ui.tooling)
177161
}
178162

@@ -246,6 +230,6 @@ rootProject.file("iosApp/Configuration/Version.xcconfig").writeText(
246230
""".trimIndent() + "\n"
247231
)
248232

249-
tasks.withType(KspAATask::class.java).configureEach {
233+
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
250234
dependsOn(versionInfoProvider)
251235
}

composeApp/src/androidMain/kotlin/org/neotech/app/abysner/di/PlatformComponent.android.kt

Lines changed: 0 additions & 31 deletions
This file was deleted.
Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* Abysner - Dive planner
3-
* Copyright (C) 2024 Neotech
3+
* Copyright (C) 2024-2026 Neotech
44
*
55
* Abysner is free software: you can redistribute it and/or modify
66
* it under the terms of the GNU Affero General Public License version 3,
@@ -12,9 +12,9 @@
1212

1313
package org.neotech.app.abysner.di
1414

15-
import me.tatarka.inject.annotations.Component
16-
import me.tatarka.inject.annotations.Provides
17-
import me.tatarka.inject.annotations.Scope
15+
import dev.zacsweers.metro.DependencyGraph
16+
import dev.zacsweers.metro.Provides
17+
import dev.zacsweers.metro.SingleIn
1818
import org.neotech.app.abysner.data.PersistenceRepositoryImpl
1919
import org.neotech.app.abysner.data.diveplanning.PlanningRepositoryImpl
2020
import org.neotech.app.abysner.data.PlatformFileDataSource
@@ -24,30 +24,28 @@ import org.neotech.app.abysner.domain.persistence.PersistenceRepository
2424
import org.neotech.app.abysner.domain.settings.SettingsRepository
2525
import org.neotech.app.abysner.presentation.MainNavController
2626

27-
@Scope
28-
annotation class AppScope
27+
abstract class AppScope
2928

30-
@AppScope
31-
@Component
32-
abstract class AppComponent(@Component val platformComponent: PlatformComponent) {
29+
@SingleIn(AppScope::class)
30+
@DependencyGraph
31+
abstract class AppComponent {
3332

3433
abstract val mainNavController: MainNavController
3534

36-
@AppScope
35+
@SingleIn(AppScope::class)
3736
@Provides
3837
fun providesPlanningRepository(planningRepository: PlanningRepositoryImpl): PlanningRepository = planningRepository
3938

40-
@AppScope
39+
@SingleIn(AppScope::class)
4140
@Provides
4241
fun providesSettingsRepository(settingsRepository: SettingsRepositoryImpl): SettingsRepository = settingsRepository
4342

44-
@AppScope
43+
@SingleIn(AppScope::class)
4544
@Provides
4645
fun providesPersistenceRepository(persistenceRepository: PersistenceRepositoryImpl): PersistenceRepository = persistenceRepository
4746

48-
}
49-
50-
abstract class PlatformComponent {
51-
52-
abstract val providesPlatformFileDataSource: PlatformFileDataSource
47+
@DependencyGraph.Factory
48+
fun interface Factory {
49+
fun create(@Provides platformFileDataSource: PlatformFileDataSource): AppComponent
50+
}
5351
}

composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavController.kt

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,26 @@
1212

1313
package org.neotech.app.abysner.presentation
1414

15+
import androidx.compose.foundation.layout.Box
1516
import androidx.compose.runtime.Composable
1617
import androidx.compose.runtime.CompositionLocalProvider
1718
import androidx.compose.runtime.collectAsState
1819
import androidx.compose.runtime.getValue
1920
import androidx.lifecycle.viewmodel.compose.viewModel
2021
import androidx.navigation.compose.rememberNavController
21-
import me.tatarka.inject.annotations.Inject
22-
import androidx.compose.ui.tooling.preview.Preview
23-
import org.neotech.app.abysner.di.AppScope
24-
import org.neotech.app.abysner.presentation.screens.about.AboutScreen
25-
import org.neotech.app.abysner.presentation.screens.planner.PlannerScreen
22+
import dev.zacsweers.metro.Inject
23+
import org.neotech.app.abysner.presentation.component.BitmapRenderRoot
2624
import org.neotech.app.abysner.presentation.screens.DiveConfigurationScreen
2725
import org.neotech.app.abysner.presentation.screens.SettingsScreen
26+
import org.neotech.app.abysner.presentation.screens.about.AboutScreen
27+
import org.neotech.app.abysner.presentation.screens.planner.PlannerScreen
2828
import org.neotech.app.abysner.presentation.screens.terms_and_conditions.TermsAndConditionsScreen
2929
import org.neotech.app.abysner.presentation.theme.LocalThemeMode
3030
import org.neotech.app.abysner.presentation.utilities.DestinationDefinition
3131
import org.neotech.app.abysner.presentation.utilities.NavHost
3232
import org.neotech.app.abysner.presentation.utilities.fadeComposable
3333
import org.neotech.app.abysner.presentation.utilities.rootComposable
3434
import org.neotech.app.abysner.presentation.utilities.slideComposable
35-
import androidx.compose.foundation.layout.Box
36-
import org.neotech.app.abysner.presentation.component.BitmapRenderRoot
3735

3836
enum class Destinations(override val destinationName: String) : DestinationDefinition {
3937
PLANNER("planner"),
@@ -44,25 +42,41 @@ enum class Destinations(override val destinationName: String) : DestinationDefin
4442
TERMS_AND_CONDITIONS_INITIAL("terms-and-conditions-initial")
4543
}
4644

47-
typealias MainNavController = @Composable () -> Unit
45+
// Metro supports @Inject on top-level functions, but the generated types are not resolved by the
46+
// IDE, causing "Unresolved reference" errors. This wrapper class avoids those IDE errors.
47+
// See: https://zacsweers.github.io/metro/latest/installation/#ide-support
48+
@Inject
49+
class MainNavController(
50+
private val viewModelCreator: () -> MainNavControllerViewModel,
51+
private val plannerScreen: PlannerScreen,
52+
private val diveConfigurationScreen: DiveConfigurationScreen,
53+
private val settingsScreen: SettingsScreen,
54+
private val termsAndConditionsScreen: TermsAndConditionsScreen,
55+
private val aboutScreen: AboutScreen,
56+
) {
57+
@Composable
58+
operator fun invoke() {
59+
MainNavController(
60+
viewModel = viewModel { viewModelCreator() },
61+
plannerScreen = plannerScreen,
62+
diveConfigurationScreen = diveConfigurationScreen,
63+
settingsScreen = settingsScreen,
64+
termsAndConditionsScreen = termsAndConditionsScreen,
65+
aboutScreen = aboutScreen,
66+
)
67+
}
68+
}
4869

4970
@Composable
50-
@Preview
51-
@AppScope
52-
@Inject
5371
fun MainNavController(
54-
viewModelCreator: () -> MainNavControllerViewModel,
72+
viewModel: MainNavControllerViewModel,
5573
plannerScreen: PlannerScreen,
5674
diveConfigurationScreen: DiveConfigurationScreen,
5775
settingsScreen: SettingsScreen,
5876
termsAndConditionsScreen: TermsAndConditionsScreen,
59-
aboutScreen: AboutScreen
77+
aboutScreen: AboutScreen,
6078
) {
6179

62-
val viewModel = viewModel {
63-
viewModelCreator()
64-
}
65-
6680
val startDestination = when (viewModel.settings.value.termsAndConditionsAccepted) {
6781
false -> Destinations.TERMS_AND_CONDITIONS_INITIAL
6882
true -> Destinations.PLANNER

composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/MainNavControllerViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.SharingStarted
1818
import kotlinx.coroutines.flow.StateFlow
1919
import kotlinx.coroutines.flow.stateIn
2020
import kotlinx.coroutines.runBlocking
21-
import me.tatarka.inject.annotations.Inject
21+
import dev.zacsweers.metro.Inject
2222
import org.neotech.app.abysner.domain.settings.SettingsRepository
2323
import org.neotech.app.abysner.domain.settings.model.SettingsModel
2424

composeApp/src/commonMain/kotlin/org/neotech/app/abysner/presentation/screens/DiveConfigurationScreen.kt

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,14 @@ import androidx.compose.runtime.getValue
3333
import androidx.compose.ui.Modifier
3434
import androidx.compose.ui.platform.LocalInspectionMode
3535
import androidx.compose.ui.text.buildAnnotatedString
36+
import androidx.compose.ui.tooling.preview.Preview
3637
import androidx.compose.ui.unit.dp
3738
import androidx.navigation.NavHostController
3839
import androidx.navigation.compose.currentBackStackEntryAsState
3940
import androidx.navigation.compose.rememberNavController
41+
import dev.zacsweers.metro.Inject
4042
import kotlinx.collections.immutable.persistentListOf
4143
import kotlinx.collections.immutable.toImmutableList
42-
import me.tatarka.inject.annotations.Assisted
43-
import me.tatarka.inject.annotations.Inject
44-
import androidx.compose.ui.tooling.preview.Preview
4544
import org.neotech.app.abysner.domain.core.model.Configuration
4645
import org.neotech.app.abysner.domain.core.model.Salinity
4746
import org.neotech.app.abysner.domain.core.model.UnitSystem
@@ -67,16 +66,32 @@ import org.neotech.app.abysner.presentation.utilities.volumePerMinuteUnitLabel
6766
import kotlin.math.abs
6867
import kotlin.math.roundToInt
6968

70-
typealias DiveConfigurationScreen = @Composable (navController: NavHostController) -> Unit
7169

72-
@OptIn(ExperimentalMaterial3Api::class)
70+
// Metro supports @Inject on top-level functions, but the generated types are not resolved by the
71+
// IDE, causing "Unresolved reference" errors. This wrapper class avoids those IDE errors.
72+
// See: https://zacsweers.github.io/metro/latest/installation/#ide-support
7373
@Inject
74+
class DiveConfigurationScreen(
75+
private val planningRepository: PlanningRepository,
76+
private val settingsRepository: SettingsRepository,
77+
) {
78+
@Composable
79+
operator fun invoke(navController: NavHostController) {
80+
DiveConfigurationScreen(
81+
navController = navController,
82+
planningRepository = planningRepository,
83+
settingsRepository = settingsRepository,
84+
)
85+
}
86+
}
87+
7488
@Composable
7589
fun DiveConfigurationScreen(
90+
navController: NavHostController,
7691
planningRepository: PlanningRepository,
7792
settingsRepository: SettingsRepository,
78-
@Assisted navController: NavHostController = rememberNavController()
7993
) {
94+
// TODO should be adding a ViewModel to this screen
8095
val configuration by planningRepository.configuration.collectAsState()
8196
val settings by settingsRepository.settings.collectAsState()
8297
DiveConfigurationScreen(

0 commit comments

Comments
 (0)