Kotlin Multiplatform and Swift - Overcoming Interoperability Challenges for Multiplatform Development
Emanuele
Posted on July 16, 2023
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
}
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")
}
}
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!
}
}
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)
}
}
Koin will be setup using:
-
appModules
: modules created in the app layer, e.g. for viewModels -
sharedModules
: modules created in thecommonMain
source set to be shared between platforms -
weatherModule
: module where we declare dependencies forcom.weather.sdk
We will concentrate on weatherModule
, let's see its code:
val weatherModule = module {
singleOf(::AndroidWeatherDataSource) bind WeatherDataSource::class
}
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!
}
}
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()
}
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)
}
}
On iOS, you do the same:
@main
struct MyApplication: App {
init() {
let iosWeatherDataSource = //create iOSWeatherDataSource
//DependenciesProviderHelper should be a singleton
DependenciesProviderHelper().initKoin(
weatherDataSource: iosWeatherDataSource
)
}
}
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.
Posted on July 16, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.