KMP for Mobile Native Developer — Part. 1: The beginning

Santiago Mattiauda
12 min readFeb 12, 2024

--

What is Kotlin Multiplatform (KMP)?

It is a technology designed to simplify the development of cross-platform projects, reducing the time spent on writing and maintaining the same code for different platforms. Additionally, it preserves the flexibility and benefits of native programming.

Kotlin Multiplatform is a technology developed by JetBrains that allows developers to write code in the Kotlin programming language and share it across multiple platforms, such as Android, iOS, web, and desktop.

With Kotlin Multiplatform, developers can share business logic, data models, and other parts of the application across platforms, reducing code duplication and facilitating maintenance. Although not all parts of the application can be shared due to differences between platforms, Kotlin Multiplatform provides tools and libraries that help maximize the amount of shared code.

Sharing code reduces the time spent on writing and maintaining the same code for different platforms, while preserving the flexibility and benefits of native programming.

Code sharing across platforms

Kotlin Multiplatform allows you to maintain a single codebase for the application logic across different platforms. You also get the benefits of native programming, including high performance and full access to platform SDKs.

Kotlin provides the following mechanisms for code sharing:

  • Share common code across all platforms used in your project.
  • Share code between some platforms included in your project to reuse a significant portion of code on similar platforms.
Platform support

Strategies for sharing our code.

Sharing a piece of logic

We can start by sharing an isolated and critical part of the application. Reusing the Kotlin code you already have to keep the applications in sync.

This strategy aims to share a logical unit of our application as small as possible and that makes sense. Why the term ‘logical unit’? This term refers to a portion of our application that solves a particular problem, such as validations, use cases, etc., that are independent of the platform. It is important to always consider promoting a good base design or aiming for it.

Share the logic and maintain the native user interface

Consider using Kotlin Multiplatform when starting a new project to implement data handling and business logic only once. Maintain the native user interface to meet the strictest requirements. While we can start with new projects, we can also bring our existing Kotlin Android code by reusing implementations on that platform.

The approach here is to leave the user interfaces to native frameworks, as it is the critical point in the user experience our application provides, and instead share the logic/infrastructure of our application.

Share up to 100% of your code

Share up to 100% of your code with Compose Multiplatform, a modern declarative framework for building user interfaces across multiple platforms.

Thanks to Compose Multiplatform, we can generate shared user interfaces for both platforms. Although Compose Multiplatform is currently in an evolving phase, it could become a viable option for new mobile app projects.

Currently, Compose Multiplatform is designed following the principles of Material Design, which may present certain limitations compared to the iOS Design System. However, the JetBrains team is working to provide support for Cupertino (Apple’s Design System) in the future.

How can we truly benefit from sharing code.

So far I have been describing what Kotlin Multiplatform promises us in some way, but now we are going to explore what KMP actually brings us and what libraries we can rely on when starting or migrating our development.

What parts of your code were you able to share between platforms?

In 2021, JetBrains conducted a survey asking: ‘What parts of your code were you able to share between platforms?’ Although I don’t have figures on KMP adoption at that time, when analyzing architecture and software design topics, it seems that the survey results could make sense.

What parts of your code were you able to share between platforms?

As for it is important to propose a good design, as we often say, we form a team language when implementing solutions regardless of the platform.

Defining an architecture

When we talk about architecture, the first thing that comes to mind is Clean Architecture. In summary, Clean Architecture is a series of architectural patterns that promote the decoupling of frameworks or external actors from our domain or business logic. To achieve this, at least three concepts or minimum definitions are defined:

  • Domain: Concepts that are within our context (User, Product, Cart, etc), and business rules that are exclusively determined by us (domain services).
  • Application: The application layer is where the use cases of our application live (register user, publish product, add product to cart, etc).
  • Infrastructure or Data: Code that changes based on external decisions. In this layer, the implementations of the interfaces that we define at the domain level will live. In other words, we will rely on the Dependency Inversion Principle (DIP) of SOLID to decouple from external dependencies.

This is where frameworks and external actors would fit, such as Repositories, HTTP Clients, and Caches.

Components in an application layout.

As our domain and application layer purely encapsulates our business logic, this will be the main code point to share between platforms. Otherwise, we would have to replicate both specifications in Kotlin and Swift for their respective platforms.

Infrastructure

At this point, we could confidently consider native implementations. If we meet the definitions at this level, we will rely on the Dependency Inversion Principle (DIP) of SOLID to decouple ourselves from external dependencies. This involves establishing contracts using the expect — actual pattern of Kotlin Multiplatform (KMP).

Networking Example

Let’s see an example of how we can use the native clients of each platform, Android and iOS, to make a network request.

To begin, we will define our repository and a data source, in this case of remote type, indicating that the source is a network source. For the data source, we will establish an interface that will have two possible implementations: one for Android and another for iOS, as shown in the following image.

In the case of Android, we will use Retrofit, and URLSession in the case of iOS.

Let’s see in code how this would look like,

We start by defining an expect function that will provide us with an implementation of the data sources depending on the platform.

expect fun provideGameDataSource(): GameRemoteDataSources

From here we can create our repository and the implementations for the data sources

class GameRepository(
private val remoteDataSources: GameRemoteDataSources = provideGameDataSource(),
) {

suspend fun fetch(): Result<GameResponse> {
return remoteDataSources.getGames()
}

}

Android

actual fun provideGameDataSource(): GameRemoteDataSources {
return AndroidGameRemoteDataSources()
}

class AndroidGameRemoteDataSources : GameRemoteDataSources {

private val client = RetrofitClient(baseUrl)
private val services = client.create<GameServices>()

override suspend fun getGames(): Result<GameResponse> {
return runCatching {
val games = services.getGames()
val jsonElement = Json.parseToJsonElement(games)
Json.decodeFromJsonElement<GameResponse>(jsonElement)
}
}

companion object {
private const val baseUrl = "https://www.freetogame.com/api/"
}
}

IOS

actual fun provideGameDataSource(): GameRemoteDataSources {
return IOSGameRemoteDataSources()
}

class IOSGameRemoteDataSources : GameRemoteDataSources {

private val client = URLSessionClient()

override suspend fun getGames(): Result<GameResponse> {
return runCatching {
val jsonString = client.fetch(baseUrl)
Json.decodeFromString<GameResponse>(jsonString)
}
}

companion object {
private const val baseUrl = "https://www.freetogame.com/api/games>"
}
}

Once implemented we would have our KMP project with the following structure.

Project structure.

💡 Spoiler Alert: In the next part we will see what the structure of a Kotlin Multiplatform project is like.

As we mentioned, “we will rely on SOLID’s Dependency Inversion Principle to decouple ourselves from external dependencies”. In this part, we could also resort to native code, that is, code implemented directly on the platform where we are using our cross-platform code. Let’s see an example of this in iOS with Swift.

Being GameRemoteDataSource an interface, this is equivalent to a protocol in Swift.

class SwiftGameRemoteDataSources: GameRemoteDataSources {

func getGames() async throws -> Any? {

guard let url = URL(string: "https://www.freetogame.com/api/games") else {
throw GameServiceError.invalidURL
}
let (data, _) = try await URLSession.shared.data(from: url)
let result = try JSONDecoder().decode(GameResponse.self, from: data)
return result.map{ item in item.asDomainModel() }
}

}

What we will take into account here is that type compatibility is lost, for example, as we see in the signature of the getGames function, it returns Any?

And we will use this in the following way

import Shared

@Observable
class GameViewModel{

let repository = GameRepository(remoteDataSources: SwiftGameRemoteDataSources())

var data:String = ""

func load(){
self.repository.fetch(completionHandler:{response, _ in
self.data = "\\(String(describing: response))"
})
}
}

While sharing our business logic is the real value of Kotlin Multiplatform, and as we saw, it is possible to reuse code from platforms at the infrastructure level, it is still a difficult scheme to maintain. Often, platforms differ in their implementations. For example, if we want to apply the same networking strategy we saw at the preferences level, using SharedPreferences on Android and UserDefaults on iOS, we will find that for Android, the Context is a requirement, which means that the implementation would have a strong platform dependency.

Let’s see what we can rely on when deciding which libraries to use at this point.

These are some of my recommendations when using libraries that help us delegate infrastructure implementations, such as networking, storage, and even dependency injection.

Libraries

Networking

Ktor

Ktor is an open-source framework for building web and server applications in Kotlin. Ktor Client is a part of Ktor that is used to make HTTP requests from a multiplatform Kotlin application. With Ktor Client, you can make HTTP requests from your shared code on supported platforms.

Ktor Client provides a declarative and fluent API for making HTTP requests in an easy and efficient way, making it suitable for developing multiplatform applications that need to interact with web services. You can use Ktor Client to make GET, POST, PUT, DELETE, and other HTTP operations, as well as easily handle headers, parameters, and request and response data.

internal fun apiClient(baseUrl: String) = HttpClient {

install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
}

defaultRequest {
url(baseUrl)
contentType(ContentType.Application.Json)
}
}

//using ktor client
class KtorRemoteMoviesDataSource(
private val client: HttpClient,
) : RemoteMoviesDataSource {

override suspend fun getMovies(): Result<List<MovieDto>> = runCatching {
// invoke service
val response = client.get("movie/popular")
val result = response.body<MovieResponse>()
result.results
}
}

Ktorfit

Ktorfit is a HTTP client/Kotlin Symbol Processor for Kotlin Multiplatform ( Android, iOS, Js, Jvm, Linux) using KSP and Ktor clients inspired by Retrofit.

class ServiceCreator(baseUrl: String) {

private val client = HttpClient {
install(ContentNegotiation) {
json(Json { isLenient = true; ignoreUnknownKeys = true })
}
}

private val ktorfit = Ktorfit.Builder()
.baseUrl(baseUrl)
.httpClient(client)
.build()

fun createPictureService() = ktorfit.create<PictureService>()
}

interface PictureService {
@GET("random")
suspend fun random(): Picture
}

Storage

Datastore

Jetpack Datastore is a data storage solution that allows you to store key-value pairs or objects written with protocol buffers. Datastore uses Kotlin coroutines and Flow to store data asynchronously, consistently, and transactionally.

If you are currently using SharedPreferences to store data, consider migrating to Datastore.

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
import okio.Path.Companion.toPath

private lateinit var dataStore: DataStore<Preferences>

private val lock = SynchronizedObject()

fun getDataStore(producePath: () -> String): DataStore<Preferences> =
synchronized(lock) {
if (::dataStore.isInitialized) {
dataStore
} else {
PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
.also { dataStore = it }
}
}

internal const val dataStoreFileName = "counter.preferences_pb"

Multiplaform Settings

This is a Kotlin library for Multiplatform apps, so that common code can persist key-value data.

import com.russhwolf.settings.Settings
import com.santimattius.kmp.skeleton.core.preferences.IntSettingConfig
import kotlinx.coroutines.flow.Flow

//commonMain
expect fun provideSettings(): Settings

class SettingsRepository(
settings: Settings = provideSettings(),
) {

private val _counter = IntSettingConfig(settings, "counter", 0)
val counter: Flow<Int> = _counter.value

fun increment() {
val value = _counter.get().toInt() + 1
_counter.set("$value")
}

fun decrease() {
val value = _counter.get().toInt() - 1
if (value < 0) {
_counter.set("0")
} else {
_counter.set("$value")
}
}
}

Initialization in Android and iOS.

//androidMain
import androidx.preference.PreferenceManager
import android.content.SharedPreferences
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings

actual fun provideSettings(context:Context): Settings {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return SharedPreferencesSettings(sharedPref)
}

//iosMain
import com.russhwolf.settings.NSUserDefaultsSettings
import com.russhwolf.settings.Settings
import platform.Foundation.NSUserDefaults

actual fun provideSettings(): Settings{
return NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults)
}

As we see, Multiplatform Settings uses the implementations of the preferences of each platform.

KStore

A tiny Kotlin multiplatform library that assists in saving and restoring objects to and from disk using kotlinx.coroutines, kotlinx.serialisation and okio. Inspired by RxStore.

Features

  • 🔒 Read-write locks; with a mutex FIFO lock
  • 💾 In-memory caching; read once from disk and reuse
  • 📬 Default values; no file? no problem!
  • 🚚 Migration support; moving shop? take your data with you
  • 🚉 Multiplatform!

Database

SQLDelight

SQLDelight generates typesafe Kotlin APIs from your SQL statements. It verifies your schema, statements, and migrations at compile-time and provides IDE features like autocomplete and refactoring which make writing and maintaining SQL simple.

CREATE TABLE Favorite (
resourceId INTEGER PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
overview TEXT NOT NULL,
imageUrl TEXT NOT NULL,
type TEXT NOT NULL
);

selectAllFavorite:
SELECT * FROM Favorite;

The SQLDelight Plugin generates the necessary classes to interact with the Database. In this example AppDatabase and databaseQueries are generated by SQLDelight.

class SQLDelightFavoriteLocalDataSource(
db: AppDatabase,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : FavoriteLocalDataSource {

private val databaseQueries = db.appDatabaseQueries

override val all: Flow<List<Favorite>>
get() = databaseQueries
.selectAllFavorite()
.asFlow()
.mapToList(dispatcher)
}

Room (Soon)

The Room persistence library provides an abstraction layer over SQLite to allow fluent database access while harnessing the full power of SQLite. In particular, Room provides the following benefits:

  • Compile-time verification of SQL queries.
  • Convenience annotations that minimize repetitive and error-prone boilerplate code.
  • Streamlined database migration paths.

Although Room does not yet have official support for multiplatform, according to Ryan W, the Google team is working on it 👉 https://medium.com/@callmeryan/kotlin-multiplatform-when-will-the-android-room-database-join-the-club-949005ba0d58

These are some of the libraries that would help us address the “problem” of delegating to specific platform implementations, allowing our code to be fully multiplatform according to our choice to share our business logic, leaving aside critical aspects of the platform such as the user interface.

If you want to know what other libraries exist with KMP support, you can visit the following repository on Github.

Conclusion

While this article presents my point of view on which part of our code should be cross-platform and the different approaches we can take to generate shared code, it will always depend on the reality of each project and its dimensions. For this reason, I emphasize the design and architecture of the projects, as it often helps us identify those isolated pieces that would be beneficial to share across platforms.

If you are starting to adopt KMP in your projects or teams, it is always advisable to consider requirements beyond the specific platform (Android, iOS, Web, for example).

Next articles

In the following articles, we will go into more detail about the implementations within KMP, and these will be some of the topics we will cover:

Referencias

--

--