KMM evaluation: part 1. How to deal with “common” code and platform-level dependencies?

Olga Deryabina
11 min readDec 1, 2023

--

For those who are not familiar with Kotlin Multiplatform at all:

Kotlin Multiplatform (KMP) is a technology from JetBrains that allows developers to write code that can be shared across multiple platforms. The primary goal of Kotlin Multiplatform is to enable the development of applications that can run on various platforms, such as Android, iOS, web, and desktop, using a single codebase.

With Kotlin Multiplatform, you can write shared business logic, data models, and other non-platform-specific code in Kotlin and then use it across different platforms. This approach can help reduce code duplication, simplify maintenance, and improve consistency between different versions of an application running on different platforms.

Key features of Kotlin Multiplatform include:

Shared Code: You can write common code in Kotlin that is shared across different platforms. This shared code typically includes business logic, data models, and utility functions.

Platform-Specific Code: While sharing a significant portion of code, Kotlin Multiplatform allows you to write platform-specific code when necessary. This code is written in the native language of the platform (e.g., Swift for iOS, Java/Kotlin for Android) and can be seamlessly integrated with the shared Kotlin code.

As per ChatGpt

Kotlin Multiplatform appears to hold great promise, having undergone significant advancements since my last evaluation in early 2022. However, a critical consideration is its suitability for projects requiring more intricate functionality beyond basic API data transmission and reception in the foreground. Obviously, JetBrain claims that “KMP developers have access to all features of the native platforms’’, but how mature and production-ready are the actual APIs? Also, how easy would it be designing required functional layers? Will we be able to abstract common functionality and wrap up platform-specific dependencies without breaking principles of clear code? Do Android design patterns suit well here, or we will need to modify them significantly? What about Unit and UI tests — which options do we currently have and do they imply any particular steps in the code design/structure (for example, creating interfaces for all the classes we want to mock)?

To find the answers to these questions, I’ve developed and tested a prototype application intended to read and store the phone’s location and health data from the device’s central repository (HealthKit on iOS and HealthConnect on Android) on a scheduled basis while operating in the background. This information was supposed to be presented on various of composable views, including a pager view, calendar and a list.

Throughout my project, I faced various challenges, including issues related to project configuration, the integration of specific features (particularly, the map view for iOS), and constructing iOS classes with Kotlin/Native. Determining the most efficient approach for unit testing required careful consideration, and I found the experimentation with background data collection using callbacks versus scheduled coroutine jobs particularly interesting (these experiments I believe merits a dedicated article). Today, my focus is on another critical aspect of Kotlin Multiplatform (KMP) development: the design of common (shared across all targets) versus platform-specific components, the distribution of dependencies, and ensuring seamless collaboration among all elements.

What is “expected/actual” declaration and how it works

As previously mentioned, the Kotlin Multiplatform (KMP) project, located within the “shared” directory, comprises multiple modules. Within the “commonMain” module, there is platform-agnostic Kotlin/Native code that can be shared across all targets (not only Android and iOS, but also web, desktop apps etc). The “androidMain” module contains Kotlin/JVM files and classes and gives us an access to platform-specific APIs, while iOS-specific modules let us access iOS-specific APIs via Kotlin/Native. Additionally, third-party libraries can be integrated using standard Gradle tools for Android and CocoaPods for iOS.

Reviewing the project structure screenshot, one can observe platform-specific directories (“androidMain” and multiple iOS-related) in the "shared" folder, along with directories named "androidApp" and "iosApp". "androidApp" and "iosApp" house entry points (Activities, View controllers, application classes, basic configuration) for Android and iOS apps. Importantly, these directories are not part of KMP but consume KMP as a module. This allows for some dependencies to be passed to the KMP module from outside, rather than being initialized inside it.

Let’s take a look at how the modules interact:

  • “androidApp” has access to "commonMain" and “androidMain” code.
  • “iosApp” has access to "commonMain" and one of the iOS targets (“iosMain”, “iosArm66”, “iosX64” etc - the exact hierarchy depends on the configuration).
  • “androidMain” and all the iOS targets can access code from "commonMain".
  • "commonMain” doesn’t have a direct access to the platform-specific modules.

It means that by default, calling platform-specific code from "commonMain" is not possible. This is where the "expected"/"actual" declaration becomes crucial, serving as syntax to build a bridge between common and platform-specific code.

While the detailed documentation is readily available online (e.g., https://kotlinlang.org/docs/multiplatform-expect-actual.html), let's examine an example from my project. In the app, I share composable UI components across Android and iOS. These UI elements collect flows of health and location records to display the corresponding data. For Android, it's recommended to make flow consumable by composables by converting it to “state” using "collectAsStateWithLifecycle", a lifecycle-aware extension. For iOS, where the concept of lifecycle is very different from what we do have in the Android ecosystem, I opted to adhere to standard practice and use "collectAsState" (probably, “collectAsStateWithLifecycle” would still work, but it’s outside of the scope of this article). Consequently, in the project, expected and actual declarations were employed.

Expected:
@Composable
expect fun<T> Flow<List<T>>.collectStateFlowForPlatform(): State<List<T>>

Android:
@Composable
actual fun <T> Flow<List<T>>.collectStateFlowForPlatform(): State<List<T>> {
return this.collectAsStateWithLifecycle(listOf())
}

iOS targets:
@Composable
actual fun <T> Flow<List<T>>.collectStateFlowForPlatform(): State<List<T>> {
return this.collectAsState(listOf())
}


Calling from the commonMain code:
@Composable
fun DeleteRecordsByDateScreen(
viewModel: RecordsListViewModel) {


val currentMonth = viewModel.currentMonth
val displayDates = viewModel.datesToDisplay.collectStateFlowForPlatform()
….
}

Here we reviewed a simple example of creating an expected/actual extension function that doesn’t necessitate integration into the dependencies hierarchy, minimizing its impact on the design process. However, consider the scenario where we require a stateful class with several platform-specific dependencies, and the count of these dependencies varies considerably from one platform to another.

How to design functional layers with an “expected/actual” approach

In my experience, achieving this objective still remains feasible by adhering to standard Android best practices and design patterns. Examining the diagram provided in the official Android documentation (https://developer.android.com/topic/architecture), we observe the “repositories” sublayer, designed to be free of platform dependencies, and the “data source” sublayer, where the introduction of such dependencies (e.g., database instances, location data providers, etc.) can start.

This approach appears readily adaptable in Kotlin Multiplatform (KMP), at least in numerous cases. In practical terms, many applications may not require stateful classes with platform-specific dependencies for layers above data sources. Consequently, leveraging platform-agnostic Kotlin/Native for the repository sub-layer and above will be sufficient in those cases (with some expected/actual static utility or extensions functions). To illustrate this concept further, consider the expected/actual declaration example of a class with some platform-specific dependencies responsible for collecting location data.

Expected:
expect class LocationServiceNativeClient{

var callbackProvider: LocationCallbackProvider?
fun updateCallbackProvider(newCallbackProvider: LocationCallbackProvider)
fun subscribeToServices()
fun unsubscribeFromServices()
}

Android:
actual class LocationServiceNativeClient (context: Context) {
//Kotlin/JVM code for Android
fun subscribeToServices() { // some code }
fun unsubscribeFromServices() { // some code }
}

iOS:
actual class LocationServiceNativeClient(
private val nativeClient: CLLocationManager?
): NSObject(), CLLocationManagerDelegateProtocol {
//Kotlin/Native code for iOS
fun subscribeToServices() { // some code }
fun unsubscribeFromServices() { // some code }
}

LocationServiceNativeClient instance is being used in the LocationDataSource class, which is a Kotlin/Native platform-agnostic abstraction of the functionality to subscribe or unsubscribe from the location updates (for example, if the permissions get revoked). This is the code:


class LocationDataSource(
override var locationServiceNativeClient: LocationServiceNativeClient
) : ILocationDataSource {


override fun subscribe() {
locationServiceNativeClient.subscribeToServices()
}
override fun unsubscribe() {
locationServiceNativeClient.unsubscribeFromServices()
}
}

The decision on the number of abstraction layers to be written in “commonMain”, placed on top of the platform-specific functionality, can vary. It depends on the complexity of the functionality and the intended approach for unit testing. Keep in mind that testing actual/expected functionality is possible only in the platform-specific modules. Nevertheless, this approach generally allows for the creation of code that is both testable and scalable.

It’s essential to note that there is no strict requirement to confine declarations solely to “actual” in platform-specific modules. In fact, having regular classes and functions in these modules is perfectly valid. However, bear in mind that they can be seamlessly integrated into a common flow only if invoked from “actual” code.

How to construct “expect/actual” classes?

In the project’s initial stages, I was unsure about the optimal approach to address platform differences. Consider a scenario where we need to create an expected class with the Android actual implementation requiring three Android-specific dependencies and an iOS implementation that needs only one dependency (and for some reasons I am planning to touch later, I want to initialize these classes outside of the KMP module). Does the solution involve structuring the class like this?

expected class MyClass (
val androidDepend1: Any?,
val androidDepend2: Any?,
val androidDepend3: Any?,
val iosDepend1: Any?
)

This approach appears highly inefficient. Eventually, I opted for declaring mostly constructor-less “expected” classes and providing parameters of the desired type for the “actual” classes (let’s look at the same example I posted above).

Expected:
expect class LocationServiceNativeClient{}

Android:
actual class LocationServiceNativeClient (context: Context) {}

iOS:
actual class LocationServiceNativeClient(
private val nativeClient: CLLocationManager?
): NSObject(), CLLocationManagerDelegateProtocol

In this example, parameterized constructors are exclusively present in the actual classes, offering clarity on the expected number of parameters and their respective data types.

Initializing actual classes

With the proposed way to construct “actual” classes with platform-specific data types, it implies that these classes can be instantiated in an environment where these dependencies and data types are available — not in “commonMain.” But where exactly — there are no strict rules, the approach varies depending on the requirements, app’s structure and many other parameters. In my case, I opted to align with Android best practices and devised expected/actual classes conceptually similar to the Dagger module, where all dependencies could be initialized at the same time and later to be accessed by consumers:

Expect:

expect class DependencyManager {

val appDataBase: AppDatabase
val healthRecordsDatabase: HealthRecordsDatabaseProvider
val locationRecordsDatabase: LocationRecordsDatabaseProvider
val dataTypesProvider: DataTypesProvider
val healthClientProvider: HealthClientProvider
val locationCallbackManager: LocationCallbackManager
val locationDataSource: ILocationDataSource
val healthRepo: HealthRecordsRepository
val locationDataRepository: LocationDataRepository
val locationClient: LocationServiceNativeClient
}

Android:

actual class DependencyManager (context: Context, val healthConnectClient: HealthConnectClient?) {

actual val appDataBase: AppDatabase
actual val healthRecordsDatabase: HealthRecordsDatabaseProvider
actual val locationRecordsDatabase: LocationRecordsDatabaseProvider
actual val dataTypesProvider: DataTypesProvider
actual val healthClientProvider: HealthClientProvider
actual val locationCallbackManager: LocationCallbackManager
actual val locationDataSource: ILocationDataSource
actual val healthRepo: HealthRecordsRepository
actual val locationDataRepository: LocationDataRepository
actual val locationClient: LocationServiceNativeClient

init {
appDataBase = DatabaseDriverFactory().createDriver(context)?.let{
AppDatabase(it)
}!!
dataTypesProvider = DataTypesProvider()
//health classes
healthRecordsDatabase = HealthRecordsDatabaseProvider(appDataBase)
healthClientProvider = HealthClientProvider(healthConnectClient)
healthRepo = HealthRecordsRepository(healthRecordsDatabase, dataTypesProvider, healthClientProvider)

//location classes
locationRecordsDatabase = LocationRecordsDatabaseProvider(appDataBase)
locationClient = LocationServiceNativeClient(context)
locationDataSource = LocationDataSource(locationClient)
locationCallbackManager = LocationCallbackManager(locationDataSource)
locationDataRepository = LocationDataRepository(locationRecordsDatabase, locationCallbackManager)
}
}

iOS:

actual class DependencyManager constructor(locationNativeClient: CLLocationManager,
healthStore: HKHealthStore) {
actual val appDataBase: AppDatabase
actual val healthRecordsDatabase: HealthRecordsDatabaseProvider
actual val locationRecordsDatabase: LocationRecordsDatabaseProvider
actual val dataTypesProvider: DataTypesProvider
actual val healthClientProvider: HealthClientProvider
actual val locationCallbackManager: LocationCallbackManager
actual val locationDataSource: ILocationDataSource
actual val healthRepo: HealthRecordsRepository
actual val locationDataRepository: LocationDataRepository
actual val locationClient: LocationServiceNativeClient

init {
appDataBase = DatabaseDriverFactory().createDriver(null)?.let{
AppDatabase(it)
}!!
dataTypesProvider = DataTypesProvider()
//health classes
healthRecordsDatabase = HealthRecordsDatabaseProvider(appDataBase)
healthClientProvider = HealthClientProvider(healthStore)
healthRepo = HealthRecordsRepository(healthRecordsDatabase, dataTypesProvider, healthClientProvider)
//location classes
locationRecordsDatabase = LocationRecordsDatabaseProvider(appDataBase)
locationClient = LocationServiceNativeClient(locationNativeClient)
locationDataSource = LocationDataSource(locationClient)
locationCallbackManager = LocationCallbackManager(locationDataSource)
locationDataRepository = LocationDataRepository(locationRecordsDatabase, locationCallbackManager)
locationClient.dataCollectionJobStartCallback = {locationDataRepository.startDataCollectionJob()}
locationClient.dataCollectionJobCancellationCallback = {locationDataRepository.stopDataCollectionJob()}
}
}

Alternatively, another method for constructing the Dependency Injection (DI) graph would involve using Koin (https://insert-koin.io/). The choice between a simple “manual” graph and more sophisticated solution depends on the classes to which the dependencies must be provided. In my scenario, I found it convenient to supply these dependencies directly to consumer classes through their constructors, including composables and the Kotlin Multiplatform (KMP) implementation of the view model, and thus, I stuck with this straightforward approach.

A notable consideration revolves around where to initialize the platform-level dependencies required by our “actual” classes and “DependencyManager”. While there is no mandate for uniformity between Android and iOS, I adopted a similar approach for both platforms. The DependencyManager was initialized outside of KMP, encompassing the required as well as platform-level dependencies. It was then passed as a parameter to the view, essentially serving as a wrapper around a KMP composable (Note: differences in class names in Android and iOS, such as “MainView” and “MainViewController,” are merely platform-specific wrappers for the same composable, “App”).

Several factors influenced this approach:

  • The necessity for initiating “DependencyManager” in the Android “client” app due to its dependency on a context.
  • For larger-scale projects in both Android and iOS, the components of the “external” apps (“androidApp” and “iosApp”) may also need to access KMP dependencies from “DependencyManager”.
  • Considering that instances of platform-specific classes like HealthKit are typically supposed to be singletons, it appeared more natural to initialize and obtain them in the “client” before passing them to KMP, rather than adopting the inverse approach.

Below is the code:

Android:
class App: Application() {

override fun onCreate() {
super.onCreate()
dependencyManager = DependencyManager(this.applicationContext, getHealthClient())

}

fun getHealthClient(): HealthConnectClient? {
//code
}

companion object {
lateinit var dependencyManager: DependencyManager
}
}

Activity:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainView(App.dependencyManager)
}
}

Disclaimer: storing an instance of DependencyManager in a static variable I would consider anti-pattern and would never use in a real project 🙂

iOS:
struct ComposeView: UIViewControllerRepresentable {

let locationManager = CLLocationManager()
let healthStore = HKHealthStore()
let dependManager: DependencyManager

init() {
dependManager = DependencyManager(locationNativeClient: locationManager, healthStore: healthStore)
locationManager.requestWhenInUseAuthorization()
checkHealthDataStatus()
}

func makeUIViewController(context: Context) -> UIViewController {

return PlatformKt.MainViewController(dependencyManager: dependManager)
}
}

Distributing dependencies in KMP

The final aspect I’d like to address is the distribution of dependencies across the “commonMain” classes. For those with Android experience, there’s nothing particularly groundbreaking here, except perhaps for the UI part. Below is a composable class responsible for managing navigation between different screens and providing the UI classes with all the necessary dependencies.

@OptIn(ExperimentalNavigationApi::class, ExperimentalSerializationApi::class)
@Composable
fun AppViewWithBottomNavigation(dependencyManager: DependencyManager) {

val navigator = rememberSavableNavigator(Destinations.Home.name)
var selectedScreen by rememberSaveable { mutableStateOf(screens.first()) }

Scaffold(
bottomBar = {
BottomNavigation(
backgroundColor = lightColors().background
) {
screens.forEach { screen ->
BottomNavigationItem(
icon = { Icon(imageVector = getIconForScreen(screen), contentDescription = "") },
label = { Text(screen) },
selected = selectedScreen == screen,
onClick = {
selectedScreen = screen
navigator.push(screen)
}
)
}
}
}
)
{ innerPadding ->

val pagerViewModel = getViewModel(
key = "PagerViewModel",
factory = viewModelFactory {
PagerViewModel(dependencyManager.healthRepo)
}
)

val recordsListViewModel = getViewModel(
key = "RecordsViewModel",
factory = viewModelFactory {
RecordsListViewModel(
dependencyManager.healthRepo,
dependencyManager.locationDataRepository,
dependencyManager.dataTypesProvider
)
}
)

val dataEntryViewModel = getViewModel(
key = "RecordEntryViewModel",
factory = viewModelFactory {
RecordEntryViewModel(
dependencyManager.healthRepo,
dependencyManager.locationDataRepository
)
}
)

NavigationContainer(navigator = navigator) { (destination, context) ->
when (selectedScreen) {
Destinations.Home.name -> HealthRecordsScreen(
flow = recordsListViewModel.recordsToDisplay)
Destinations.DeleteAll.name -> DeleteRecordsByDateScreen(
viewModel = recordsListViewModel
)
Destinations.Metrics.name -> CarouselWithHealthMetrics(
viewModel = pagerViewModel)
Destinations.AddRecord.name -> HealthDataEntryScreen(
viewModel = dataEntryViewModel
)
}
}

}
}

As previously mentioned, this declaration looks very unusual for the “traditional” Android ecosystem, where we can’t have (or, more precisely, should’t use) parametrized constructors for Activities and Fragments (and also ViewModels, unless we initialize it via factory). Here we have a flexibility to construct composables and view models with the parameters we want.

Hope you find some inspiration here. Personally, I found KMP really cool, regardless of some bottlenecks I am planning to talk about in my next articles. If anyone wants to take a closer look at the project, here is the repo — https://github.com/OlgaDery/health_connect_multiplatform. And happy coding!

--

--

Olga Deryabina

Mobile Developer with a passion for product design. Love the startup environment as it encourages us to think beyond conventional boundaries.