Multiplatform Dependency Injection in Kotlin
KMM Architecture #5
In the KMM we can’t use well known Android DI solutions like Dagger or Hilt. This article shows alternative approaches which can be applied to provide Multiplatform objects to the native applications.
Single entry point
Regardless of the architecture, we should always create a single entry point for the shared codebase. A single entry point makes it easier to manage shared instances. We access them from the native application using public properties or methods.
// Shared Services
class DemoSdk(private val config: DemoConfig) {
val usersService: UsersService
get() = ...
val articlesService: ArticlesService
get() = ...
}
// Shared MVI
class DemoSdk(private val config: DemoConfig) {
val loginViewModel: LoginViewModel
get() = ...
val homeViewModel: HomeViewModel
get() = ...
}
Configuration
Additionally the entry point can accept a Config
object as a constructor parameter. It holds an external configuration which can be set by the native application.
It is especially useful when we need to pass some platform specific configuration down to the KMM implementation. For example on the Android side we need a reference to the Context
to init an SQL database or Shared Preferences. Config
class can be implemented using the KMM expect/actual
mechanism.
// commonMain
expect class DemoConfig {
val databaseName: String
}
// androidMain
actual data class DemoConfig(
actual val databaseName: String,
val context: Context,
)
// iosMain
actual data class DemoConfig(
actual val databaseName: String
)
Use in the Android app
Use of the KMM Entry Point in the Android App is very simple. All we need to do is create an instance of the DemoSdk
class and store it as a singleton. It should be accessible from different parts of the app. To achieve it we can use Hilt, Dagger or do it manually like presented below.
class DemoApp : Application() {
lateinit var sdk: DemoSdk
override fun onCreate() {
val config = DemoConfig(
databaseName = "android_demo.db",
context = applicationContext,
)
sdk = DemoSdk(config)
}
}
@Composable
fun getDemoSdk(): DemoSdk {
val context = LocalContext.current
val app = context.applicationContext as DemoApp
return app.sdk
}
@Composable
fun LoginScreen() {
val demoSdk = getDemoSdk()
// Standard androidx viewModel function
val viewModel = viewModel { demoSdk.loginViewModel }
val state by viewModel.uiState.collectAsState()
LoginScreen(state)
}
Use in the iOS app
Situation on the iOS is equally simple as for the Android. Thanks to the single entry point concept we can access Multiplatform code in the exactly the same way on both platforms. Here we can also use a DI solution of our choice or do it manually like presented below.
class DemoSdkProvider {
static let shared = DemoSdkProvider()
let sdk: DemoSdk
private init() {
let config = DemoConfig(databaseName: "ios_demo.db")
self.sdk = DemoSdk(config)
}
}
func buildLoginController() -> UIViewController {
let demoSdk = DemoSdkprovider.shared.sdk
let viewModel = LoginViewModel(
viewModelDelegate: demoSdk.loginViewModel
)
let view = LoginView(viewModel: viewModel)
return UIViewHostingController(rootView: view)
}
Single entry point vs other approaches
In the Internet we can find KMM samples where single entry point is not in use. They often rely on the Koin library in both KMM and Android parts of the codebase.
The big downside of this approach is that Android and iOS apps access the shared code in a different way. Android is privileged as it can use Koin modules directly. iOS app, on the other hand, doesn’t have a Koin support and requires extra helper functions to start Koin and get objects from it.
In order to keep it consistent I recommend to always introduce a single entry point class for the KMM part. Thanks to this both Android and iOS apps access the shared code exactly the same way.
The Android app is also not forced to use the Koin library. An instance of DemoSdk
can be managed in different ways, including adding it to the Hilt or Dagger @Module
Plain Kotlin DI
For simple cases it is perfectly fine to build a simple custom DI solution using just a Kotlin language features.
- Objects which can be used by the native app are
public
properties. - Objects which are used only inside the KMM codebase are
private
orinternal
properties. - When we need to define a Factory for a class we implement a
get()
for a given property. - When we need to define a Singleton we implement a
lazy
delegate for a given property. Config
object can be used to create configurable instances.
class DemoSdk(private val config: DemoConfig) {
// It can be accessed by the native app
val usersService: UsersService
get() = UsersService(usersRepository)
val articlesService: ArticlesService
get() = ArticlesService(articlesRepository, usersRepository)
// It can be accessed only inside the KMM module
private val usersRepository: UsersRepository by lazy {
UsersRepository(usersApi)
}
// lazy works as a Singleton
private val articlesRepository: ArticlesRepository by lazy {
ArticlesRepository(articlesApi)
}
// get() works as a Factory
private val usersApi: UsersApi
get() = UsersApi(httpClient)
private val articlesApi: ArticlesApi
get() = ArticlesApi(httpClient)
private val httpClient: HttpClient by lazy {
createHttpClient(config)
}
}
Plain Kotlin DI — submodules
When we split our Multiplatform implementation into submodules and use shared
module as an Umbrella we should also split the DI structure.
- Each submodule defines its own entry point called
*Module
- Module can depend on other Modules and get objects from them.
- Root
DemoSdk
entry point combines all the Modules in one place and keeps them as Singletons. DemoSdk
can passConfig
to the Modules if they need it.
class UsersModule(private val coreModule: CoreModule) {
val usersService: UsersService
get() = UsersService(...)
...
private val usersApi: UsersApi
get() = UsersApi(coreModule.httpClient)
}
class DemoSdk(private val config: DemoConfig) {
val usersModule: UsersModule by lazy {
UsersModule(coreModule)
}
val coreModule: CoreModule by lazy {
CoreModule(config)
}
}
Koin DI
We can also choose to use a previously mentioned Koin library to manage our Multiplatform objects. In contrast to Dagger and Hilt, Koin has a direct support for the KMM projects.
DemoSdk
creates an instance of theKoinApplication
and attaches Koin modules to it.DemoSdk
exposes public properties, same as for the Plain Kotlin DI.- Each public property uses the
KoinApplication
to get a given object. Config
is also attached to theKoinApplication
as a Koin module.
class DemoSdk(config: DemoConfig) {
private val configModule = module {
factory { config }
}
private val koinApplication = KoinApplication
.init()
.modules(demoSdkModule, configModule)
val usersService: UsersService
get() = getFromKoin()
val articlesService: ArticlesService
get() = getFromKoin()
private inline fun <reified T> getFromKoin(): T {
return koinApplication.koin.get()
}
}
⚠️ Avoid using standard startKoin
function when using Koin in the shared KMM SDK. startKoin
registers a global instance of Koin and we want to scope it only to the KMM part so the Native app doesn’t have to rely on this library.
Koin DI — submodules
When we split our KMM codebase into submodules and the shared
module as an Umbrella we can also split our Koin setup.
DemoSdk
exposes public properties with objects, same as before.- Each submodule defines its own Koin module.
DemoSdk
creates an instance of theKoinApplication
and attaches all the submodules to it.
val usersModule = module {
factoryOf(::UsersService)
...
}
class DemoSdk(config: DemoConfig) {
private val configModule = module {
factory { config }
}
private val koinApplication = KoinApplication
.init()
.modules(
usersModule,
articlesModule,
coreModule,
configModule,
)
val usersService: UsersService
get() = getFromKoin()
val articlesService: ArticlesService
get() = getFromKoin()
private inline fun <reified T> getFromKoin(): T {
return koinApplication.koin.get()
}
}