Temporarily Unavailable

Mofe Ejegi
10 min readDec 6, 2023

In Kotlin Multiplatform (KMP), developers enjoy the freedom to gradually adopt shared features across various platforms, eliminating the need for a complete overhaul of the existing codebase. This flexibility and versatility are what make KMP an exceptional and thoughtfully designed framework.

In certain scenarios, especially when building these shared features in KMP projects, there arises a need for specific implementations for particular dependencies in our KMP targets. Consider, for example, a common scenario where a KMP or Compose Multiplatform application is targeting both Android and iOS platforms. Assuming you need a certain dependency for both platforms, you can consider a few options. On android you can add the android dependency as you would in a normal Android application, but on iOS you can:

  • Check if the iOS dependency is part of the prebuilt platform libraries in Kotlin Multiplatform projects from the Kotlin Native libraries (e.g: Core Foundation, Core Data or Cloud Kit, etc). No additional configuration is required in this case, and you can find more information on that here.
  • Integrate with the CocoaPods dependency manager then import and call the pod dependencies directly from your Kotlin code — this is recommended according to the Kotlin docs. While using this method, it’s pertinent to note that Kotlin only supports interoperability with Objective-C dependencies and Swift dependencies if their APIs are exported to Objective-C with the @objc attribute. Pure Swift dependencies or Swift pods are not yet supported. There is also the option to directly download the library and add it to your source set, but I personally don’t recommend that option. That said, using CocoaPods is a good option to go for and there are many articles and posts covering that. Using CocoaPods, along with the first method, enables you to leverage expect/actual implementations or Dependency Injection (DI) to utilise iOS dependencies.
  • Using Platform-Specific APIs through Shared Interfaces: Often relevant for existing third-party libraries, this approach involves utilising platform-specific APIs through a shared interface, implemented in both Kotlin and Swift. These are passed from the target platform’s modules into the shared module. The official Kotlin documentation advises checking for a Multiplatform library alternative before resorting to platform-specific APIs. This is sound advice, and I strongly recommend exploring the kmp-awesome repository for suitable Multiplatform libraries. However, given that Kotlin Multiplatform is still evolving, many libraries lack an official or recommended KMP version, or a reliable alternative with guaranteed long-term support. It’s these scenarios that prompt the need to discuss implementing shared platform components in a KMP project, enabling the provision and implementation of platform-specific APIs, a topic we’ll explore in this article.

“Before writing code that uses a platform-specific API, check whether you can use a Multiplatform library instead”.

The topics this article would cover include:

  • Creating an ApplicationComponent to use as a bridge for passing components between target(platform) modules and the shared module.
  • Using Koin as a DI library for Injecting these components.
  • Implementing a simple Network Listener on Android and iOS for a KMP project.

For a part 2 (coming soon), I’ll be introducing this:

If you’re already well-versed with the initial two topics, feel free to browse through them and proceed directly to the third topic, which delves into the final setup implementation. Below is a video demonstrating what the end result will look like.

Getting Started: Introducing the ApplicationComponent

The term ApplicationComponent refers to a class designed with properties that can be implemented or utilised by either platform. The naming and concept was inspired by the structure used in Chris Banes’ Tivi App repository on Github.
To kick things off, initiate a new project with the Kotlin Multiplatform wizard. For the sake of this example, we will set up a Compose Multiplatform project. Each platform will have its bespoke component: AndroidApplicationComponent for Android and IosApplicationComponent for iOS.
Upon creation, your project’s architecture will be organised as follows:

/
...
- composeApp
- src
- androidMain
- commonMain
- iosMain
- iosApp
...

If the folder structure or project layout is unfamiliar, I recommend visiting this detailed article to gain insight into the organisation of a KMP application and the purpose of each component. Additionally, if you’re new to the framework and interested in adopting it, ‘Get Started with Kotlin Multiplatform’ is an invaluable resource.

Let’s begin with the composeApp module. Within the androidMain and iosMain source sets, create a new directory or package named platform. In this location, define your ApplicationComponent classes. In the same file, implement a function named application that will accept the respective ApplicationComponent as its argument.

// AndroidApplicationComponent.kt (In androidMain)
package org.example.project.platform

class AndroidApplicationComponent

fun application(component: AndroidApplicationComponent) {
// TODO: Initialization logic with android components
}

...

// IosApplicationComponent.kt (In iosMain)
package org.example.project.platform

class IosApplicationComponent

fun application(component: IosApplicationComponent) {
// TODO: Initialization logic with ios components
}

We’ve laid the groundwork; now it’s time to set up the entry point in each source set to initialise this component. We’ll begin with the androidMain source set. Here, create an Application class named ExampleApp. Make sure to declare this class in your AndroidManifest.xml. Then, emulating the conventions found in the JetBrains (JB) documentation, we'll invoke the application function we previously defined. Here's how it's done:

package org.example.project

import android.app.Application
import org.example.project.platform.*

class ExampleApp : Application() {
override fun onCreate() {
super.onCreate()
application(AndroidApplicationComponent())
}
}

The entry point to the iOS application can be accessed through the iOSApp directory. Simply search for the IOSApp.swift file and make the changes like this:

import SwiftUI
import composeApp

@main
struct iOSApp: App {
init() {
application(IosApplicationComponent())
}

var body: some Scene { ... }
}

And there you have it — a platform bridge that can transfer components from various platforms into your shared module. Typically, these components could be shared interfaces with platform-specific implementations or particular platform-specific dependencies, like UserDefaults on iOS or the Android Application class. It’s worth noting that this architecture will slightly change once Dependency Injection (DI) is incorporated.

To cap it off, here’s a snapshot of how the project structure appears after implementing these modifications:”

/
...
- composeApp
- src
- androidMain
- kotlin
- org.example.project
- platform
AndroidApplicationComponent.kt
ExampleApp.kt
MainActivity.kt
- commonMain
- kotlin
- org.example.project
App.kt
- iosMain
- kotlin
- org.example.project
- platform
IosApplicationComponent.kt
MainViewController.kt

- iosApp
- iosApp
IOSApp.swift
...

NOTE: Given that our Compose UI is shared between both platforms, the androidApp module isn't included, allowing us to establish all Android-specific dependencies directly within the androidMain source set via Dependency Injection (DI). As a result, for the scope of this article, we won't be utilising the AndroidApplicationComponent. However, this component may still be relevant in different contexts, such as when you're incrementally integrating KMP in projects with distinct UIs for each platform.

Dependency Injection: Using Koin to aid Component Injection

As we scale up and the component count increases, streamlining dependency provision becomes essential, and this is where Koin’s Dependency Injection framework enters the picture. For those not yet acquainted with Koin, or if you’re transitioning from another DI framework like Dagger or Hilt, I strongly suggest perusing the Koin official documentation for comprehensive tutorials and guides.

With Koin DI, we can centralise the initialisation of our components within the platformModule. The setup is straightforward—simply adhere to the steps provided by the JetBrains (JB) team. We'll delve deeper into this process when we're ready to integrate our first dependency.

To kickstart Koin in our project, begin in the commonMain source set by creating a new package named di. Then proceed with the following setup:

// CommonModule.kt
package org.example.project.di

import org.koin.dsl.module

val commonModule = module { }

// PlatformModule.kt
package org.example.project.di

import org.koin.core.module.Module

expect val platformModule: Module

// PlatformModule.android.kt and PlatformModule.ios.kt
actual val platformModule = module { }
// KoinInit.kt (We'll use this to initialize Koin)
package org.example.project.di

import org.koin.core.context.startKoin
import org.koin.core.module.Module
import org.koin.dsl.KoinAppDeclaration

fun initKoin(
additionalModules: List<Module> = listOf(),
appDeclaration: KoinAppDeclaration = {},
) {
startKoin {
appDeclaration()
modules(
additionalModules +
listOf(
commonModule,
platformModule,
)
)
}
}

With the foundation now set, we’re ready to start using Koin across different platforms.

First and foremost, it’s crucial to ensure that the ApplicationComponents for both platforms are correctly injected into their respective dependency graphs. This approach guarantees that we can consistently access any additional components linked to them. For the sake of organisation, I prefer to separate the initialisation process: thus, I’ll create KoinInit.android.kt and KoinInit.ios.kt files. This step, while not mandatory—especially in the androidMain source set—is more about personal coding style and preference.

Next, let’s return to our ExampleApp.kt file in the Android project and initiate Koin. The setup should look something like this:

// KoinInit.android.kt
package org.example.project.di

import org.example.project.platform.AndroidApplicationComponent
import org.koin.dsl.KoinAppDeclaration
import org.koin.dsl.module

fun initKoinAndroid(
appComponent: AndroidApplicationComponent,
appDeclaration: KoinAppDeclaration = {},
) {
initKoin(
listOf(module { single { appComponent } }),
appDeclaration,
)
}
// ExampleApp.kt
// ...
class ExampleApp : Application() {

override fun onCreate() {
super.onCreate()

initKoinAndroid(
appComponent = AndroidApplicationComponent()
) {
androidContext(this@ExampleApp)
}
}
}
// ...

And voilà, that’s all there is to it! With these steps, the AndroidApplicationComponent is now integrated into the DI graph. The process for iOS follows a similar pattern. On the iOS side, I'll employ another method to supply additional modules from the iosApp module through the IosApplicationComponent. This method, named initKoinIos, will essentially add the IosApplicationComponent as an extra module, but exclusively for the iOS shared source set.

// KoinInit.ios.kt
package org.example.project.di

import org.example.project.platform.IosApplicationComponent
import org.koin.dsl.module

fun initKoinIos(appComponent: IosApplicationComponent) {
initKoin(
listOf(module { single { appComponent } })
)
}

And we utilise it in our Swift code like this

// iOSApp.swift
import SwiftUI
import composeApp

@main
struct iOSApp: App {
init() {
KoinInit_iosKt.doInitKoinIos(
appComponent: IosApplicationComponent()
)
}

var body: some Scene {...}
}

I’ll proceed to explain how to utilise these components, particularly the IosApplicationComponent it in the next and final section of this article.

Implementing a Simple Network Listener on Android and iOS

With all the foundational elements in place, we’re now poised to implement platform-specific dependencies from their respective modules. A practical example to illustrate this is the creation of a simple network listener.

We’ve all encountered scenarios where we need to inform users about their internet connectivity, be it an offline status or a disabled connection. Android developers typically employ the ConnectivityManager for this purpose, while iOS developers might use NWPathMonitor. Accessing ConnectivityManager in the Android module is straightforward with the androidContext() provided by Koin. However, as of December 2023, I observed that NWPathMonitor isn’t included in the prebuilt libraries for Kotlin Native. Consequently, I opted to integrate it via Swift code, using an interface. This approach involves leveraging the IosApplicationComponent to bridge it from the iosApp module to our common composeApp shared module.

Let’s dive in by first conceptualizing our interface. At the core, we need our network listener to perform two fundamental actions: initiating the listening process and ceasing it when necessary. To this end, we’ll define two functions: registerListener to start the listening, and unregisterListener to stop it. We’ll encapsulate these functionalities within an interface named NetworkHelper. This interface is also designed to notify us of network status changes, providing callbacks to indicate when a network connection is established or lost.

// NetworkHelper.kt (In commonMain)
package org.example.project.platform

interface NetworkHelper {
fun registerListener(onNetworkAvailable: () -> Unit, onNetworkLost: () -> Unit)
fun unregisterListener()
}

Implementing this interface on Android is pretty simple. Go to the same location in your androidMain module and create a new class called AndroidNetworkHelper which implements the NetworkHelper interface like this — a pretty standard implementation.

// AndroidNetworkHelper.kt
package org.example.project.platform

import android.content.Context
import android.content.Context.CONNECTIVITY_SERVICE
import android.net.ConnectivityManager
import android.net.Network

class AndroidNetworkHelper(context: Context) : NetworkHelper {
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager

override fun registerListener(onNetworkAvailable: () -> Unit, onNetworkLost: () -> Unit) {
networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
onNetworkAvailable()
}
override fun onUnavailable() {
onNetworkLost()
}
override fun onLost(network: Network) {
onNetworkLost()
}
}
networkCallback?.let { connectivityManager.registerDefaultNetworkCallback(it) }
}

override fun unregisterListener() {
networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) }
}
}

We still want to make this class available in the DI graph.

// PlatformModule.android.kt
actual val platformModule = module {
single<NetworkHelper> { AndroidNetworkHelper(androidContext()) }
}

Now for the fun part, in the iosApp module, go into the iosApp folder and create a new Swift class file. You should ideally be doing this using Fleet or XCode. This would be called IosNetworkHelper.

// IosNetworkHelper.swift
import Foundation
import Network
import composeApp

class IosNetworkHelper : NetworkHelper {
private let monitor: NWPathMonitor = NWPathMonitor()

func registerListener(onNetworkAvailable: @escaping () -> Void, onNetworkLost: @escaping () -> Void) {
monitor.pathUpdateHandler = { path in
if path.status == .satisfied {
onNetworkAvailable()
} else {
onNetworkLost()
}
}
monitor.start(queue: DispatchQueue.global(qos: .background))
}

func unregisterListener() {
monitor.cancel()
}
}

Notice that the Unit return type is now represented as a Void type. That’s as a result of the Kotlin Native Obj-C interoperability mappings which can be found here.

Now that we have an iOS implementation leveraging the Network package native to the iOS ecosystem, our next step is to integrate it into the IosApplicationComponent. To do this effectively, we first define it within the component, like so:

// IosApplicationComponent.kt (In iosMain)
package org.example.project.platform

class IosApplicationComponent(val networkHelper: NetworkHelper)

// We've removed the old 'application()' function
// iOSApp.swift
import SwiftUI
import composeApp

@main
struct iOSApp: App {
init() {
KoinInit_iosKt.doInitKoinIos(
appComponent: IosApplicationComponent(
networkHelper: IosNetworkHelper() // Note this
)
)
}

var body: some Scene {...}
}

Lastly, to piece the puzzle all together, we also make it available to Koin.

// PlatformModule.ios.kt
actual val platformModule = module {
single<NetworkHelper> { get<IosApplicationComponent>().networkHelper }
}

We now have all we need to complete our NetworkListener implementation. Return to the commonMain source set and create a class called NetworkListener.

// NetworkListener.kt
class NetworkListener(private val helper: NetworkHelper) {
val networkStatus: Flow<NetworkStatus> = callbackFlow {
helper.registerListener(
onNetworkAvailable = {
trySend(NetworkStatus.Connected)
},
onNetworkLost = {
trySend(NetworkStatus.Disconnected)
}
)

awaitClose {
helper.unregisterListener()
}
}.distinctUntilChanged().flowOn(Dispatchers.IO)
}

sealed class NetworkStatus {
data object Connected : NetworkStatus()
data object Disconnected : NetworkStatus()
}

In this class, we utilise Kotlin’s flows to tap into the capabilities of the NetworkHelper methods we established previously. Additionally, this NetworkListener class can be seamlessly injected into the dependency injection (DI) graph within the commonMain source set, further streamlining its integration and use.

// CommonModule.kt (In commonMain source set)
package org.example.project.di

import org.example.project.platform.NetworkListener
import org.koin.dsl.module

val commonModule = module {
single { NetworkListener(get()) }
}

To wrap it all up, go to the App.kt composable and test the network connectivity like this:

// App.kt
@Composable
fun App(
networkListener: NetworkListener = koinInject(),
) {
KoinContext {
MaterialTheme {
val networkStatus by networkListener.networkStatus.collectAsState(NetworkStatus.Connected)
Text(text = networkStatus.toString())
}
}
}

This article has provided a concise, step-by-step guide to calling platform-specific dependencies in Kotlin Multiplatform. While I’ve applied some creative approaches in this article, the majority of the patterns discussed are derived from a variety of sources including blogs, official Kotlin/JetBrains articles, documentation, and KMP samples.

Stay tuned for part 2 of this series, where we’ll delve into more intricate use cases and complex libraries. Thank you for taking the time to read through this guide!

References:

  1. The basics of Kotlin Multiplatform project structure
  2. Kotlin Multiplatform Samples
  3. Using platform-specific APIs
  4. Expected and actual declarations
  5. Interoperability with Swift/Objective-C

--

--