Kotlin MPP expect / actual with different signatures

Viewed 2489

I have a manager class that has an Android and iOS impl (from a 3rd party lib). "ex. MyManagerImpl()". To construct the 3rd party manager, iOS does not require a Context but Android does. I created a common class "MyManager" which pulls out all the common methods needed to be called within commonMain.

//commonMain
expect class MyManager {
  fun method1()
  companion object Factory {
    makeManager(): MyManager
  }
}

val manager = MyManager.Factory.makeManager() // ex intended usage

//androidMain
MyManagerImpl(context: Context) {
  fun method1()
}

actual class MyManager private constructor(manager: MyManagerImpl) {
  ..
  actual companion object Factory {
    override fun makeManager(): MyManager {
      return MyManager(MyManagerImpl(?how to get context?))
    }
  }
}

//iosMain
MyManagerImpl() {
  fun method1()
}

actual class MyManager private constructor(manager: MyManagerImpl) {
  ..
  actual companion object Factory {
    override fun makeManager(): MyManager {
      return MyManager(MyManagerImpl())
    }
  }
}

What is the cleanest way to merge the two implementations? Is it possible to do so even tho they have different constructor dependencies? We would like to be able to construct the classes lazily within commonMain. Is this possible?

5 Answers

Dependency Injection of Context

I had the same issue with the SQLDelight SqlDriver, which requires a context on Android, but not on iOS. With Kodein-DI or Koin, this can be done without any messy static variables by using injection for the context.

The basic concept is that expect/actual is used to create a platform-specific factory class (ManagerFactory).

On Android, the actual implementation of ManagerFactory takes the context as a parameter, which can be obtained from the DI context (for Kodein-DI on Android, see the androidXModule code and docs).

Once the factory class has been defined in both the android and iOS DI modules, it can then be injected/retrieved in the common module, and the retrieved MyManager instance bound into the DI, and used wherever it is needed.

It would look something like this using Kodein-DI:

commonMain

//commonMain
expect class ManagerFactory {
    fun createManager(): MyManager
}

val sharedModule = DI.Module("common") {
    bind<MyManager>() with singleton { instance<ManagerFactory>().createManager() }

    // now you can inject MyManager wherever...
}

androidMain

//androidMain
actual class ManagerFactory(private val context: Context) {
    actual fun createManager(): MyManager = MyAndroidManagerImpl(context)
}

val androidModule = DI.Module("android") {
    importAll(sharedModule)

    // instance<Context> via androidXModule, see `MainApplication` below
    bind<ManagerFactory>() with singleton { ManagerFactory(instance<Context>()) }
}

class MainApplication : Application(), DIAware {
    override val di by DI.lazy {
        // androidXModule (part of Kodein's android support library) gives us access to the context, as well as a lot of other Android services
        // see https://github.com/Kodein-Framework/Kodein-DI/blob/7.1/framework/android/kodein-di-framework-android-core/src/main/java/org/kodein/di/android/module.kt
        import(androidXModule(this@MainApplication))
        importAll(androidModule)
    }
}

iosMain

//iosMain
actual class ManagerFactory {
    actual fun createManager(): MyManager = MyNativeManagerImpl()
}

val iosModule = DI.Module("ios") {
    importAll(sharedModule)
    bind<ManagerFactory>() with singleton { ManagerFactory() }
}

There isn't a super clean way to do this, as a general rule. There's no way to just globally grab a Context from Android. Although not pretty, I'd do something like this:

//androidMain
class MyManagerImpl(context: Context) {
    fun method1(){}
}

actual class MyManager private constructor(manager: MyManagerImpl) {

    actual companion object Factory {
        lateinit var factoryContxet :Context
        override fun makeManager(): MyManager {
            return MyManager(MyManagerImpl(factoryContxet))
        }
    }
}

class SampleApplication : Application{
    override fun onCreate() {
        super.onCreate()
        MyManager.Factory.factoryContxet = this
    }
}

If you want to be able to call this from any code, init the Context on app start. Holding that in a static reference won't show up on everybody's best practice list, but it's not a technical issue per see. Alternatively, you could do something like that with an Activity, but that has it's own set of issues.

We're doing it like this (example for bundleId - maybe it will help you):

expect fun bundleId(context: Any?): String?

androidMain:

actual fun bundleId(context: Any?): String? {
    (context as? Context)?.let {
        return AndroidIdentifier(it).getBundleId()
    }
    throw Exception("")
}

iosMain:

actual fun bundleId(context: Any?): String? =
        NSBundle.mainBundle.bundleIdentifier

Here's what we did (without dependency injection as we have not yet set it up) for our database factory to solve this problem:

We created a class called AppContext in a bottom level (has no other dependencies) module. In the commonMain we have the expected class:

expect class AppContext private constructor()

Then in the androidMain we have this:

actual class AppContext private actual constructor() {
    lateinit var context: Context
        private set

    constructor(context: Context) : this() {
        this.context = context
    }
}

The iosMain implementation will be the same as the commonMain one but with actual (you can do something similar to the actual Android implementation if you need to access some other properties):

actual class AppContext private actual constructor()

The key thing here is that it has the same expected and actual constructors so the compiler thinks of it as having the same signature and being the same class, but we actually have different implementations for it exposed for Android and iOS.

We create this AppContext on app start and pass it to the bootstrapper where we pass it to the DbFactory so that we can create the respective databases.

class DbFactory(appContext: AppContext, userId: String) : DataBaseFactory {
    private val sqlDriver =
        appContext.createSqlDriver(dbFileName = "$userId$DB_FILE_NAME_SUFFIX")
}

And the expected and actual implementations of createSqlDriver:

commonMain:

internal expect fun AppContext.createSqlDriver(dbFileName: String): SqlDriver

androidMain:

internal actual fun AppContext.createSqlDriver(dbFileName: String): SqlDriver =
    AndroidSqliteDriver(
        schema = KmmDb.Schema,
        context = context,
        name = dbFileName
    )

iosMain:

internal actual fun AppContext.createSqlDriver(dbFileName: String): SqlDriver =
    NativeSqliteDriver(
        schema = KmmDb.Schema,
        name = dbFileName
    )

I came up with another "hacky" solution, which doesn't require static references:

data class PlatformDependencies(
  val androidContext: Any?,
  val someIosOnlyDependency: Any?
)
// in commonMain
expect class ManagerFactory(platformDeps: PlatformDependencies) {
  fun create(): Manager
}

// in androidMain
actual class ManagerFactory actual constructor(
  private val platformDeps: PlatformDependencies
) {
  override fun create(): Manager {
    val context = platformDeps.androidContext as? Context 
      ?: error("missing context in platform deps")     
    return AndroidManager(context)
  }
}

// in iosMain: similar to androidMain, but using "someIosOnlyDependency"

Then you'd have to bind an instance of PlatformDependencies with specific values in your platform root gradle projects.

Having an object with just platform specific deps allows you to use it to construct any platform-dependent class down the DI module hierarchy.

It's still far from elegant solution, relies on discipline and somewhat leaks platforms' details into a common code, but it works.

Related