In the previous tutorial, we learned what KMP is and why it matters. Now let’s get our hands dirty — create a real project, understand every file in it, write shared code, and run it on both Android and iOS.

This is a long tutorial. Take your time. By the end, you will have a working cross-platform app and understand exactly how every piece fits together.

Part 1: Setting Up Your Environment

What You Need

ToolRequiredPurpose
Android StudioYesIDE for KMP development (latest stable)
XcodeYes (Mac only)Builds and runs iOS apps
JDK 17+YesKotlin compiler depends on it
Mac computerFor iOSApple requires Xcode, which only runs on macOS
CocoaPodsOptionalSome KMP libraries need it for iOS

If you are on Windows or Linux, you can still write shared code and build the Android app. But you cannot build or run iOS. There is no way around this — Apple controls iOS builds.

Step 1: Install Android Studio

Download the latest stable version from developer.android.com/studio. Install it and make sure you have:

  • Android SDK (API 34 or newer)
  • An Android emulator (or physical device)

Step 2: Install the Kotlin Multiplatform Plugin

This is the step most tutorials skip — and then you wonder why you don’t see iOS run configurations.

  1. Open Android Studio
  2. Go to Settings → Plugins → Marketplace
  3. Search for “Kotlin Multiplatform”
  4. Install it and restart Android Studio

Without this plugin, Android Studio doesn’t know how to handle KMP projects.

Step 3: Install Xcode (Mac Only)

Download Xcode from the Mac App Store. After installing:

# Install command line tools
sudo xcode-select --install

# Accept the license
sudo xcodebuild -license accept

Open Xcode at least once so it installs additional components.

Step 4: Verify Everything with kdoctor

kdoctor is a command-line tool that checks your entire KMP environment at once:

# Install kdoctor
brew install kdoctor

# Run the check
kdoctor

It checks:

  • Java version
  • Android Studio and SDK
  • Xcode and command line tools
  • CocoaPods (if installed)
  • Kotlin plugin version

Fix any issues it reports before continuing. Green checkmarks mean you are ready.

If you don’t have Homebrew, check manually:

java -version           # Need 17+
xcode-select --version  # Xcode CLT installed

Part 2: Creating the Project

The KMP Wizard at kmp.jetbrains.com is the easiest way to create a project. No Gradle configuration headaches.

Step 1: Go to kmp.jetbrains.com

Step 2: Fill in the project details:

  • Project name: MyKmpApp (or whatever you like)
  • Project ID: com.kemalcodes.mykmpapp

Step 3: Choose your platforms:

  • Check Android
  • Check iOS
  • Optionally check Desktop or Web

Step 4: Choose UI sharing:

  • “Share UI with Compose Multiplatform” — one Compose UI for all platforms. Good for learning and prototyping.
  • “Do not share UI” — native UI on each platform (Compose on Android, SwiftUI on iOS). Better for production apps that need platform-specific look and feel.

For this tutorial, choose “Share UI with Compose Multiplatform” so we can focus on shared code without writing separate Swift code.

Step 5: Click Download, unzip the file, and open the folder in Android Studio.

Using Android Studio

Alternatively, if your Android Studio version supports it:

  1. File → New → New Project
  2. Select Kotlin Multiplatform from the template list
  3. Set your project name and package
  4. Choose platforms (Android + iOS)
  5. Click Finish

First Gradle Sync

After opening the project, Android Studio will start syncing Gradle. This downloads all dependencies.

The first sync takes 5-10 minutes. This is normal. KMP projects have more dependencies than regular Android projects because they include the Kotlin/Native compiler, iOS framework generators, and multiplatform libraries.

If the sync fails, check:

  • Internet connection
  • Proxy settings
  • JDK version (needs 17+)

Part 3: Understanding the Project Structure

After the project opens, you will see something like this:

MyKmpApp/
├── shared/                         ← THE SHARED MODULE
│   ├── build.gradle.kts            ← Configures targets and dependencies
│   └── src/
│       ├── commonMain/             ← Code that runs EVERYWHERE
│       │   └── kotlin/
│       │       ├── Platform.kt     ← expect declaration
│       │       └── Greeting.kt    ← shared business logic
│       ├── commonTest/             ← Tests for shared code
│       │   └── kotlin/
│       ├── androidMain/            ← Android-specific code
│       │   └── kotlin/
│       │       └── Platform.android.kt  ← actual for Android
│       └── iosMain/                ← iOS-specific code
│           └── kotlin/
│               └── Platform.ios.kt      ← actual for iOS
├── composeApp/                     ← THE APP MODULE
│   ├── build.gradle.kts
│   └── src/
│       ├── commonMain/             ← Shared Compose UI
│       │   └── kotlin/
│       │       └── App.kt         ← Main app composable
│       ├── androidMain/            ← Android entry point
│       │   └── kotlin/
│       │       └── MainActivity.kt
│       │   └── AndroidManifest.xml
│       └── iosMain/                ← iOS entry point
├── iosApp/                         ← iOS XCODE PROJECT
│   └── iosApp/
│       ├── ContentView.swift       ← SwiftUI wrapper
│       └── iOSApp.swift            ← iOS app entry point
├── build.gradle.kts                ← Root build config
├── settings.gradle.kts             ← Module declarations
├── gradle.properties               ← Project properties
└── gradle/
    └── libs.versions.toml          ← Dependency versions

That is a lot of folders. Let me explain each one.

shared/ — The Heart of Your KMP Project

This is where your shared Kotlin code lives. Everything in commonMain runs on every platform. Everything in androidMain runs only on Android. Everything in iosMain runs only on iOS.

The rule is simple:

  • Can your code run on any platform? → Put it in commonMain
  • Does it need Android APIs? → Put it in androidMain
  • Does it need Apple APIs? → Put it in iosMain

composeApp/ — The App That Uses Shared Code

If you chose “Share UI with Compose Multiplatform,” this module contains the Compose UI that runs on all platforms. It has its own commonMain (shared UI), androidMain (Android entry point), and iosMain (iOS entry point).

If you chose “Do not share UI,” this would be androidApp/ instead, containing only the Android app.

iosApp/ — The Xcode Project

This is the iOS app project. Even with Compose Multiplatform, you need an Xcode project as the entry point for iOS. The ContentView.swift file wraps your Compose UI for iOS.

Part 4: Understanding the Default Code

The expect/actual Pattern

Open shared/src/commonMain/kotlin/Platform.kt:

// commonMain — declares WHAT we need
// "expect" means: each platform must provide its own implementation
expect fun getPlatformName(): String

This says: “I need a function called getPlatformName that returns a String. Each platform decides what it returns.”

Open shared/src/androidMain/kotlin/Platform.android.kt:

// androidMain — provides the Android answer
actual fun getPlatformName(): String = "Android ${android.os.Build.VERSION.SDK_INT}"

Open shared/src/iosMain/kotlin/Platform.ios.kt:

// iosMain — provides the iOS answer
import platform.UIKit.UIDevice

actual fun getPlatformName(): String = UIDevice.currentDevice.systemName() +
    " " + UIDevice.currentDevice.systemVersion

Notice:

  • expect in commonMain → “I need this”
  • actual in androidMain → “Here is the Android version”
  • actual in iosMain → “Here is the iOS version”

The compiler ensures every expect has a matching actual on every platform. If you forget one, the project won’t compile. No runtime crashes from missing implementations.

The Greeting Class

Open shared/src/commonMain/kotlin/Greeting.kt:

class Greeting {
    fun greet(): String {
        return "Hello from ${getPlatformName()}!"
    }
}

This is shared code. It calls getPlatformName() which returns different values on each platform:

  • On Android: “Hello from Android 35!”
  • On iOS: “Hello from iOS 18.2!”

Same code, different behavior. This is the power of KMP.

Part 5: Running the App

Running on Android

  1. In Android Studio, select the composeApp run configuration from the toolbar dropdown
  2. Select an Android emulator or connected device
  3. Click the Run button (green triangle)

Or from terminal:

./gradlew :composeApp:assembleDebug
./gradlew :composeApp:installDebug

You should see a screen with the greeting message.

Running on iOS

From Android Studio (with KMP Plugin):

  1. Select the iosApp run configuration from the dropdown
  2. Select an iOS simulator (e.g., iPhone 16)
  3. Click Run

The first iOS build takes longer than Android because it needs to compile the Kotlin/Native framework. Subsequent builds are faster.

From Xcode:

  1. Open iosApp/iosApp.xcodeproj in Xcode
  2. Select a simulator from the device dropdown
  3. Click the Run button (or Cmd+R)

Common iOS build issue: If you get “framework not found Shared”, run:

./gradlew :shared:linkDebugFrameworkIosSimulatorArm64

This builds the shared framework that iOS needs.

Running on Desktop (If Selected in Wizard)

./gradlew :composeApp:run

A desktop window opens with the same Compose UI. Same code, third platform.

Part 6: Writing Your First Real Shared Code

Let’s go beyond the greeting and write something useful.

Shared Data Models

Create shared/src/commonMain/kotlin/models/User.kt:

package com.kemalcodes.mykmpapp.models

// This data class works on Android, iOS, Desktop, and Web
// Define once, use everywhere

data class User(
    val id: String,
    val name: String,
    val email: String,
    val joinedYear: Int = 2026
) {
    // Validation logic — shared across all platforms
    fun isValid(): Boolean {
        return name.isNotBlank() &&
            email.contains("@") &&
            email.contains(".")
    }

    // Formatting logic — same on every platform
    fun displayName(): String {
        return if (name.isNotBlank()) name
        else email.substringBefore("@")
    }

    // Business logic — consistent everywhere
    fun membershipYears(): Int {
        return 2026 - joinedYear
    }
}

This User class works identically on Android and iOS. Validation rules, formatting, business logic — all written once. If you fix a bug in isValid(), it is fixed on both platforms simultaneously.

Shared Utility Functions

Create shared/src/commonMain/kotlin/utils/StringUtils.kt:

package com.kemalcodes.mykmpapp.utils

// Utility functions available on all platforms

object StringUtils {

    fun capitalizeWords(text: String): String {
        return text.split(" ")
            .joinToString(" ") { word ->
                word.replaceFirstChar { it.uppercase() }
            }
    }

    fun truncate(text: String, maxLength: Int, suffix: String = "..."): String {
        return if (text.length <= maxLength) text
        else text.take(maxLength - suffix.length) + suffix
    }

    fun isValidEmail(email: String): Boolean {
        return email.contains("@") &&
            email.contains(".") &&
            email.indexOf("@") < email.lastIndexOf(".")
    }

    fun slugify(text: String): String {
        return text.lowercase()
            .replace(Regex("[^a-z0-9\\s-]"), "")
            .replace(Regex("\\s+"), "-")
            .trim('-')
    }
}

Shared Business Logic

Create shared/src/commonMain/kotlin/repository/UserRepository.kt:

package com.kemalcodes.mykmpapp.repository

import com.kemalcodes.mykmpapp.models.User

// Repository pattern — shared data access logic
// In a real app, this would call an API or database
// For now, we use in-memory data

class UserRepository {

    private val users = mutableListOf(
        User("1", "Alex", "alex@example.com", 2023),
        User("2", "Sam", "sam@example.com", 2024),
        User("3", "Jordan", "jordan@example.com", 2025),
        User("4", "Taylor", "taylor@example.com", 2026),
    )

    fun getAllUsers(): List<User> = users.toList()

    fun getUserById(id: String): User? = users.find { it.id == id }

    fun searchUsers(query: String): List<User> {
        val lowerQuery = query.lowercase()
        return users.filter {
            it.name.lowercase().contains(lowerQuery) ||
                it.email.lowercase().contains(lowerQuery)
        }
    }

    fun addUser(user: User): Boolean {
        if (!user.isValid()) return false
        if (users.any { it.email == user.email }) return false
        users.add(user)
        return true
    }

    fun deleteUser(id: String): Boolean {
        return users.removeAll { it.id == id }
    }

    fun getUserCount(): Int = users.size
}

This entire repository — data models, validation, search, CRUD operations — is shared code. Zero duplication between platforms.

Using Shared Code in the App

Now use this shared code in the Compose UI. Open or create composeApp/src/commonMain/kotlin/App.kt:

@Composable
fun App() {
    val repository = remember { UserRepository() }
    var users by remember { mutableStateOf(repository.getAllUsers()) }
    var searchQuery by remember { mutableStateOf("") }

    MaterialTheme {
        Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
            Text(
                "Users (${repository.getUserCount()})",
                style = MaterialTheme.typography.headlineMedium
            )

            // Search bar
            OutlinedTextField(
                value = searchQuery,
                onValueChange = {
                    searchQuery = it
                    users = if (it.isEmpty()) repository.getAllUsers()
                    else repository.searchUsers(it)
                },
                label = { Text("Search") },
                modifier = Modifier.fillMaxWidth()
            )

            Spacer(modifier = Modifier.height(16.dp))

            // User list
            LazyColumn {
                items(users) { user ->
                    Column(modifier = Modifier.padding(vertical = 8.dp)) {
                        Text(user.displayName(), fontWeight = FontWeight.Bold)
                        Text(user.email, fontSize = 14.sp)
                        Text(
                            "Member for ${user.membershipYears()} year(s)",
                            fontSize = 12.sp,
                            color = Color.Gray
                        )
                    }
                }
            }
        }
    }
}

This Compose code is ALSO shared — it runs on Android, iOS, and Desktop. The UserRepository, User data class, displayName(), membershipYears() — all come from the shared module.

Part 7: Understanding Gradle Configuration

settings.gradle.kts

// Declares which modules exist in the project
rootProject.name = "MyKmpApp"
include(":shared")        // The shared Kotlin module
include(":composeApp")    // The app module (Android/iOS/Desktop)

shared/build.gradle.kts

This is the most important build file. It configures which platforms to target:

kotlin {
    // Android target
    androidTarget {
        compilations.all {
            kotlinOptions {
                jvmTarget = "17"
            }
        }
    }

    // iOS targets — need all three for full compatibility
    listOf(
        iosX64(),              // Intel Mac simulators
        iosArm64(),            // Real iOS devices
        iosSimulatorArm64()    // Apple Silicon Mac simulators
    ).forEach {
        it.binaries.framework {
            baseName = "Shared"   // Name of the iOS framework
            isStatic = true
        }
    }

    // Source sets — where dependencies go
    sourceSets {
        commonMain.dependencies {
            // Dependencies for ALL platforms
            // e.g., kotlinx-coroutines, kotlinx-serialization
        }

        androidMain.dependencies {
            // Android-only dependencies
        }

        iosMain.dependencies {
            // iOS-only dependencies
        }

        commonTest.dependencies {
            // Test dependencies for shared code
            implementation(kotlin("test"))
        }
    }
}

Key concepts:

  • androidTarget() — enables Android compilation
  • iosX64(), iosArm64(), iosSimulatorArm64() — three iOS targets for full device/simulator coverage
  • sourceSets — where you declare dependencies for each platform
  • Dependencies in commonMain automatically propagate to all platforms

gradle.properties

# Memory for Gradle daemon
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

# Kotlin code style
kotlin.code.style=official

# Enable Compose Multiplatform
kotlin.mpp.applyDefaultHierarchyTemplate=true

Part 8: How iOS Sees Your Kotlin Code

When you build for iOS, KMP compiles your shared Kotlin code into a native Objective-C framework called Shared.framework. Swift can import this framework directly.

What Kotlin Becomes in Swift

KotlinSwift
data class User(...)class User
object CalculatorCalculator.shared (singleton)
fun greet(): Stringfunc greet() -> String
suspend fun getUsers()Async callback (or async with wrapper)
sealed interfaceProtocol + classes
enum classKotlinEnum
List<User>[User] (NSArray)

Example — Kotlin object in Swift:

// Kotlin (shared/commonMain)
object Calculator {
    fun add(a: Double, b: Double): Double = a + b
}
// Swift (iosApp)
import Shared

let result = Calculator.shared.add(a: 10.0, b: 5.0)
print(result) // 15.0

The .shared is how Kotlin object singletons appear in Swift. It takes a few minutes to get used to, but it works seamlessly.

Part 9: Writing and Running Tests

Shared code can have shared tests. Create shared/src/commonTest/kotlin/UserTest.kt:

package com.kemalcodes.mykmpapp

import com.kemalcodes.mykmpapp.models.User
import com.kemalcodes.mykmpapp.utils.StringUtils
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class UserTest {

    @Test
    fun validUserShouldReturnTrue() {
        val user = User("1", "Alex", "alex@example.com")
        assertTrue(user.isValid())
    }

    @Test
    fun invalidEmailShouldReturnFalse() {
        val user = User("1", "Alex", "not-an-email")
        assertFalse(user.isValid())
    }

    @Test
    fun emptyNameShouldReturnFalse() {
        val user = User("1", "", "alex@example.com")
        assertFalse(user.isValid())
    }

    @Test
    fun displayNameShouldUseEmailWhenNameIsBlank() {
        val user = User("1", "", "alex@example.com")
        assertEquals("alex", user.displayName())
    }

    @Test
    fun membershipYearsShouldCalculateCorrectly() {
        val user = User("1", "Alex", "alex@example.com", 2024)
        assertEquals(2, user.membershipYears())
    }
}

class StringUtilsTest {

    @Test
    fun capitalizeWordsShouldWork() {
        assertEquals("Hello World", StringUtils.capitalizeWords("hello world"))
    }

    @Test
    fun truncateShouldAddSuffix() {
        assertEquals("Hello...", StringUtils.truncate("Hello World", 8))
    }

    @Test
    fun isValidEmailShouldWork() {
        assertTrue(StringUtils.isValidEmail("alex@example.com"))
        assertFalse(StringUtils.isValidEmail("not-email"))
        assertFalse(StringUtils.isValidEmail("@example.com"))
    }

    @Test
    fun slugifyShouldWork() {
        assertEquals("hello-world", StringUtils.slugify("Hello World!"))
    }
}

Run the tests:

# Run tests for all platforms
./gradlew :shared:allTests

# Run tests for Android only
./gradlew :shared:testDebugUnitTest

# Run tests for iOS only
./gradlew :shared:iosSimulatorArm64Test

These tests run on both Android and iOS — same test code, verifying the shared logic works correctly on each platform.

Common Mistakes

Mistake 1: Forgetting the KMP Plugin

If you don’t install the Kotlin Multiplatform plugin in Android Studio, you won’t see:

  • iOS run configurations
  • KMP project templates
  • Proper code navigation between source sets

Fix: Settings → Plugins → search “Kotlin Multiplatform” → Install.

Mistake 2: Using Android APIs in commonMain

// BAD — this crashes on iOS because Android APIs don't exist there
// shared/src/commonMain/kotlin/
import android.util.Log  // This won't compile for iOS!

fun logMessage(msg: String) {
    Log.d("TAG", msg)  // Android-only API
}
// GOOD — use expect/actual for platform-specific APIs
// shared/src/commonMain/kotlin/
expect fun logMessage(msg: String)

// shared/src/androidMain/kotlin/
actual fun logMessage(msg: String) {
    android.util.Log.d("TAG", msg)
}

// shared/src/iosMain/kotlin/
actual fun logMessage(msg: String) {
    platform.Foundation.NSLog(msg)
}

Mistake 3: Not Understanding Source Set Visibility

commonMain can see: commonMain only
androidMain can see: commonMain + androidMain
iosMain can see: commonMain + iosMain

commonMain CANNOT see: androidMain or iosMain

If you try to use Android code in commonMain, it won’t compile. Use expect/actual to bridge between them.

Mistake 4: Huge First Gradle Sync

The first sync downloads ~500MB of dependencies (Kotlin/Native compiler, iOS toolchains, etc.). Don’t panic. Use a good internet connection and wait.

Mistake 5: Not Building the iOS Framework

If Xcode can’t find your shared code:

# Build the framework manually
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64

This generates the Shared.framework that Xcode needs.

Quick Reference

ActionCommand
Build Android./gradlew :composeApp:assembleDebug
Build iOS framework./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
Run tests (all)./gradlew :shared:allTests
Run tests (Android)./gradlew :shared:testDebugUnitTest
Run Desktop app./gradlew :composeApp:run
Clean build./gradlew clean
Check environmentkdoctor

Source Code

The base project for this tutorial series is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will dive deeper into the KMP project structure — understanding how source sets work, dependency management across platforms, and advanced expect/actual patterns with real-world examples.

See you there.