I’ve been using Koin in most of the Kotlin Multiplatform (KMP) samples I have but thought it would be good to include use of at least one other DI framework and this article outlines changes made to add kotlin-inject to the BikeShare KMP sample. This project retrieves data from a backend using Ktor and stores data locally on the device using Realm. It supports several platforms but we’ll be focusing on Android and iOS here.
Common KMP code
Starting off, the following were the changes made to libs.version.toml
and build.gradle.kts
. Note also that we’re setting the generateCompanionExtensions
option…this allows a more convenient way of creating the generated kotlin-inject components.
libs.version.toml
1
2
3
4
5
kotlininject = "0.7.1"
kotlininject-compiler = { module = "me.tatarka.inject:kotlin-inject-compiler-ksp", version.ref = "kotlininject" }
kotlininject-runtime = { module = "me.tatarka.inject:kotlin-inject-runtime", version.ref = "kotlininject" }
build.gradle.kts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
commonMain.dependencies {
...
implementation(libs.kotlininject.runtime)
}
...
ksp {
arg("me.tatarka.inject.generateCompanionExtensions", "true")
}
dependencies {
add("kspAndroid", libs.kotlininject.compiler)
add("kspIosX64", libs.kotlininject.compiler)
add("kspIosArm64", libs.kotlininject.compiler)
add("kspIosSimulatorArm64", libs.kotlininject.compiler)
add("kspJvm", libs.kotlininject.compiler)
}
We then define the shared dependencies in SharedApplicationComponent.kt
as shown below. We’re using a single interface given the scope of this project but typically, in larger projects, the dependencies would get grouped into different interfaces.
As we’ll see later, we’re also going to create platform specific components that subclass this interface. This will allow both creating platform specific versions of the dependencies (for example getHttpClientEngine
which is used to create Ktor client engine for each platform) and also to allow clients to pass in objects that might be needed for each platform (though not doing that yet in this project).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
interface SharedApplicationComponent {
val countriesViewModel: CountriesViewModelShared
val networksViewModel: NetworksViewModelShared
val stationsViewModel: StationsViewModelShared
val repository: CityBikesRepository
val cityBikesApi: CityBikesApi
val json: Json
@Provides get() = Json { isLenient = true; ignoreUnknownKeys = true; useAlternativeNames = false }
val realm: Realm
@Provides get() {
val config = RealmConfiguration.create(schema = setOf(NetworkDb::class))
return Realm.open(config)
}
@Provides
fun getHttpClientEngine(): HttpClientEngine
@Provides
fun httpClient(): HttpClient = createHttpClient(getHttpClientEngine(), json)
}
fun createHttpClient(httpClientEngine: HttpClientEngine, json: Json) = HttpClient(httpClientEngine) {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO
}
}
The way those dependencies get injected then in various classes is through use of @Inject
annotation as shown here (we also mark in this case as @Singleton
)
1
2
3
4
@Inject @Singleton
class CityBikesRepository(val cityBikesApi: CityBikesApi,val realm: Realm) {
...
}
Android
The Android specific version of the kotlin-inject component (AndroidApplicationComponent.kt
) is shown below (created in androidMain
in the shared KMP module). In this case we’re using that component to add dependencies for various Composable functions that we want to be passed particular dependencies. We also define the Android specific version of the Ktor client engine here.
1
2
3
4
5
6
7
8
9
10
11
@Component
@Singleton
abstract class AndroidApplicationComponent: SharedApplicationComponent {
abstract val countryListScreen: CountryListScreen
abstract val networkListScreen: NetworkListScreen
abstract val stationsScreen: StationsScreen
override fun getHttpClientEngine() = Android.create()
companion object
}
To allow dependencies to be passed to Composable functions we need to use approach shown below (more info on that in this page). Note also the use of @Assisted
for parameters that we’ll be using when those composable functions are used.
1
2
3
4
5
6
7
8
typealias CountryListScreen = @Composable ((country: Country) -> Unit) -> Unit
@Inject
@Composable
fun CountryListScreen(viewModel: CountriesViewModelShared, @Assisted countrySelected: (country: Country) -> Unit) {
val countryList by viewModel.countryList.collectAsState()
...
}
The following are the changes needed then in the Android client module. We firstly add code to create the component in BikeShareApplication.kt
1
2
3
4
5
6
7
class BikeShareApplication : Application() {
val component: AndroidApplicationComponent by lazy {
AndroidApplicationComponent.create()
}
...
}
That gets used then in MainActivity.kt
as shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
val applicationComponent = (applicationContext as BikeShareApplication).component
setContent {
BikeShareTheme {
BikeShareApp(applicationComponent)
}
}
}
}
The android app is using Jetpack Navigation and this is an example of how the appropriate Composable function was retrieved from the component and used then in our NavHost
.
1
2
3
4
5
6
7
8
9
10
NavHost(navController, startDestination = Screen.CountryListScreen.title) {
val countryListScreen = applicationComponent.countryListScreen
composable(Screen.CountryListScreen.title) {
countryListScreen {
navController.navigate(Screen.NetworkListScreen.title + "/${it.code}")
}
}
...
}
iOS
For iOS we also have a platform specific component (IosApplicationComponent
as shown below) which is created in iosMain
in the shared KMP module. As with Android, we’re also creating the platform specific version of the Ktor client engine here.
1
2
3
4
5
6
7
8
@Component
@Singleton
abstract class IosApplicationComponent: SharedApplicationComponent {
override fun getHttpClientEngine() = Darwin.create()
companion object
}
In our SwiftUI client we create that component on startup and pass to ContentView
.
1
2
let applicationCompoonent = IosApplicationComponent.companion.create()
let contentView = ContentView(applicationCompoonent: applicationCompoonent)
The following then shows example in ContentView
of how we’re using that component to look up one of the shared view models (done in this way also to allow StateFlow
s in the shared view models to be observed in SwiftUI….using KMP-ObservableViewModel library).
1
2
3
4
5
6
7
8
9
10
11
12
13
struct ContentView : View {
let applicationCompoonent: IosApplicationComponent
@ObservedViewModel var viewModel: CountriesViewModelShared
init(applicationCompoonent: IosApplicationComponent) {
self.applicationCompoonent = applicationCompoonent
self.viewModel = applicationCompoonent.countriesViewModel
}
var body: some View {
...
}
}
Featured in Android Weekly #633 and Kotlin Weekly Issue #418
Related tweet
Wrote a short article outlining changes made to add kotlin-inject to the BikeShare Kotlin Multiplatform sample. https://t.co/44VDe6X9t9 #KMP
— John O'Reilly (@joreilly) July 27, 2024