Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiplatform expect / actual module support #105

Closed
matejsemancik opened this issue Dec 7, 2023 · 10 comments
Closed

Multiplatform expect / actual module support #105

matejsemancik opened this issue Dec 7, 2023 · 10 comments
Labels
Milestone

Comments

@matejsemancik
Copy link

matejsemancik commented Dec 7, 2023

Is your feature request related to a problem? Please describe.

I'm going to describe my problem using example.

I have a Multiplatform dependency called Persistence which has platform-specific implementations:

// commonMain
interface Persistence

// androidMain
class AndroidPersistence(context: Context) : Persistence

// iosMain
class IosPersistence : Persistence

I would like to provide this Persistence dependency in my commonMain source sets using annotated Koin module. My approach is to create an expect/actual module class named PersistencePlatformModule and annotate it using @Module annotation, where actual implementation provides platform-specific dependency for creating an instance of Persistence (for example Android needs an application Context which is also provided by Koin but only in androidMain source set).

Then I include that module into my other module PersistenceModule which contains the rest of other dependencies I use in my project:

// commonMain
@Module(includes = [PersistencePlatformModule::class])
class PersistenceModule { ... }

@Module
expect class PersistencePlatformModule {
    @Single
    fun persistence(): Persistence
}

// androidMain
@Module
actual class PersistencePlatformModule(private val context: Context) {
    @Single
    actual fun persistence(): Persistence = AndroidPersistence(context)
}

// iosMain
@Module
actual class PersistencePlatformModule {
    @Single
    actual fun persistence(): Persistence = IosPersistence()
}

The compilation of above fails with some errors:

  • Expected class PersistencePlatformModule does not have default constructor
  • No value passed for parameter 'context'

My next approach was to define a module without expected dependency function:

@Module
expect class PersistencePlatformModule()

// androidMain
@Module
actual class PersistencePlatformModule actual constructor() {
    @Single
    fun persistence(context: Context): Persistence = AndroidPersistence(context)
}

// iosMain
@Module
actual class PersistencePlatformModule actual constructor() {
    @Single
    fun persistence(): Persistence = IosPersistence()
}

This will compile without compile-time checks and PersistenceModule.module extension is generated. However when Persistence is injected at runtime, the application crashes with NoBeanDefFoundException.

This will also not compile with compile-time checks:

e: [ksp] --> Missing Definition type 'my.app.package.Persistence' for `my.app.package.WhereItsInjectedClass`.

Describe the solution you'd like
I would like to be able to specify expect/actual modules using Koin annotations using one of described approaches above ☝️

Describe alternatives you've considered
The alternative is to not use koin-annotations and fall back to typical module-as-function approach:

// commonMain
expect fun persistencePlatformModule(): Module

// androidMain
actual fun persistencePlatformModule() = module {
    singleOf(::AndroidPersistence) bind Persistence::class
}

// iosMain
actual fun persistencePlatformModule() = module {
    singleOf(::IosPersistence) bind Persistence::class
}

This approach works, however when combined with other koin-annotations modules in your project, you cannot use compile-time checks because they fail to check dependencies provided by this functional-style module.

Target Koin project
koin-annotations

@arnaudgiuliani arnaudgiuliani added this to the 1.4.0 milestone Jan 30, 2024
@arnaudgiuliani arnaudgiuliani added type:improvement status:checking Ticket is currently being checked labels Jan 30, 2024
@arnaudgiuliani
Copy link
Member

Interesting feedback. Let's see how we can get close to the actual/expect KMP declarations 👍

@kalinjul
Copy link

kalinjul commented Feb 27, 2024

You shouldn't need to provide the context in your module constructor.
What works today is using one Module per platform together with expect class:

// commonMain
expect class SettingsPlatformModule

@Module(includes = [SettingsPlatformModule::class])
class SettingsModule

// androidMain
@Module
@ComponentScan("org.example")
actual class SettingsPlatformModule

IMHO it would be even better to have @componentscan support across targets.
E.g. in commonMain, i declare a Module with ComponentScan:

@Module
@ComponentScan("app.settings")
class SettingsModule

And in src/androidMain/kotlin, without having a module:

@Single
class Settings(val context: Context) {
...
}

Currently, i have to declare one module per platform

@Neyasbit
Copy link

Neyasbit commented Apr 2, 2024

In my opinion, it works even better now. If you have a module in common, for example SharedModule. Either in androidMain or in any other sources Set. You just need to add to a file inside androidMain

@Single
 fun provideAndroidPersistence(context: Context) : Persistence = AndroidPersistence(context)

Without creating a separate module for this. Component Scan will put this dependency in the SharedModule itself. Provided the packages match.

@matejsemancik
Copy link
Author

@Neyasbit This looks promising. I'm trying to implement this like you described, but does not work for me with latest Koin version. The Component scan does not seem to pick up dependencies from my source sets. Am I missing something?

@Neyasbit
Copy link

@matejsemancik ,
I set this up using

dependencies {
    with(libs.findLibrary("koin-kspCompiler").get()) {
        add("kspCommonMainMetadata", this)
        add("csp Android", this)
        add("kspIosX64", this)
        add("kspIosArm64", this)
        add("kspIosSimulatorArm64", this)
        add("kspIosSimulatorArm64Test", this)
    }
} 

If your module has only platform dependencies, then you should remove generation
kspCommonMainMetadata. Then you will have for android, for example, a module will be generated in build/genereared/ksp/android. The module itself lies in the command main.
Here is an article that should help you figure it out.
However, it says that kspCommonMainMetadata is enough for new dependencies and it should automatically generate platform dependencies. But it didn't work in my trash and I left them.

@matejsemancik
Copy link
Author

@Neyasbit indeed, looks like we had some ksp misconfiguration in our project that prevented annotations compiler from generating sources outside of commonMain. I managed to solve the problem using suggestions you provided, but there are still some quirks (see mentioned issue 👆)

@arnaudgiuliani
Copy link
Member

The annotated module in commonMain seems to be super interesting approach to specify expect/actual components. Thanks @Neyasbit for pointing it.

Example:

// in commonMain

@Module
@ComponentScan("com.jetbrains.kmpapp.platform")
class PlatformModule

// package com.jetbrains.kmpapp.platform 

expect class PlatformHelper {
    fun getName() : String
} 

// in androidMain
// package com.jetbrains.kmpapp.platform

@Single
actual class PlatformHelper(
    val context: Context
){
    actual fun getName(): String = "I'm Android - $context"
}

// in nativeMain
// package com.jetbrains.kmpapp.platform

@Single
actual class PlatformHelper(){
    actual fun getName(): String = "I'm Native"
}

Good thing it can work on top level function too. Your contract comes from expect class/functions.
Like said above, important thing is the KSP setup in KMP that is tedious:

dependencies {
    add("kspCommonMainMetadata", libs.koin.ksp.compiler)
    add("kspAndroid", libs.koin.ksp.compiler)
    add("kspIosX64", libs.koin.ksp.compiler)
    add("kspIosArm64", libs.koin.ksp.compiler)
    add("kspIosSimulatorArm64", libs.koin.ksp.compiler)
}

@arnaudgiuliani
Copy link
Member

@blakelee
Copy link

blakelee commented Jul 14, 2024

I tried testing this with KOIN_CONFIG_CHECK enabled and it doesn't work. Although creating a factory then binding the instance from a create method works just fine with only a little more code.

@arnaudgiuliani
Copy link
Member

Yes config check still need work. Looking at it. Closing this one 👍

Follow up on #140
for checks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

5 participants