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
| Tool | Required | Purpose |
|---|---|---|
| Android Studio | Yes | IDE for KMP development (latest stable) |
| Xcode | Yes (Mac only) | Builds and runs iOS apps |
| JDK 17+ | Yes | Kotlin compiler depends on it |
| Mac computer | For iOS | Apple requires Xcode, which only runs on macOS |
| CocoaPods | Optional | Some 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.
- Open Android Studio
- Go to Settings → Plugins → Marketplace
- Search for “Kotlin Multiplatform”
- 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
Using the KMP Wizard (Recommended)
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:
- File → New → New Project
- Select Kotlin Multiplatform from the template list
- Set your project name and package
- Choose platforms (Android + iOS)
- 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:
expectin commonMain → “I need this”actualin androidMain → “Here is the Android version”actualin 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
- In Android Studio, select the composeApp run configuration from the toolbar dropdown
- Select an Android emulator or connected device
- 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):
- Select the iosApp run configuration from the dropdown
- Select an iOS simulator (e.g., iPhone 16)
- 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:
- Open
iosApp/iosApp.xcodeprojin Xcode - Select a simulator from the device dropdown
- 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 compilationiosX64(),iosArm64(),iosSimulatorArm64()— three iOS targets for full device/simulator coveragesourceSets— where you declare dependencies for each platform- Dependencies in
commonMainautomatically 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
| Kotlin | Swift |
|---|---|
data class User(...) | class User |
object Calculator | Calculator.shared (singleton) |
fun greet(): String | func greet() -> String |
suspend fun getUsers() | Async callback (or async with wrapper) |
sealed interface | Protocol + classes |
enum class | KotlinEnum |
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
| Action | Command |
|---|---|
| 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 environment | kdoctor |
Source Code
The base project for this tutorial series is on GitHub:
Related Tutorials
- KMP Tutorial #1: What is Kotlin Multiplatform? — understand KMP before setting up
- Jetpack Compose Tutorial Series — learn Compose (the UI framework used in KMP apps)
- Cursor vs Claude Code vs Copilot — AI tools that help with KMP development
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.