Master KMP as an Android Developer by Building a Joke-Generator App
Introduction
After talking with several Android developers, I realized that one major hurdle keeping them from trying Kotlin Multiplatform (KMP) is its complex build system. More specifically — how hard it is to simply get something up and running (beyond the sample page).
Kotlin Multiplatform (KMP) enables sharing code across multiple platforms like Android, iOS, and the web. However, its build system can feel overwhelming due to multiple modules, targets, and platform-specific configurations.
In fact, this complexity was something that prevented me from starting with KMP as well. It seems like there are too many moving parts in KMPs’ project structure.
Lately though, I’ve become determined to master KMP. With this article I hope to make it more accessible to the rest of Android devs.
I will guide you through building a joke-generator app. And in the process — you’ll learn how KMP works.
Just in case — the full code is here.
How difficult is it to understand KMPs’ build structure?
If you use the KMP Setup wizard, tick all the boxes, and then open up the project you’ll see this:
Sweet mother of god... Looks scary, doesn’t it?
Well, it is. I’m not going to sugarcoat it for you. The build system for KMP is complex. But what’s the best antidote to help you understand complex things? You got it — breaking them down into “simpler” things.
Let’s do the same with the KMP build system and try to understand what’s going on.
Let’s start with a simple example
First, I highly recommend you check out my first article where I compare KMP and Flutter and explain how to set up each of these frameworks for development.
Now, in this article we’ll “target” only two platforms — Android and iOS. If we do this using the Wizard and tick the box “Share UI” on iOS we’ll see something like this (make sure you switch to Project view in Android Studio):
Whew. Looks much more manageable.
The naming here is pretty self-explanatory. And the default README file explains this:
commonMain
for “common” code (we’ll go into this later).androidMain
for Android code.iosMain
for IOS code… written in Kotlin. Yes, it’s weird, but bear with me here — I’ll explain how this works further.
Let’s proceed.
Before we start building our networking feature — let’s understand our limitations.
Soo… there’s no easy way to say this, so I’ll just say it… Most of the libraries you’re currently using on Android — you won’t be able to use in KMP.
Yes, Dagger, Hilt, Retrofit — and a lot of the stuff that you’ve used for such a long time in Android — down the toilet (at least for now).
So we will need to use other tools to build our networking feature. Now, while you’re still in denial and processing this fact — let me explain why.
Targets
The first thing we need to understand is a concept called “target”. From documentation:
Target is a part of the build responsible for compiling, testing, and packaging a piece of software aimed at one of the supported platforms.
A Kotlin target is an identifier that describes a compilation target. It defines the format of the produced binaries, available language constructions, and allowed dependencies.
Now there “targets” and there are “target-platforms”.
If I were to give a real-world analogy — think of target-platforms as countries and targets as cities.
A Kotlin/JS target-platform encompasses browser
and node.js
targets. This means we can build a web app and a server if we declare these targets. While Kotlin/Native target-platform includes macOs
, iOS
, linux
and many more.
There are many target platforms — but the ones we care about in our case are the Android
and Kotlin/Native
(because we want to deploy to iOS). The Kotlin/Native
target-platform is named in such way because it compiles the code straight into binary that is “native” to the target (iOS in our case).
You have to declare the targets in the kotlin
block of the app-levelbuild.gradle
file. And this is what our kotlin
block should look like:
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
//Specifying the target JVM version to avoid compilation errors
jvmTarget.set(JvmTarget.JVM_11)
}
}
iosArm64() // actual IOS device
iosX64() // simulator
iosSimulatorArm64() // simulator
}
Now what does this have to do with our beloved Hilt and Retrofit?
Problems with Hilt / Dagger in KMP:
- Closely tied to the Android lifecycle (I don’t think I need to explain why this won’t work on other platforms).
- Hilt relies on annotations for generating code. Annotation processing uses reflection and JVM-based compilers.
Essentially this is the difference (simplified):
- Kotlin / JVM . Kotlin source code → Java bytecode -> runs on the JVM
- Kotlin / Native. Kotlin source code -> actual binary -> runs on that specific target (no support for reflection and other tools only available in the )
Bottom line — you can only use Kotlin in the commonMain
aka “shared code”.
I thought it was important to explain the difference between a platform and a target — however, due to their similarities — I will use these two definitions interchangeably further down.
Pure Kotlin libraries to the rescue!
Thankfully, KMP has better libraries than we have in Android. You’ll love your new buddies.
The full list of tools and libraries available for KMP is here. And for our use case — we’ll use Ktor.
Enough theory. Let’s build.
If you followed all of the steps above and in the first article, you should see something like this when you run the app.
Great, now open up composeApp
-> commonMain
-> App.kt
Inside you should see this:
This is the “shared” UI between all of your targets / platforms. Feel free to play around with it and launch on Android and iOS.
When working with KMP, I recommend running the build frequently on both platforms. You can refer to “Running the iOS configuration” paragraph in the first article if you want to launch on iOS.
Remember, KMP is compiling Kotlin into native machine code for each platform, but that doesn’t mean it’s doing so perfectly. To be safe — run frequently on both platforms.
Now, we’re going to keep things simple to keep the article short so we’ll avoid DI for now (I’ll cover Koin in further articles).
Adding dependencies
To make a call to our API we’ll need a library — Ktor. Declare the dependency in libs.versions.toml
:
[versions]
ktor = "3.0.2"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
And in your composeApp
-> build.gradle.kts
-> sourceSets{}
add:
commonMain.dependencies {
implementation(libs.ktor.client.core)
//Other dependencies
}
Don’t forget to press “Sync now” to download the libraries.
Having Ktor in our shared code is not enough, unfortunately . Each platform has its’ own Http engine in Ktor. Separate engines are required because they are optimized to work with their respective platforms. In our case Android uses OkHttp
and iOS uses Darwin
.
Adding platform-specific dependencies
First, let’s declare these dependencies as well. In libs.versions.toml
add:
[libraries]
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
To use Okhttp
and Darwin
— we need to make them available for their respective targets. How do we do that? By putting them in their corresponding source sets.
Why not put everything into commonMain
? Platform-specific libraries rely on APIs that are available in each of the platforms — and those are not available in commonMain
. If you’re familiar with Clean Architecture — think of commonMain
as the domain layer — i.e. you can reference it in other layers (modules), but you can’t put anything into it that’s not related to all platforms. Made this infographic to help you understand:
So, to make the dependencies available only to their respective platform we have to add them to the appropriate sourceSets. Add this to our build.gradle.kts
:
sourceSets {
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
//you might have to add this source set
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
Now, let’s see how we can actually provide Darwin
and Okhttp
engines to Ktor in our shared code.
Expect / Actual. Implementing the networking code.
Now we’re really getting into KMP specifics. This is probably the only keyword that’s not familiar to you if you’re coming from Android. From the documentation:
Expected and actual declarations allow you to access platform-specific APIs from Kotlin Multiplatform modules. You can provide platform-agnostic APIs in the common code.
In simple language — it’s how you provide custom dependency implementations for each platform to your shared code. Think of these as interfaces in Kotlin that force each target to provide an implementation. There is another way to do this, though, — I’ll cover it further down.
To make our network call we need to get an instance of a Ktors’ HttpClient
. But! to createHttpClient
we need to give it HttpClientEngineFactory
. Those platform-specific dependencies we declared above give us implementations of these factories. We can’t access them in our commonMain
so let’s see how we can get them.
Let’s create HttpClient.kt
in our commonMain
and add this code:
expect fun createPlatformHttpClient(): HttpClient
You will notice that the name of the function is highlighted. If we hover over the name we’ll get a clue from IDE:
And if we press the “Add missing actual declarations” at the bottom, we’ll get this prompt:
Since we’re targeting only Android and iOS — we select only those. By selecting iosMain
— we’re also providing an implementation for the three targets below. If we would’ve wanted to target the whole Apple platform (including the MacOS) — we would need to check the appleMain
box.
After you press OK you will be redirected to iosMain
's root folder and will see this code:
actual fun createPlatformHttpClient(): HttpClient {
TODO("Not yet implemented")
}
And here we provide the iOS Darwin engine:
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
actual fun createPlatformHttpClient(): HttpClient {
return HttpClient(Darwin)
}
Now we have to provide the same thing in androidMain -> HttpClient.android.kt
:
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
actual fun createPlatformHttpClient(): HttpClient {
return HttpClient(OkHttp)
}
At compile-time — for every expect
declaration — the KMP compiler will look for the matching actual
for each target.
Making the network call
Ok, we’ve got everything set up to make our first API call.
This is just a demonstration — so we’re going to create HttpClient
and make a call from the UI — which you should never do in a production application.
In our commonMain
-> App.kt
let’s go ahead and change up our UI a bit and do an actual call to the API:
const val URL = "https://v2.jokeapi.dev/joke/Any?type=single"
@Composable
fun App() {
MaterialTheme {
var client: HttpClient? by remember { mutableStateOf(null) }
//to make sure that we're not creating a new client on every recomposition
LaunchedEffect(Unit) {
client = createPlatformHttpClient()
}
val scope = rememberCoroutineScope()
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column {
Button(onClick = {
scope.launch(Dispatchers.IO) {
if (client!!.get(URL).status.value == 200) {
println("Joke received!")
}
}
}) {
Text("Generate a joke")
}
}
}
}
}
As you can see in our LaunchedEffect()
we’re invoking createPlatformHttpClient()
which in compile-time will determine the correct implementation of HttpClient
depending on the target being built.
We also need to register our Internet permission in androidMain -> AndroidManifest.xml
:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
Let’s give our code a spin…
Seems like the request is working!
Also, don’t forget to test iOS as well. Remember I said to run on both platforms often?
You can refer to the first article for more detailed instructions. But as a reminder — open the iosApp
folder in your project from XCode. Then run the app. You might have to set up a development team and do some other set ups — follow XCode instructions to complete that.
Seems like iOS works as well! We are almost done. However, Ktor can’t serialize classes out of the box. To parse our joke we have to add serialization + content negotiation.
Configuring Ktor
To configure serialization in Ktor we need to add some more dependencies. Bear with me, there’s quite a bit.
In your libs.versions.toml
add:
[libraries]
ktor-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
[plugins]
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
In composeApp build.gradle.kts
:
plugins {
//Other plugins
alias(libs.plugins.serialization)
}
//other code
commonMain.dependencies {
//Other dependencies
implementation(libs.ktor.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
In project-level build.gradle.kts
:
repositories {
mavenCentral() // Add this if it's missing
}
plugins {
//Other plugins
alias(libs.plugins.serialization) apply false
}
Press “Sync now” and now create the actual Joke
class in our commonMain
:
@Serializable
data class Joke(
val joke: String,
)
And finally, let’s configure Ktor:
const val URL = "https://v2.jokeapi.dev/joke/Any?type=single"
@Composable
fun App() {
MaterialTheme {
var client: HttpClient? by remember { mutableStateOf(null) }
//to make sure that we're not creating a new client on every recomposition
LaunchedEffect(Unit) {
client = createPlatformHttpClient().config {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
}
}
val scope = rememberCoroutineScope()
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column {
Button(onClick = {
scope.launch(Dispatchers.IO) {
val joke: Joke = client!!.get(URL).body()
println(joke)
}
}) {
Text("Generate a joke")
}
}
}
}
}
The code here is pretty self-explanatory — we’re configuring Ktor using its’ DSL and making sure that the Json
serializer maps the received json to our “incomplete” Joke
data class.
Then further down we’re simply getting the body()
of our request and mapping it straight to the Joke
data class via Ktor magic.
Let’s run:
Yay! Our app is working! Let’s change up our UI a bit to make sure the app is actually usable.
Applying final touches
Now, to finally see the fruits of our labor — let’s bring the long-awaited jokes to our screen:
@Composable
fun App() {
MaterialTheme {
var client: HttpClient? by remember { mutableStateOf(null) }
var joke by remember { mutableStateOf<Joke?>(null) }
//to make sure that we're not creating a new client on every recomposition
LaunchedEffect(Unit) {
client = createHttpClient()
}
val scope = rememberCoroutineScope()
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
scope.launch(Dispatchers.IO) {
joke = client!!.get(URL).body()
}
}) {
Text("Generate a joke")
}
joke?.let {
Text(
modifier = Modifier.padding(16.dp),
fontSize = 24.sp,
text = it.joke
)
}
}
}
}
Here — I’ve abstracted away the createHttpClient()
into a private function for brevity and am displaying a joke text if it’s not null.
Let’s run and see what we’ve got:
Haha, so funny! Let’s see the iOS build:
Oh my god, I’m dying! Just kidding, but there are a lot of good jokes out there :)
Conclusion
In this article, you learned how to:
- Set up a Kotlin Multiplatform project for Android and iOS.
- Use platform-specific dependencies with
expect
andactual
. - Make API calls with Ktor.
- Configure serialization for shared code.
There is, of course, much more to KMP — but this should get you started.
If you need the full code — it’s here.
Excited to learn more about Kotlin Multiplatform? Follow me for future articles and updates on my upcoming course, “KMP for Android Developers”. Don’t miss out — click the green envelope on my profile to get updates via email!
Thanks for reading! Let me know in the comments what topics you want me to cover next.