Kotlin Multiplatform and Swift - Overcoming Interoperability Challenges for Multiplatform Development

ema987

Emanuele

Posted on July 16, 2023

Kotlin Multiplatform and Swift - Overcoming Interoperability Challenges for Multiplatform Development

Kotlin Multiplatform Mobile (KMM) is a popular framework for developing multi-platform mobile applications with shared code between Android and iOS.
However, one limitation of KMM is that it doesn't allow direct use of Swift libraries: this is because Kotlin doesn't have direct interoperability with the Swift language; instead, it relies on interoperability with Objective-C.

As a result, KMM doesn't have built-in support for Swift-only libraries that don't have an Objective-C API. This means that developers can't use Swift libraries directly in a KMM project without first creating a bridging library that provides a Kotlin interface to the Swift code.

Creating a bridging library requires writing a separate library for each Swift library that needs to be used in the project: this can be time-consuming and requires knowledge of both Kotlin and Swift.

Using a Swift library with Dependency Injection and Koin

Imagine the classical situation where you want to use a third party service and they provide you a native Android SDK and a native iOS SDK (written in Swift).
If you are going to develop two native apps, you face no issues, each platform implements and uses their respective SDK.
If you are going to develop a KMM app, you end up having some issues due to the fact the iOS SDK is written in Swift.

Let's see how we can use Koin to achieve Dependency Injection (DI) and incorporate a third-party iOS Swift SDK in a KMM project.
Koin supports KMM development, making it the ideal choice for KMM projects.

Shared code

If you are going to use a library which already supports KMM, you are going to declare it as a dependency in the commonMain source set of the shared module build.gradle.kts file.
This is not our case, we have a library for Android and a separate one for iOS.

For the sake of example, let's imagine we have com.weather.sdk:1.0.0 for Android and WeatherSDK for iOS: they provide weather information given a city.

So what do we do?

Create a wrapper

To avoid scattering the use of the weather SDK throughout the project and allow for future flexibility to swap it out with another SDK, we create a wrapper interface for it. This approach provides a layer of abstraction that enables us to decouple the SDK implementation from the rest of the project, making it easier to maintain and modify in the future.

interface WeatherDataSource {
    suspend fun getWeather(cityName: String): WeatherInfo
}
Enter fullscreen mode Exit fullscreen mode

Do not take care of the function signature, it's just an example. Keep the focus on the fact we are creating an interface to decouple from the Weather SDK.

Now, we need to implement this interface both for Android and for iOS. The implementantions will use the respective Weather SDK to fetch data.
Let's start from Android first, which is easier.

Android setup

First, set the Android library as dependency in the androidMain source set.

val androidMain by getting {
    dependencies {
        implementation("com.weather.sdk:1.0.0")
    }
}
Enter fullscreen mode Exit fullscreen mode

This makes us able to use com.weather.sdk into the androidMain source set.

Next step is to create the implementation for WeatherDataSource, so we create AndroidWeatherDataSource, where we use the weatherSdk class of Weather SDK to fetch weather information.

This is the link between our code and the third party SDK.

class AndroidWeatherDataSource(
    private val weatherSdk: WeatherSDK
): WeatherDataSource {
    suspend fun getWeather(cityName: String): WeatherInfo {
        //use weatherSdk to fetch weather information!
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, still in the androidMain source set, we create this function to init Koin:

/**
 * [context] is the Android app context
 * [appModules] is a list of modules defined in the app layer (e.g. viewModels)
 */
fun initKoin(context: Context, appModules: List<Module>) {
    startKoin {
        androidLogger()
        androidContext(context)
        modules(appModules + sharedModules + weatherModule)
    }
}
Enter fullscreen mode Exit fullscreen mode

Koin will be setup using:

  • appModules: modules created in the app layer, e.g. for viewModels
  • sharedModules: modules created in the commonMain source set to be shared between platforms
  • weatherModule: module where we declare dependencies for com.weather.sdk

We will concentrate on weatherModule, let's see its code:

val weatherModule = module {
    singleOf(::AndroidWeatherDataSource) bind WeatherDataSource::class
}
Enter fullscreen mode Exit fullscreen mode

We declare AndroidWeatherDataSource as single instance for whenever we require WeatherDataSource.

Now, let's move to iOS!

iOS setup

iOS setup it's a little bit more cumbersome than the Android one, but let's go step by step to see what's needed.

Since the iOS library is written in Swift and doesn't have Objective-C bindings, we can't declare it in the iOSMain source set, we can't cinterop it neither.
The only way to use it is from the iOS project that AndroidStudio/KMMPlugin created for us.

First thing, as we did for Android, it's to create the implementation for WeatherDataSource, so we create IOSWeatherDataSource:

import WeatherSDK
import shared

class IOSWeatherDataSource: WeatherDataSource {
    final let weatherSDK: WeatherSDK

    init(weatherSDK: WeatherSDK) {
        self.weatherSDK = weatherSDK
    }

    func getWeather(cityName: String) async throws -> WeatherInfo {
        //use weatherSdk to fetch weather information!
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's go back to our shared module into iOSMain source set: here, as well as for Android, we need to create a function to init Koin.
Right now, Koin API can't be used in the iOS project, so we need both to create a function to init Koin and to expose all Koin dependencies to the iOS project.

To do so we create a class DependenciesProviderHelper:

class DependenciesProviderHelper : KoinComponent {

    /**
     * [weatherDataSource] is the iOS implementation to fetch weather information
     */
    initKoin(
        weatherDataSource: WeatherDataSource
    ) {
        val iosDependenciesModule = module {
            single {
                weatherDataSource
            } bind WeatherDataSource::class
        }
        startKoin {
            modules(iosDependenciesModule + sharedModules)
        }
    }

    //usecases
    val getWeatherInfoUseCase: GetWeatherInfoUseCase by inject()

}
Enter fullscreen mode Exit fullscreen mode

Since it's currently not possible to use Koin APIs from iOS, the initKoin() function can't have a list of Koin Module like the Android one. However, we can still provide the real implementations of our dependencies and create a Koin module in the shared code. Then, we can use startKoin() with both the iOS dependencies and the shared modules.

Imagine then that this DataSource will be used in a Repository that will be used in a UseCase, here in DependenciesProviderHelper we also expose getWeatherInfoUseCase to be able to use it from the iOS project.

Start Koin!

Both the projects are now setup correctly, Dependency Injection is in place, real implementations have been created, we just miss one last thing: to finally start Koin!

On Android, you start Koin in your application class:

class MyApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        initKoin(this@MyApplication, appModules)
    }

}
Enter fullscreen mode Exit fullscreen mode

On iOS, you do the same:

@main
struct MyApplication: App {

    init() {
        let iosWeatherDataSource = //create iOSWeatherDataSource
        //DependenciesProviderHelper should be a singleton
        DependenciesProviderHelper().initKoin(
            weatherDataSource: iosWeatherDataSource
        )
    }

}
Enter fullscreen mode Exit fullscreen mode

Instances injected by Koin

What instances does Koin provide in our code and how does it provide them, based on the code above?
AndroidWeatherDataSource is defined as a single instance for WeatherDataSource on Android, which means that Koin will handle its instantiation and provide it whenever it is needed in our code. Because it is defined as single, the instance will remain the same throughout the lifecycle of our app.
On iOS, the approach is similar, but Koin will not instantiate IOSWeatherDataSource on its own. Instead, we will need to instantiate it ourselves and provide the instance to startKoin() before Koin can manage it and provide it whenever it is needed.

Considerations

We have come to the end of how to use a Swift third-party library in a Kotlin Multiplatform project. While it's still necessary to write two different native implementations (one for iOS and one for Android), these implementations are restricted in the DataSource level, while UseCases and Repositories can still be shared and written into the commonMain source set.

This means that the UseCases can use shared Repositories, and tests can be written to cover the UseCases and Repositories code just once. If there's a bug in the DataSources, it will be spotted by these tests.

The shared code also provides the advantage of being able to implement functionality once, such as a cache in the Repository layer, instead of writing it twice for both platforms.

While there are limitations and challenges to using Swift third-party libraries in a Kotlin Multiplatform project, the benefits can make it a worthwhile investment for developers looking to build multiplatform apps.

💖 💪 🙅 🚩
ema987
Emanuele

Posted on July 16, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related