Droid Manual // Android Knowledge Base

Droid Manual

Kotlin Multiplatform for Mobile

October 30, 2026 · 8 min read

Introduction

Kotlin Multiplatform (KMP) lets you share business logic, data models, and cross-platform utilities between Android, iOS, and desktop applications while keeping each app's UI native. For Android engineers, KMP changes the way you think about modularization: instead of copying code into feature modules or maintaining parallel implementations, you write once in a commonMain source set and let each platform supply only what differs. This tutorial walks you through the full lifecycle — project layout, expect/actual declarations, shared module construction, and publishing a library to a Maven repository — using current Kotlin and Gradle practices as of early 2025.

What is Kotlin Multiplatform?

KMP is not a framework; it is a compiler strategy. The Kotlin compiler produces platform-specific artifacts from the same source code. On the JVM side (which includes Android), KMP compiles to regular .class files that run on the Dalvik/ART virtual machine. On Apple platforms, it compiles to LLVM bitcode via Kotlin/Native. Because the shared code is just Kotlin, Android developers already know the language; what changes is the build configuration.

The core value proposition is that networking code, database queries, serialization, authentication flows, and business rules can live in a single module. Platform-specific concerns — the actual HTTP client, disk I/O paths, biometric APIs — stay in their respective source sets. Gradle resolves these at compile time, producing one AAR for Android and one XCFramework for iOS.

Setting Up a KMP Project

You create a KMP project using the Gradle template system. The modern approach uses build.gradle.kts files and the Kotlin Multiplatform plugin published on Maven Central. Start by adding the plugin declaration to your root settings.gradle.kts or build.gradle.kts:

plugins {
    id("org.jetbrains.kotlin.multiplatform") version "2.0.21" apply false
    id("com.android.library") version "8.5.2" apply false
    id("maven-publish") apply false
}

This registers the KMP plugin and the Android library plugin without applying them to the root project itself. The apply false flag is important — it makes the plugins available as dependencies without executing them at the root level. You then reference these plugins inside your shared module's own build script. Make sure your gradle.properties contains these lines to avoid known performance regressions:

kotlin.mpp.stability.nowarn=true
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g

These properties enable parallel task execution, build caching, and suppress a deprecation warning. The JVM arguments prevent out-of-memory errors during native compilation, which is memory-intensive. When you create the shared module, Gradle will generate a default build.gradle.kts that mirrors the structure shown below.

Understanding Source Sets and the Project Structure

A KMP module organizes code into source sets arranged in a hierarchy. The commonMain source set is the root; it compiles for every target. Platform-specific source sets inherit from it and can override declarations. The default hierarchy for an Android + iOS project looks like this:

shared/
├── src/
│   ├── commonMain/         # Code shared across all targets
│   ├── androidMain/        # Android-specific overrides
│   ├── iosMain/            # iOS-specific overrides
│   └── jvmMain/            # JVM-wide code (also used by Android)
├── build.gradle.kts        # KMP module build script
└── settings.gradle.kts     # Optional: library module settings

The key insight is that commonMain cannot reference anything that is not part of the common subset of Kotlin. It cannot call android.os.Bundle, java.io.File, or any platform API. Those must live in androidMain, iosMain, or the appropriate platform source set. At runtime, Gradle merges the compiled outputs: the shared module's commonMain classes are packaged together with the platform-specific source set classes into a single artifact.

// shared/src/commonMain/kotlin/com/example/shared/NetworkClient.kt
package com.example.shared

import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json

expect class PlatformLogger() {
    fun log(tag: String, message: String)
}

class NetworkClient {
    private val client = HttpClient(OkHttp) {
        install(ContentNegotiation) {
            json(kotlinx.serialization.json.Json {
                prettyPrint = true
                isLenient = true
            })
        }
    }

    suspend fun fetchData(endpoint: String): String {
        PlatformLogger().log("Network", "Fetching $endpoint")
        return client.get(endpoint).bodyAsText()
    }
}

This example demonstrates the boundary between common and platform code. NetworkClient lives in commonMain because it uses only Kotlin Multiplatform libraries — Ktor's OkHttp engine runs on both Android and desktop JVM. The expect class PlatformLogger() declaration tells the compiler: "there is a class called PlatformLogger defined somewhere, but I don't know the implementation yet." Each platform source set must provide an actual declaration matching that signature. If any platform is missing the actual, compilation fails with a clear error pointing to the unresolved expect.

expect/actual Declarations

The expect/actual mechanism is how KMP handles platform differences while preserving type safety. An expect declaration in commonMain is a promise that a matching actual exists on every compiled target. The compiler enforces this constraint. You use it for three categories of code: platform services (logger, file system, biometric auth), dependency injection wiring, and any API that differs between platforms.

// shared/src/androidMain/kotlin/com/example/shared/PlatformLogger.kt
package com.example.shared

import android.util.Log

actual class PlatformLogger {
    actual fun log(tag: String, message: String) {
        Log.d(tag, message)
    }
}
// shared/src/commonMain/kotlin/com/example/shared/Storage.kt
package com.example.shared

expect fun getApplicationDocumentsPath(): String
// shared/src/androidMain/kotlin/com/example/shared/Storage.android.kt
package com.example.shared

import android.content.Context
import java.io.File

actual fun getApplicationDocumentsPath(): String {
    // This function requires a Context, so the actual
    // implementation in androidMain receives it as a parameter.
    // In a real app you would inject Context through DI.
    return File("/data/data/com.example.app/files/documents").absolutePath
}
// shared/build.gradle.kts
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile

plugins {
    id("org.jetbrains.kotlin.multiplatform")
    id("com.android.library")
    id("maven-publish")
    kotlin("plugin.serialization") version "2.0.21"
}

kotlin {
    androidTarget {
        compilations["main"].compileTaskProvider.configure {
            compilerOptions {
                jvmTarget.set(JvmTarget.JVM_11)
            }
        }
    }

    listOf(
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "shared"
            isStatic = true
        }
    }

    sourceSets {
        commonMain {
            dependencies {
                implementation("io.ktor:ktor-client-core:2.3.12")
                implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
                implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
            }
        }
        androidMain {
            dependencies {
                implementation("io.ktor:ktor-client-okhttp:2.3.12")
                implementation("androidx.core:core-ktx:1.15.0")
            }
        }
        iosMain {
            dependencies {
                implementation("io.ktor:ktor-client-darwin:2.3.12")
            }
        }
    }
}

The build.gradle.kts ties everything together. The androidTarget block configures the Android compilation to target JVM 11, which is the minimum supported by most Android apps. The iosTarget calls create separate compilation tasks for ARM64 devices and the simulator; both produce static frameworks because dynamic frameworks require additional Xcode configuration. Source set dependencies declare which Ktor engines to use on each platform — okhttp for Android and darwin for iOS — while commonMain holds only the cross-platform dependencies. Gradle resolves the correct engine at compile time based on the target being built.

Publishing a KMP Library to Maven

Once your shared module compiles cleanly, the next step is making it available to other projects. KMP supports publishing to any Maven-compatible repository, including JFrog Artifactory, Sonatype OSSRH, or a private Nexus instance. The maven-publish plugin generates publication descriptors for every target variant you configure.

// shared/build.gradle (Groovy DSL alternative)
apply plugin: 'org.jetbrains.kotlin.multiplatform'
apply plugin: 'com.android.library'
apply plugin: 'maven-publish'

publishing {
    repositories {
        maven {
            url = layout.projectDirectory.dir("local-maven").asFile.toURI().toString()
            // For production, replace with:
            // url = uri("https://maven.pkg.github.com/your-org/your-repo")
            // credentials {
            //     username = project.findProperty("gpr.user") ?: System.getenv("GITHUB_USERNAME")
            //     password = project.findProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN")
            // }
        }
    }
    publications {
        withType<MavenPublication> {
            artifactId = project.archivesBaseName
            pom {
                name.set("Shared KMP Library")
                description.set("Cross-platform shared module for Android and iOS")
                url.set("https://example.com/shared")
                licenses {
                    license {
                        name.set("MIT")
                        url.set("https://opensource.org/licenses/MIT")
                    }
                }
                developers {
                    developer {
                        id.set("your-id")
                        name.set("Your Name")
                        email.set("[email protected]")
                    }
                }
                scm {
                    connection.set("scm:git:git://github.com/your-org/your-repo.git")
                    url.set("https://github.com/your-org/your-repo")
                }
            }
        }
    }
}

Replace the withType<MavenPublication> block with Groovy DSL syntax if you are using build.gradle instead of build.gradle.kts. The publication block tells Gradle what metadata to include in the generated POM file. Each Kotlin Multiplatform target produces its own publication variant — androidRelease, iosArm64, iosSimulatorArm64 — and Gradle assembles them into a single multi-platform artifact that consuming projects resolve automatically. To publish, run ./gradlew publishToMavenLocal for local testing, then ./gradlew publish for the target repository.

When another project consumes this library, it declares the Maven repository in its settings.gradle.kts and adds the dependency with a group-artifact-version coordinate. Gradle resolves the correct binary for the current target and links the shared sources accordingly.

Common Pitfalls

KMP simplifies cross-platform development, but several traps catch developers off guard. The first is the expect/actual visibility mismatch: an expect declaration must have the same visibility in every actual implementation. If you mark the expect as internal, every actual must also be internal. Mixing public and internal across platforms produces a compile-time error.

The second pitfall involves serialization. kotlinx.serialization works in commonMain, but you must add the plugin to every target's compilation. If you only apply it in the root or in one target, the @Serializable annotation will not generate the necessary serializers for other targets. Always verify that kotlin("plugin.serialization") appears in every module that needs it.

The third issue is dependency version alignment. Ktor, kotlinx.coroutines, and kotlinx.serialization all follow independent release cadences. Pinning mismatched versions causes resolution failures or runtime crashes due to ABI incompatibilities. Use a libs.versions.toml file to centralize all version declarations and ensure every KMP target reads from the same catalog.

The fourth pitfall is native memory limits on iOS. Kotlin/Native compiles to a static framework that links against system libraries. Large shared modules increase the iOS binary size and can hit linker limits on older device architectures. Profile your framework size with nm and consider splitting large modules into smaller, focused libraries.

The fifth pitfall is test infrastructure. Unit tests in commonTest run on the JVM target by default. If your tests depend on platform APIs, you must annotate them with @Ignore for non-JVM targets or restructure them into platform-specific test source sets.

Conclusion

Kotlin Multiplatform transforms how Android teams approach shared code. By placing business logic in commonMain, using expect/actual to isolate platform concerns, and publishing to Maven for centralized consumption, you eliminate duplicated implementations and reduce the surface area for bugs. The trade-offs are real — build times increase, the common subset of Kotlin has limitations, and the ecosystem is still maturing — but for teams shipping to both Android and iOS, the long-term gains in code consistency and developer velocity are substantial. Start small: move a single use case into a shared module, verify the compile pipeline end-to-end, and iterate from there.