Digging into Kotlin Multiplatform Mobile

João Moreira
Pink Wall
Published in
11 min readFeb 23, 2023

--

Share the logic of your iOS and Android apps while keeping the UX native.

Intro

In this blog post, I’ll be showcasing a project I’ve built called Pokedex — A list of all the Pokemons on your hand. I’ve started it to explore the current state of Kotlin Multiplatform Mobile — KMM (more info later) and its tech stack. The main goal is to see how much code I can share when building Android and iOS native UIs with a backbone written in Kotlin.

Motivation

At Pink Room, we thrive on challenges. We constantly strive to push the boundaries of modern technology and challenge ourselves to think outside the box and explore new avenues. After reading the book Kotlin Multiplatform by Tutorials, written by Carlos Mota, Saeed Taheri & Kevin D Moore, I was inspired to take up a new challenge and create my first blog post writing about my discoveries. Here’s to embracing opportunities and, hopefully, creating something great out of them!

What is Kotlin Multiplatform

Kotlin Multiplatform is designed to simplify the development of cross-platform projects. It reduces time spent writing and maintaining the same code for different platforms while retaining the flexibility and benefits of native programming.

Note that in this blog post, I’ll be mainly focused on Kotlin Multiplatform Mobile — KMM which is:

an SDK designed to simplify creating cross-platform mobile applications. With KMM, you can share common code between iOS and Android apps and write platform-specific code only where necessary.

How to setup a KMM project

For you to start building, you’ll need:

There are a lot of awesome tutorials on this subject, so I’ll do a brief description of the steps to create a project like the one I’ll be showcasing.

On Android Studio click on File -> New -> New Project and select Kotlin Multiplatform App. Follow the instructions on the screen.

For better dependency management I’ve decided to use a buildSrc module, you can see a cool tutorial on setting it up here.

Structure of the project

The project is divided into 3 modules:

Example of a KMM project from the kotlinlang.org site
  • androidApp — Android-specific code;
  • iosApp *— iOS-specific code;
  • shared — shared code between the two platforms.

* You still need a Mac computer to run the iOS code.

The shared module has 3 different source sets:

  • commonMain — stores the code that works on both platforms, including the expect declarations;
  • androidMain — stores Android-specific parts, including actual implementations;
  • iosMain — stores iOS-specific parts, including actual implementations;

Note: I’ll talk more about expect and actual further in the blog post. For more information about the project architecture, you can check KotlinLang’s site.

Tech stack used

Let’s start with the platform-dependent tech stack.

Android

I’m using Jetpack Compose, as I’ve had great success when utilising it in the various projects I’ve started over the past year. I’m really enjoying the experience of using it.

iOS

As I’m still relatively new to iOS development, I decided to use SwiftUI, which has similar concepts to what I’m accustomed to using in Android.

Now the fun part begins! I have been experimenting with KMM for some time and have tested many libraries for shared components. Below I’ll list them all, with a brief explanation of why I did or didn’t choose to use them in the final version.

Dependency injection

Koin — A pragmatic lightweight dependency injection framework for Kotlin.
Koin is a library widely used by KMM developers, which I have previously tested in an Android environment. It appears to be the library of choice for many.

API requests

Ktor — Framework for quickly creating connected applications in Kotlin with minimal effort.
Ktor is an established player in the world of Android development and is the go-to choice for many developers when it comes to creating internet requests in KMM. Initially, I used Ktor, but I was looking for something similar to Retrofit, which offers annotation-processed requests.

KtorFit — HTTP client / Kotlin Symbol Processor for Kotlin Multiplatform (Js, Jvm, Android, Native, iOS) using KSP and Ktor clients inspired by Retrofit.
With KtorFit I found exactly what I was looking for — it offers all the benefits of Ktor, with a familiar Retrofit style implementation.

Database

SQLDelight — Generates typesafe Kotlin APIs from SQL.
I prefer to use SQLDelight and Room for Android projects and find SQLDelight, with its availability in Kotlin Multiplatform (KMM), to be the most practical choice for most projects. With its powerful query library and user-friendly implementations of SQLite, it is a great option for data persistence in applications.

Door — Room for Kotlin Multiplatform.
Door is not an option at this time since it does not have any iOS capabilities.

Sharing ViewModels

moko-mvvm — MVVM architecture components for mobile multiplatform with LiveData (iOS and Android).
On the first implementation of the project, I tested moko-mvvm and, despite the challenging initial configuration, I got great results. As I continued exploring it deeper, I found some restrictions like the need to use moko-kswift on iOS and no support for Swift Package Manager. Also, the lack of documentation for Compose and SwiftUI lead me to consider another library.
I’ll most likely revisit it in the future because I feel this will be a very powerful library when some of these issues are addressed.

KMM ViewModels — A library that allows you to share ViewModels between Android and iOS.
Setting up KMM ViewModels was significantly simpler than using moko-mvvm, and the results I got from using KMM ViewModels were excellent. The only issue I experienced was related to pagination, which I discuss in more detail later.

Pagination

Multiplatform Paging — Kotlin Multiplatform library for Pagination on Android and iOS.
For Android, pagination was easy to implement with out-of-the-box solutions. To achieve the same functionality on iOS, however, a wrapper was required.

Let the game begin

First thing first! Setting up Koin.

Let’s start by creating a Koin.kt file on the commonMain of the shared module.

This file has functions that allow initializing Koin on both Android and iOS, a declaration for platform-specific modules, and common modules that are not platform-specific, in this case, our HTTP client, API services, repositories and database modules.

Now, we need an HTTP client driver and a Database driver for each platform. To inject those, we’ll use:

expect fun platformModule(): Module

We’ll now need to create the actual function on both androidMain and iosMain like this:

androidMain

actual fun platformModule() = module {
single { DatabaseDriverFactory(get()) }
single { Android.create() }
}

iosMain

actual fun platformModule() = module {
single { DatabaseDriverFactory() }
single { Darwin.create() }
}

Note: Don’t forget that the expect and actual functions need to be in the same packages, but on different source sets, in this case, expect will be on the commonMain source set and actual on both androidMain and iosMain, all on the dev.pinkroom.pokedex.di package.

For the database, I did the same: create the DatabaseDriverFactory expect class on the commonMain and the actual class implementation is done on each platform’s source set.

Now we just need to create the platform-specific part. This includes adding new modules and/or just calling the initKoin(…) function.

On Android, I’ve added a module to inject the view models that you can see here and the initialization is made on the MainActivity.

private fun initKoin() {
initKoin {
androidLogger(if (BuildConfig.DEBUG) Level.ERROR else Level.NONE)
androidContext(this@MainActivity)
modules(appModule)
}
}

On the other hand, on iOS I only added the initialization on the iOSApp.swift.

Next, let’s setup Ktorfit for the API requests.

You might have noticed that in the Koin.kt file I’ve already created my Ktorfit client like this:

fun createHttpClient(
httpClientEngine: HttpClientEngine,
json: Json,
enableNetworkLogs: Boolean,
): Ktorfit {
val client = HttpClient(httpClientEngine) {
defaultRequest { url("https://pokeapi.co/api/v2/") }
install(ContentNegotiation) { json(json) }
if (enableNetworkLogs) {
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO
}
}
}
return Ktorfit.Builder().httpClient(client).build()
}

I’ve created two services and repositories for this project. One is to get the pokemon list, called PokedexService

interface PokedexService {
@GET("pokemon")
suspend fun getPokemons(
@Query("limit") limit: Long,
@Query("offset") offset: Long,
): PokemonResponse
}

and the other is called DetailsService, where we get the selected pokemon’s information.

interface DetailsService {
@GET("pokemon/{name}")
suspend fun getDetails(@Path("name") name: String): PokemonDetails
}

For the requests, I needed to create the data modules used to get the data from the endpoints. Here you can check out the data models. The Pokemon.kt and PokemonDetails.kt data modules as well as PokemonResponse.kt I use to get the pagination data on the Pokedex endpoint.

Moving on to persisting data locally. I wanted to create a simple database where I would store the pokemon list only, to do so, I needed to setup SQLDelight.

In cashapp’s website, there’s a very clean tutorial on setting it up for multiplatform projects. Check it out here.

When using SQLDelight you need to create your own tables, you can check my LocalPokemon.sq here.

With drivers and initial setup out of the way are only missing a way to store and retrieve the data.

For that I created a DAO like this:

class PokemonDao(private val queries: LocalPokemonQueries) {

fun get(limit: Long, offset: Long): List<LocalPokemon> =
queries.getAllPaging(limit, offset).executeAsList()

fun deleteAll() {
queries.deleteAll()
}

fun insert(list: List<LocalPokemon>) {
queries.transaction {
list.forEach { insert(it) }
}
}

private fun insert(item: LocalPokemon) {
queries.insert(item)
}
}

Note that I’m already adding limits and offsets as parameters on the get function since I’m going to do pagination on the pokemon list.

For pagination, I found a solution that worked out of the box on Android but needed a wrapper on the iOS app. The implementation was not that complex, but in the future, I expect those components will get updates to facilitate the developer’s integration.

Moving on to how I implemented pagination ignoring all the setup because you can find it here.

The majority of the work was created on the PokedexRepository.kt.

First, I needed a way to get the data: I created the function getPokemonsRemote(…) where I get the information from the remote source and store it in my database.

private suspend fun getPokemonsRemote(key: Long): List<Pokemon> = try {
val result = pokedexService.getPokemons(PAGING_SIZE, key * PAGING_SIZE)
if (key == 0L) pokemonDao.deleteAll()
pokemonDao.insert(result.results.map { it.toPokemonModel() })
result.results
} catch (e: Exception) {
emptyList()
}

This function is used to retrieve the information from the API when we don’t have any data on our database. The getPokemons(…) function decides when we call the API or when we use the data persisted in the database:

private suspend fun getPokemons(key: Long): List<Pokemon> {
val local = pokemonDao.get(PAGING_SIZE, key * PAGING_SIZE)
return if (local.isEmpty()) getPokemonsRemote(key) else local.map { it.toPokemon() }
}

Using these functions, I was able to create a pager that effectively handles paginated requests.

val pager = Pager(
clientScope = scope,
config = PagingConfig(pageSize = PAGING_SIZE.toInt(), enablePlaceholders = false),
initialKey = 0L,
getItems = { key, _ ->
PagingResult(
items = getPokemons(key),
currentKey = key,
prevKey = { null },
nextKey = { key + 1 }
)
}
)

Note: the pager, ideally, wouldn’t be exposed out of the repository, but in this case, I needed it for the iOS wrapper in order to get to the next page.

Finally, I created the pokedexPagingData variable that is exposed to the view model and is collected by the view to show the data to the user.

val pokedexPagingData: CommonFlow<PagingData<Pokemon>>
get() = pager.pagingData.cachedIn(scope).asCommonFlow()

Note: the function .asCommonFlow() is a helper we can use to consume Flow on iOS. See FlowHelpers.

I was absolutely thrilled when I found out about KMM, especially due to the possibility of sharing view models. After trying out several solutions and seeing the different results, I eventually decided to use KMM ViewModels. This was by far the most exciting part of my exploration!

Let’s talk about setting it up. After adding the dependency to the shared module, I added KMMViewModels() to the view model class declaration. Simple as that.

Let me show you what my DetailsViewModel.kt looks like:

class DetailsViewModel : KMMViewModel(), KoinComponent {

private val detailsRepository by inject<DetailsRepository>()

private val _state = MutableStateFlow(viewModelScope, DetailsState())

@NativeCoroutinesState
val state = _state.asStateFlow()

fun getDetails(name: String) = viewModelScope.coroutineScope.launch {
_state.value = _state.value.copy(loading = true)
detailsRepository.getDetails(name)
.onSuccess { _state.value = _state.value.copy(loading = false, pokemon = it) }
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message) }
}
}

This is as close as I could get to what I’m used to doing in Android. The only nuance here is the usage of the @NativeCoroutinesState annotation from KMP-NativeCoroutines to turn the StateFlow into a property in Swift.

The UI was not a big focus of this project, so I’ll not get into much detail.

The feeling I got after I finished the first iteration of the code for this blog post is that I could have made the UI much better since I’m using native frameworks to do it, but since my main focus was exploring the shared code I decided to keep it as much simple as I could.

I’ll probably play with this part a lot more in the future since I eventually plan to try out Compose for iOS. Maybe in a future blog post, who knows…

Pains

There were some pains throughout this process, none that I wasn’t able to quickly mitigate.

One that bothered me was a bug in SQLDelight that was creating a duplicated commonMain but it is fixed in the latest release.

Other than that I had some small issues setting up some libraries like mokoMVVM but in the end, I got it working. The only reason I’m not talking about it a lot is that I really think KMM ViewModels is the perfect alternative for the project I ended up creating. Maybe in the future, I’ll explore it more and some other libraries from IceRock Development.

Conclusion

I feel I’ve written plenty of conclusions in the course of the blog post regarding libraries and some personal preferences but none regarding the state of KMM development.

I believe that KMM is a very good choice when:

  • a complex UI requires more customization than is possible with other cross-platform frameworks;
  • we want to reach native performance, require access to native APIs and have a large amount of sharable code;
  • the team that is developing the project has very strong Kotlin knowledge.

Having said this, I don’t think KMM should be thought of as a general replacement for Flutter or React Native, there are pros and cons in using any of these frameworks, we just need to think about the use cases we have and make our choice accordingly.

Final thoughts

I have the feeling that KMM will grow a lot over the next few years (take a look at the Kotlin official roadmap) since the community seems to be growing as well. KMM is production ready, but the tools/libraries available are still in the beta stage. What I mean by that is, the community is growing and the libraries, frameworks and knowledge sharing will mature with it and we’ll create better code and better multiplatform applications!

--

--