PPrepLearn
Progress · 0/6 phases

📦 Phase 1 — KMP Foundations (Days 1–12)

8 min read · Days 1–12 · Notion

Core insight: The KMP build system is the hardest part of getting started. Kotlin targets, source sets, Gradle dependencies, and expect/actual declarations must all be set up correctly before a single line of business logic runs. Invest here — everything else is just Kotlin.


📅 Day 1–2 — KMP Project Setup

# Use the official JetBrains KMP project generator
# https://kmp.jetbrains.com
# Select: Android + iOS + Desktop
# Opens in Android Studio with Kotlin Multiplatform plugin installed

Method 2: Manual from scratch

// settings.gradle.kts
pluginManagement {
    includeBuild("build-logic")
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}
 
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
 
rootProject.name = "MyKMPApp"
include(":shared", ":composeApp", ":androidApp")
# gradle/libs.versions.toml — single source of truth for all versions
[versions]
kotlin = "2.0.21"
agp = "8.5.2"
compose = "1.7.3"
ksp = "2.0.21-1.0.27"
ktor = "3.0.1"
sqldelight = "2.0.2"
koin = "4.0.0"
coil = "3.0.4"
voyager = "1.1.0"
kotlinxSerialization = "1.7.3"
kotlinxCoroutines = "1.9.0"
kotlinxDatetime = "0.6.1"
skie = "0.9.4"
turbine = "1.2.0"
 
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", 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" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
 
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
 
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
 
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" }
 
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
 
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
 
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
 
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
skie = { id = "co.touchlab.skie", version.ref = "skie" }
// shared/build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.library)
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.sqldelight)
    alias(libs.plugins.ksp)
    alias(libs.plugins.skie)
}
 
kotlin {
    androidTarget {
        compilations.all {
            kotlinOptions.jvmTarget = "17"
        }
    }
 
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "Shared"
            isStatic = true
        }
    }
 
    jvm("desktop")
 
    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.kotlinx.serialization.json)
            implementation(libs.kotlinx.datetime)
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.logging)
            implementation(libs.ktor.serialization.json)
            implementation(libs.ktor.content.negotiation)
            implementation(libs.ktor.client.auth)
            implementation(libs.sqldelight.runtime)
            implementation(libs.sqldelight.coroutines)
            implementation(libs.koin.core)
        }
        commonTest.dependencies {
            implementation(kotlin("test"))
            implementation(libs.turbine)
            implementation(libs.kotlinx.coroutines.core)  // for runTest
        }
        androidMain.dependencies {
            implementation(libs.ktor.client.okhttp)
            implementation(libs.sqldelight.android.driver)
            implementation(libs.koin.android)
        }
        val iosMain by getting {
            dependencies {
                implementation(libs.ktor.client.darwin)
                implementation(libs.sqldelight.native.driver)
            }
        }
        val desktopMain by getting {
            dependencies {
                implementation(libs.ktor.client.okhttp)
                implementation(libs.sqldelight.sqlite.driver)
            }
        }
    }
}
 
android {
    namespace = "com.myapp.shared"
    compileSdk = 35
    defaultConfig { minSdk = 26 }
}
 
sqldelight {
    databases {
        create("AppDatabase") {
            packageName.set("com.myapp.db")
        }
    }
}

📅 Day 3–4 — Source Sets & the Dependency Graph

Source set hierarchy:
 
commonMain
  ├── androidMain       (+ androidTest)
  ├── iosMain           (+ iosTest)
  │   ├── iosX64Main
  │   ├── iosArm64Main
  │   └── iosSimulatorArm64Main
  └── desktopMain       (JVM desktop)
// Intermediate source sets — share code between iOS targets
kotlin {
    sourceSets {
        // Share code between all Apple targets
        val appleMain by creating {
            dependsOn(commonMain.get())
        }
        val iosMain by getting {
            dependsOn(appleMain)
        }
        val macosMain by getting {
            dependsOn(appleMain)
        }
 
        // Share code between JVM targets (Android + Desktop)
        val jvmMain by creating {
            dependsOn(commonMain.get())
        }
        val androidMain by getting {
            dependsOn(jvmMain)
        }
        val desktopMain by getting {
            dependsOn(jvmMain)
        }
    }
}

📅 Day 5–7 — expect / actual — The Core Mechanism

What it solves

commonMain cannot import platform APIs. expect/actual defines an interface in commonMain and a platform-specific implementation in each target.

// commonMain: declare the contract
expect class Platform() {
    val name: String
    val version: String
    val isDebug: Boolean
}
 
expect fun randomUUID(): String
 
expect fun currentTimeMillis(): Long
 
expect fun createLogger(tag: String): AppLogger
 
interface AppLogger {
    fun debug(message: String)
    fun error(message: String, throwable: Throwable? = null)
}
// androidMain: Android implementation
actual class Platform actual constructor() {
    actual val name: String = "Android"
    actual val version: String = android.os.Build.VERSION.RELEASE
    actual val isDebug: Boolean = BuildConfig.DEBUG
}
 
actual fun randomUUID(): String = java.util.UUID.randomUUID().toString()
 
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
 
actual fun createLogger(tag: String): AppLogger = AndroidAppLogger(tag)
 
class AndroidAppLogger(private val tag: String) : AppLogger {
    override fun debug(message: String) = android.util.Log.d(tag, message)
    override fun error(message: String, throwable: Throwable?) {
        android.util.Log.e(tag, message, throwable)
    }
}
// iosMain: iOS implementation
actual class Platform actual constructor() {
    actual val name: String = UIDevice.currentDevice.systemName()
    actual val version: String = UIDevice.currentDevice.systemVersion
    actual val isDebug: Boolean = Platform.isSimulator
}
 
actual fun randomUUID(): String = NSUUID().UUIDString()
 
actual fun currentTimeMillis(): Long =
    (NSDate().timeIntervalSince1970 * 1000).toLong()
 
actual fun createLogger(tag: String): AppLogger = IosAppLogger(tag)
 
class IosAppLogger(private val tag: String) : AppLogger {
    override fun debug(message: String) = NSLog("[$tag] DEBUG: $message")
    override fun error(message: String, throwable: Throwable?) {
        NSLog("[$tag] ERROR: $message ${throwable?.message ?: ""}")
    }
}
// desktopMain: JVM desktop implementation
actual class Platform actual constructor() {
    actual val name: String = "Desktop (${System.getProperty("os.name")})"
    actual val version: String = System.getProperty("os.version") ?: "unknown"
    actual val isDebug: Boolean = System.getenv("DEBUG") == "true"
}
 
actual fun randomUUID(): String = java.util.UUID.randomUUID().toString()
actual fun currentTimeMillis(): Long = System.currentTimeMillis()

Common expect/actual patterns

// Settings / Preferences
expect class AppSettings {
    fun getString(key: String, defaultValue: String = ""): String
    fun putString(key: String, value: String)
    fun remove(key: String)
    fun clear()
}
// Android: SharedPreferences / DataStore
// iOS: NSUserDefaults
// Desktop: java.util.prefs.Preferences
 
// File system
expect fun getDocumentsDirectory(): String
expect fun getTemporaryDirectory(): String
 
// Network connectivity
expect class ConnectivityObserver() {
    val isOnline: StateFlow<Boolean>
}
 
// Crypto / Security
expect fun encryptString(value: String, key: String): String
expect fun decryptString(encrypted: String, key: String): String
// Android: Android Keystore
// iOS: CommonCrypto
// Desktop: javax.crypto

📅 Day 8–9 — Convention Plugins & Build-Logic

// build-logic/build.gradle.kts
plugins {
    `kotlin-dsl`
}
dependencies {
    compileOnly(libs.android.gradlePlugin)
    compileOnly(libs.kotlin.gradlePlugin)
    compileOnly(libs.compose.gradlePlugin)
}
 
// build-logic/src/main/kotlin/KmpLibraryConventionPlugin.kt
class KmpLibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) = with(target) {
        with(pluginManager) {
            apply("org.jetbrains.kotlin.multiplatform")
            apply("com.android.library")
        }
        extensions.configure<KotlinMultiplatformExtension> {
            androidTarget {
                compilations.all {
                    kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString()
                }
            }
            iosX64(); iosArm64(); iosSimulatorArm64()
            sourceSets.commonMain.dependencies {
                implementation(libs.findLibrary("kotlinx-coroutines-core").get())
                implementation(libs.findLibrary("koin-core").get())
            }
        }
        extensions.configure<LibraryExtension> {
            compileSdk = 35
            defaultConfig.minSdk = 26
        }
    }
}

📅 Day 10–12 — Phase 1 Project: KMP Skeleton App

Build and run on ALL platforms:

// commonMain — Platform info screen
class PlatformViewModel : ViewModel() {
    private val platform = Platform()
    val platformInfo: StateFlow<PlatformInfo> = MutableStateFlow(
        PlatformInfo(
            name = platform.name,
            version = platform.version,
            uuid = randomUUID(),
            time = currentTimeMillis()
        )
    ).asStateFlow()
}
 
data class PlatformInfo(
    val name: String,
    val version: String,
    val uuid: String,
    val time: Long
)

Deliverables:

  • ./gradlew :shared:testDebugUnitTest — all tests pass on JVM
  • ./gradlew :shared:iosSimulatorArm64Test — all tests pass on iOS sim
  • Android app runs in emulator showing platform info
  • iOS app runs in simulator showing platform info
  • Desktop app runs showing platform info
  • All three show different platform names from the SAME shared Platform() class

⚠️ Common mistakes Phase 1

Mistake 1

❌ Importing Android classes in commonMain****.

import android.util.Log in a commonMain file compiles for Android but fails for iOS/Desktop.

Use expect/actual for all platform APIs. The rule: if the IDE shows a red import in commonMain, it needs an abstraction.

Mistake 2

❌ Using @JvmStatic or @JvmOverloads in commonMain****.

JVM annotations don’t exist on iOS targets. Compilation fails.

Put JVM-specific annotations in androidMain or jvmMain source sets only.

Mistake 3

❌ Wrong Gradle source set naming. iosMain and val iosMain by getting — if targets aren’t declared before source sets are configured, you get “source set not found” errors.

Always declare targets (iosX64, iosArm64, etc.) BEFORE configuring source sets in the kotlin { } block.

Related