← Back to list

KMP From Zero: What Kotlin Multiplatform Actually Is, How It Compiles to Native Code, Project…

You’re an Android developer. You know Kotlin, Compose, Coroutines, Room, Retrofit, Hilt. You’ve built production apps. Now your company…

Ramadan Sayed · 2026-05-13 15:48 · 8 claps · 9.9 min read paywalled
#kmp #kmp-2 #kotlin-multiplatform #kmm #kmm-setup
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

KMP From Zero: What Kotlin Multiplatform Actually Is, How It Compiles to Native Code, Project Structure, Gradle Setup, expect/actual, and Your First Shared Module

You’re an Android developer. You know Kotlin, Compose, Coroutines, Room, Retrofit, Hilt. You’ve built production apps. Now your company wants to share code with iOS. You’ve heard of KMP but have no idea where to start — what it compiles to, how it talks to Swift, what you can actually share, and what the project structure looks like.

This is Part 1 of an 8-part series that takes you from zero KMP knowledge to a production-ready shared codebase. No prior KMP experience required. Every concept is explained from the Android developer’s perspective — comparing what you already know to how KMP does it differently.

What Is Kotlin Multiplatform?

Kotlin Multiplatform (KMP) is a technology that lets you write Kotlin code once and compile it to multiple targets — JVM (Android), Native (iOS), JavaScript (Web), and more. But unlike cross-platform frameworks like Flutter or React Native, KMP does NOT replace your native UI. You keep Jetpack Compose on Android and SwiftUI on iOS. You only share the parts that don’t touch the UI.

FLUTTER / REACT NATIVE:              KOTLIN MULTIPLATFORM:
┌─────────────────────┐              ┌─────────────────────┐
│   Shared UI          │              │  Native UI           │
│   (Flutter widgets)  │              │  Android: Compose    │
│   (React components) │              │  iOS: SwiftUI        │
├─────────────────────┤              ├─────────────────────┤
│   Shared Logic       │              │  Shared Logic (KMP)  │
│   (same framework)   │              │  Models, Networking  │
│                      │              │  Database, Validation│
├─────────────────────┤              │  UseCases, Repos     │
│   Platform Bridge    │              ├─────────────────────┤
│   (method channels)  │              │  Platform APIs       │
│   (native modules)   │              │  (expect/actual)     │
└─────────────────────┘              └─────────────────────┘

Trade-off:                            Trade-off:
✅ ONE codebase for everything        ✅ Native UI (best performance)
❌ Non-native UI (looks off)          ✅ 60-80% code shared
❌ Performance gap                    ❌ Two UI codebases
❌ Platform limitations               ✅ No bridge overhead

What Can You Share?

SHAREABLE (common module — 60-80% of your code):
  ✅ Data models (data classes, sealed classes, enums)
  ✅ Business logic (use cases, validators, formatters)
  ✅ Repository interfaces AND implementations
  ✅ Networking (Ktor replaces Retrofit)
  ✅ Database (SQLDelight replaces Room)
  ✅ Serialization (kotlinx.serialization — same as Android)
  ✅ Key-value storage (DataStore is multiplatform)
  ✅ Coroutines & Flow (fully multiplatform)
  ✅ ViewModel (lifecycle-viewmodel is multiplatform)
  ✅ Date/time (kotlinx-datetime)
  ✅ DI setup (Koin is multiplatform)

PLATFORM-SPECIFIC (expect/actual):
  ❌ UI (Compose for Android, SwiftUI for iOS)
  ❌ Camera, Bluetooth, Biometrics
  ❌ Push notifications (FCM vs APNs)
  ❌ Background processing (WorkManager vs BGTaskScheduler)
  ❌ File system paths, app lifecycle hooks

How Does It Compile?

This is the key insight most developers miss. KMP doesn’t interpret or transpile. It compiles natively for each target:

Your Kotlin code in commonMain/
          │
          ├─── Kotlin/JVM compiler ──→ .class files ──→ Android APK
          │    (same compiler you already use)
          │
          └─── Kotlin/Native compiler ──→ native binary ──→ iOS framework
               (LLVM-based, compiles to ARM64)

Android:
  commonMain Kotlin → JVM bytecode → Dalvik/ART → runs on Android
  (exactly like your current Android Kotlin code)
iOS:
  commonMain Kotlin → LLVM IR → ARM64 machine code → runs on iOS
  (no VM, no interpreter - actual native binary like Swift/ObjC)

For Android: Nothing changes. Your shared module compiles to a regular .jar that your Android app imports as a Gradle dependency. It's just Kotlin.

For iOS: The shared module compiles to a .framework (like a Swift Package). Your iOS app imports it and calls Kotlin classes as if they were Swift/ObjC classes. There's no bridge, no JNI, no FFI overhead.

Project Structure

my-kmp-project/
├── shared/                           ← KMP module (the shared code)
│   ├── src/
│   │   ├── commonMain/               ← Code that runs on ALL platforms
│   │   │   └── kotlin/
│   │   │       └── com/myapp/shared/
│   │   │           ├── data/
│   │   │           │   ├── remote/   (Ktor API client)
│   │   │           │   ├── local/    (SQLDelight database)
│   │   │           │   └── repository/
│   │   │           ├── domain/
│   │   │           │   ├── model/    (shared data classes)
│   │   │           │   └── usecase/  (business logic)
│   │   │           └── util/         (formatters, validators)
│   │   │
│   │   ├── commonTest/               ← Tests for common code
│   │   │   └── kotlin/
│   │   │
│   │   ├── androidMain/              ← Android-specific implementations
│   │   │   └── kotlin/
│   │   │       └── com/myapp/shared/
│   │   │           └── Platform.android.kt
│   │   │
│   │   └── iosMain/                  ← iOS-specific implementations
│   │       └── kotlin/
│   │           └── com/myapp/shared/
│   │               └── Platform.ios.kt
│   │
│   └── build.gradle.kts              ← KMP Gradle config
│
├── androidApp/                        ← Android app (Compose UI)
│   ├── src/main/
│   │   └── kotlin/
│   │       └── com/myapp/android/
│   │           ├── ui/               (Compose screens)
│   │           ├── di/               (Hilt modules)
│   │           └── MainActivity.kt
│   └── build.gradle.kts
│
├── iosApp/                            ← iOS app (SwiftUI/UIKit)
│   ├── iosApp/
│   │   ├── ContentView.swift
│   │   ├── iOSApp.swift
│   │   └── Info.plist
│   └── iosApp.xcodeproj
│
├── build.gradle.kts                   ← Root build file
├── settings.gradle.kts
└── gradle.properties

Key insight: commonMain is where 60-80% of your code lives. androidMain and iosMain are only for the small percentage that needs platform-specific APIs.

Gradle Setup (Step by Step)

Root settings.gradle.kts

pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
rootProject.name = "MyKMPApp"
include(":shared")
include(":androidApp")

Shared module build.gradle.kts

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
    alias(libs.plugins.kotlinSerialization)
}

kotlin {
    // ═══════════════════════════════════
    // TARGET: Android
    // ═══════════════════════════════════
    androidTarget {
        compilations.all {
            kotlinOptions {
                jvmTarget = "17"
            }
        }
    }

    // ═══════════════════════════════════
    // TARGET: iOS (arm64 + simulator)
    // ═══════════════════════════════════
    listOf(
        iosX64(),        // Intel Mac simulator
        iosArm64(),      // Physical iPhone
        iosSimulatorArm64() // Apple Silicon Mac simulator
    ).forEach {
        it.binaries.framework {
            baseName = "shared"
            isStatic = true  // Static framework (recommended for iOS)
        }
    }

    // ═══════════════════════════════════
    // DEPENDENCIES per source set
    // ═══════════════════════════════════
    sourceSets {
        commonMain.dependencies {
            // Networking
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.content.negotiation)
            implementation(libs.ktor.serialization.json)

            // Serialization
            implementation(libs.kotlinx.serialization.json)

            // Coroutines
            implementation(libs.kotlinx.coroutines.core)

            // DateTime
            implementation(libs.kotlinx.datetime)

            // ViewModel (multiplatform)
            implementation(libs.lifecycle.viewmodel)
        }

        commonTest.dependencies {
            implementation(libs.kotlin.test)
            implementation(libs.kotlinx.coroutines.test)
        }

        androidMain.dependencies {
            // Android-specific Ktor engine
            implementation(libs.ktor.client.okhttp)
        }

        iosMain.dependencies {
            // iOS-specific Ktor engine
            implementation(libs.ktor.client.darwin)
        }
    }
}

android {
    namespace = "com.myapp.shared"
    compileSdk = 35
    defaultConfig {
        minSdk = 26
    }
}

android {
    namespace = "com.myapp.shared"
    compileSdk = 35
    defaultConfig {
        minSdk = 26
    }
}

Version Catalog gradle/libs.versions.toml

[versions]
kotlin = "2.1.0"
agp = "8.7.3"
ktor = "3.0.3"
coroutines = "1.9.0"
serialization = "1.7.3"
datetime = "0.6.1"
lifecycle = "2.8.4"

[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" }

[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
androidApplication = { id = "com.android.application", version.ref = "agp" }

expect/actual: The Platform Bridge

When you need platform-specific behavior in shared code, KMP uses expect/actual. Think of it like an interface (expect) and implementation (actual), but enforced at compile time.

// ═══════════════════════════════════
// commonMain — DECLARE what you need
// ═══════════════════════════════════

// "I expect each platform to provide this"
expect fun getPlatformName(): String

expect class DeviceInfo() {
    val osVersion: String
    val deviceModel: String
    val isPhysicalDevice: Boolean
}

// ═══════════════════════════════════
// androidMain — IMPLEMENT for Android
// ═══════════════════════════════════

actual fun getPlatformName(): String = "Android ${Build.VERSION.SDK_INT}"

actual class DeviceInfo {
    actual val osVersion: String = "Android ${Build.VERSION.RELEASE}"
    actual val deviceModel: String = "${Build.MANUFACTURER} ${Build.MODEL}"
    actual val isPhysicalDevice: Boolean = !Build.FINGERPRINT.contains("generic")
}

// ═══════════════════════════════════
// iosMain — IMPLEMENT for iOS
// ═══════════════════════════════════

actual fun getPlatformName(): String = "iOS ${UIDevice.currentDevice.systemVersion}"

actual class DeviceInfo {
    actual val osVersion: String = UIDevice.currentDevice.systemVersion
    actual val deviceModel: String = UIDevice.currentDevice.model
    actual val isPhysicalDevice: Boolean = TARGET_OS_SIMULATOR == 0
}

The compiler guarantees: If you declare expect in commonMain, every platform module (androidMain, iosMain) MUST provide actual. Missing an actual is a compile error, not a runtime crash.

When to Use expect/actual vs Interface

// Use expect/actual when:
// - You need a platform CONSTRUCTOR (creating platform objects)
// - Top-level functions (no class instance needed)
// - Platform-specific annotations

// Use interface + injection when:
// - You want testability (inject fakes)
// - The implementation is complex
// - You're using DI already (Koin)

// Example: Logger
// Option A: expect/actual (simple, no DI)
expect fun logDebug(tag: String, message: String)
// androidMain: actual fun logDebug(...) = Log.d(tag, message)
// iosMain: actual fun logDebug(...) = NSLog("[$tag] $message")

// Option B: Interface + Koin (testable, injectable)
interface Logger {
    fun debug(tag: String, message: String)
}
// Provide platform implementations via Koin modules

Your First Shared Code

Let’s build something real — a greeting that works on both platforms.

Step 1: Shared Data Model

// commonMain/kotlin/com/myapp/shared/domain/model/Greeting.kt

data class Greeting(
    val message: String,
    val platform: String,
    val timestamp: Long
)

This data class compiles to:

  • Android: A regular Kotlin data class on JVM (exactly what you’re used to)
  • iOS: A Kotlin/Native class that Swift sees as a regular class with properties

Step 2: Shared Business Logic

// commonMain/kotlin/com/myapp/shared/domain/usecase/GetGreetingUseCase.kt

class GetGreetingUseCase {

    fun execute(userName: String): Greeting {
        val platform = getPlatformName()  // expect/actual from earlier
        val timeOfDay = getTimeOfDay()

        val message = buildString {
            append("Good $timeOfDay, $userName! ")
            append("Welcome from $platform. ")
            append("Today is ${getCurrentDateFormatted()}.")
        }

        return Greeting(
            message = message,
            platform = platform,
            timestamp = Clock.System.now().toEpochMilliseconds()
        )
    }

    private fun getTimeOfDay(): String {
        val hour = Clock.System.now()
            .toLocalDateTime(TimeZone.currentSystemDefault())
            .hour

        return when {
            hour < 12 -> "morning"
            hour < 17 -> "afternoon"
            else -> "evening"
        }
    }

    private fun getCurrentDateFormatted(): String {
        return Clock.System.now()
            .toLocalDateTime(TimeZone.currentSystemDefault())
            .date
            .toString()  // "2026-03-30"
    }
}

Step 3: Use From Android

// androidApp — your regular Compose screen
@Composable
fun GreetingScreen() {
    val useCase = remember { GetGreetingUseCase() }
    val greeting = remember { useCase.execute("Ramadan") }

    Column(
        modifier = Modifier.fillMaxSize().padding(24.dp),
        verticalArrangement = Arrangement.Center
    ) {
        Text(greeting.message, style = MaterialTheme.typography.headlineSmall)
        Spacer(Modifier.height(8.dp))
        Text("Platform: ${greeting.platform}", color = Color.Gray)
    }
}

Step 4: Use From iOS (Swift)

// iosApp — SwiftUI view
import shared  // The compiled KMP framework

struct GreetingScreen: View {
    let useCase = GetGreetingUseCase()

    var body: some View {
        let greeting = useCase.execute(userName: "Ramadan")

        VStack(spacing: 8) {
            Text(greeting.message)
                .font(.title3)
            Text("Platform: \(greeting.platform)")
                .foregroundColor(.gray)
        }
        .padding(24)
    }
}

Notice: The Swift code calls GetGreetingUseCase() and useCase.execute(userName:) — the same class, the same method names. KMP generates Swift-friendly APIs automatically.

How iOS Sees Your Kotlin Code

When you compile the shared module for iOS, KMP generates an Objective-C header file. Swift imports this through interop. Here’s what happens:

Kotlin data class Greeting(         →  Swift sees:
    val message: String,                class Greeting {
    val platform: String,                   let message: String
    val timestamp: Long                     let platform: String
)                                           let timestamp: Int64
                                        }

Kotlin sealed class Resource<T>     →  Swift sees:
    Success(val data: T)                class ResourceSuccess { let data: Any }
    Error(val message: String)          class ResourceError { let message: String }
    Loading                             class ResourceLoading { }

Kotlin suspend fun                  →  Swift sees:
    getUser(): User                     func getUser() async throws -> User

Kotlin Flow<T>                      →  Swift sees (with SKIE):
    observeUsers(): Flow<List<User>>    func observeUsers() -> AsyncSequence<[User]>

Key limitations to know:

  • Kotlin generics have limitations in iOS interop (use SKIE library to improve)
  • Kotlin sealed class becomes separate classes in Swift (use SKIE for enum mapping)
  • Kotlin suspend maps to Swift async/await natively (Kotlin 1.9+)
  • Kotlin Flow needs SKIE or manual wrapper for clean Swift consumption

Common Pitfalls for Android Developers

Pitfall 1: Trying to Use Android APIs in commonMain

// ❌ WON'T COMPILE in commonMain
import android.util.Log  // Android-specific!

fun logMessage(msg: String) {
    Log.d("TAG", msg)  // Can't use Android APIs in shared code
}

// ✅ Use expect/actual
expect fun logMessage(tag: String, msg: String)

// androidMain:
actual fun logMessage(tag: String, msg: String) = Log.d(tag, msg)
// iosMain:
actual fun logMessage(tag: String, msg: String) = NSLog("[$tag] $msg")

Pitfall 2: Using Java Libraries in commonMain

// ❌ java.util.Date doesn't exist on iOS
import java.util.Date
val now = Date()

// ✅ Use kotlinx-datetime (multiplatform)
import kotlinx.datetime.*
val now = Clock.System.now()
val local = now.toLocalDateTime(TimeZone.currentSystemDefault())

Pitfall 3: Thinking KMP Replaces Native UI

KMP is NOT Flutter. You don't write UI once.

Android UI: Jetpack Compose (your existing skills — unchanged)
iOS UI: SwiftUI (iOS team writes this)
Shared: Everything BELOW the UI layer

This is actually the STRENGTH:
- No compromised UI (native look and feel on both platforms)
- Android team keeps using Compose
- iOS team keeps using SwiftUI
- Both share the same models, networking, database, business logic

What’s Coming in the Series

Part 1: KMP From Zero (this article)         ✅ You are here
Part 2: Sharing Models & Business Logic       → Data classes, sealed types, validation
Part 3: Networking with Ktor                  → Replacing Retrofit, interceptors, error handling
Part 4: Database with SQLDelight              → Replacing Room, migrations, Flow
Part 5: ViewModel & State Management          → Shared ViewModels, MVI, Flow collection on iOS
Part 6: Dependency Injection with Koin        → Modules, platform binds, scoping
Part 7: Compose Multiplatform UI              → Shared UI (when it makes sense)
Part 8: Testing, CI/CD & Production           → Common tests, GitHub Actions, publishing

Conclusion

KMP lets you share 60–80% of your code between Android and iOS while keeping native UI on both platforms. It compiles to JVM bytecode for Android (exactly like your current code) and native ARM64 for iOS (no VM, no bridge). The expect/actual mechanism provides platform-specific behavior where needed, and the compiler guarantees you can't forget an implementation.

For Android developers, the learning curve is small — you already know Kotlin, coroutines, and Flow. The main shift is replacing Android-specific libraries (Retrofit → Ktor, Room → SQLDelight, SharedPreferences → DataStore) with multiplatform equivalents.

Part 1: https://medium.com/@ramadan123sayed/kmp-from-zero-what-kotlin-multiplatform-actually-is-how-it-compiles-to-native-code-project-a7b044e2b919

part2: https://medium.com/@ramadan123sayed/kmp-part-2-sharing-data-models-business-logic-and-validation-between-android-and-ios-domain-167c2f9fa738

part3: **https://medium.com/@ramadan123sayed/kmp-part-3-networking-with-ktor-replacing-retrofit-with-a-multiplatform-http-client-02139fb42d30

part4: https://medium.com/@ramadan123sayed/kmp-part-4-database-in-kotlin-multiplatform-room-multiplatform-vs-sqldelight-which-one-to-pick-7b99d5ad186c

part5: https://medium.com/@ramadan123sayed/kmp-part-5-sharing-viewmodels-and-state-management-between-android-and-ios-685fa85190eb

part6: https://medium.com/@ramadan123sayed/kmp-part-6-dependency-injection-with-koin-compile-time-annotations-via-ksp-runtime-dsl-migrating-a1c6955ae891

part7: https://medium.com/@ramadan123sayed/kmp-part-7-compose-multiplatform-sharing-ui-between-android-and-ios-when-shared-ui-makes-sense-vs-6b0239050acf

part8: https://medium.com/@ramadan123sayed/kmp-part-8-testing-ci-cd-and-production-writing-tests-in-commontest-that-run-on-both-platforms-1b6a44c7f54b

part9: https://medium.com/@ramadan123sayed/kmp-part-9-ios-interop-deep-dive-735057f0f310

part10: https://medium.com/@ramadan123sayed/kmp-part-10-migrating-an-existing-android-app-to-kotlin-multiplatform-9eeaf83368e7

part11: https://medium.com/@ramadan123sayed/kmp-part-11-platform-apis-in-kotlin-multiplatform-push-notifications-fcm-apns-camera-cd0d034a18e3**

Connect with Me on LinkedIn

Follow me on LinkedIn

Tags: #KotlinMultiplatform #KMP #Android #iOS #Kotlin #CrossPlatform #MobileDevelopment #SharedCode


메타데이터
post_id
a7b044e2b919
slug
kmp-from-zero-what-kotlin-multiplatform-actually-is-how-it-compiles-to-native-code-project-a7b044e2b919
url
https://medium.com/@ramadan123sayed/kmp-from-zero-what-kotlin-multiplatform-actually-is-how-it-compiles-to-native-code-project-a7b044e2b919
canonical_url
https://medium.com/@ramadan123sayed/kmp-from-zero-what-kotlin-multiplatform-actually-is-how-it-compiles-to-native-code-project-a7b044e2b919
author_url
https://medium.com/@ramadan123sayed
status
ok
fetched_at
2026-06-09 15:37:30