Gradle Configuration, the Multiplatform DSL and Compatibility
|
This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Every Kotlin Multiplatform (KMP) project is, first of all, a Gradle build. The Kotlin Multiplatform Gradle plugin
(org.jetbrains.kotlin.multiplatform) adds one extension, kotlin \{ … \}, to each shared module, and everything
the earlier pages described — targets, compilations, source sets and the iOS framework — is configured inside it.
This page is the working reference for that build: the files you create, the DSL you write in them, the flags that
keep them fast, and the version matrix that decides whether a given combination of tools works at all.
The baseline is Kotlin 2.4.20 (Kotlin Multiplatform Gradle plugin 2.4.20), Compose Multiplatform (CMP)
1.12.1, Android Gradle Plugin (AGP) 9.4.1 and Xcode 26.4, with Gradle 9.8.0 as the wrapper version.
Examples are slices of TrailMate Everywhere, the multiplatform version of the Android section’s TrailMate
app (package com.example.trailmate), with the modules sharedLogic, sharedUI, androidApp, desktopApp and
webApp, plus the Xcode project iosApp. For the module layout see
Project Structure and Source Sets; for how a
native Android build looks without KMP see Android Project
Structure and Gradle. The Kotlin language itself, and Gradle basics in the Kotlin context, are covered in
Kotlin Build and Tooling and are not repeated here.
settings.gradle.kts
The settings script is evaluated before any module and decides where Gradle looks for plugins and libraries.
Centralizing repositories in dependencyResolutionManagement with FAIL_ON_PROJECT_REPOS means a module cannot
quietly add its own repository, which keeps resolution reproducible.
// settings.gradle.kts
rootProject.name = "TrailMateEverywhere"
// Gives modules type-safe project accessors: projects.sharedLogic instead of project(":sharedLogic")
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
repositories {
google {
content {
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
includeGroupAndSubgroups("androidx")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google {
content {
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
includeGroupAndSubgroups("androidx")
}
}
mavenCentral()
}
}
include(":sharedLogic")
include(":sharedUI")
include(":androidApp")
include(":desktopApp")
include(":webApp")
The Kotlin Multiplatform and Compose Multiplatform plugins come from the Gradle Plugin Portal and Maven Central, AGP
and AndroidX libraries from Google Maven. JetBrains' Compose artifacts (for example
org.jetbrains.compose.components:components-resources) are published to Maven Central, so no extra repository is
needed in 2026. A common mistake is to declare google() without the content filter and then wonder why a
multiplatform library resolves from the wrong place; filtering Google’s repository to the groups it owns avoids it.
The version catalog
Legacy KMP builds kept versions in a buildSrc object named Versions. The replacement is a version catalog:
a TOML file that Gradle turns into type-safe accessors (libs.ktor.client.core, libs.plugins.kotlinMultiplatform).
It is the single source of truth for versions, and Android Studio, IntelliJ IDEA, Renovate and Dependabot all
understand it.
# gradle/libs.versions.toml
[versions]
kotlin = "2.4.20"
agp = "9.4.1"
compose-multiplatform = "1.12.1"
coroutines = "1.11.0"
serialization = "1.11.0"
datetime = "0.8.0"
ktor = "3.6.0"
sqldelight = "2.4.0"
koin = "4.2.2"
androidx-lifecycle = "2.11.0"
kermit = "2.2.0"
coil = "3.6.3"
turbine = "1.2.1"
mokkery = "3.5.0"
vanniktech-publish = "0.37.0"
dokka = "2.2.0"
[libraries]
# kotlinx
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
# Ktor
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-kotlinx-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" }
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
# SQLDelight
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 and lifecycle
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
# Misc
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
turbine = { module = "app.cash.turbine:turbine", version = "1.2.1" }
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
vanniktechPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech-publish" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
See Kotlin docs — Configure a Gradle project and Kotlin docs — Best practices for Kotlin Gradle projects.
Three details worth noticing. The Kotlin, Compose-compiler and serialization plugins all use version.ref = "kotlin":
the Compose compiler plugin (org.jetbrains.kotlin.plugin.compose) is versioned with Kotlin, while the
org.jetbrains.compose Gradle plugin carries the CMP version. Both com.android.application and
com.android.kotlin.multiplatform.library share the agp version, because they are two entry points of one plugin
family. Finally, hyphens in alias names become dots in the accessor, so ktor-client-core is libs.ktor.client.core.
The root build script applies each plugin once with apply false, so the modules do not each load their own copy:
// build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.kotlinSerialization) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
alias(libs.plugins.sqldelight) apply false
}
The kotlin block
A module’s build.gradle.kts applies the plugins and then configures the kotlin \{ \} extension. The top-level
blocks documented in the DSL reference are:
| Block | What it configures |
|---|---|
|
Declares one target. A target compiles, tests and packages code for one platform. |
|
The collection of every declared target, for code that must loop over them. |
|
Configures predefined source sets and declares custom ones. |
|
Extension-level compiler options, used as defaults for every target and shared source set. |
|
Common dependencies. Experimental; see below. |
Declaring targets
// sharedLogic/build.gradle.kts
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.sqldelight)
}
kotlin {
// Android: the single-variant plugin is configured here, not in an android {} block at the top level
android {
namespace = "com.example.trailmate.shared"
compileSdk = 37
minSdk = 26
}
// Desktop: a JVM target with a custom name
jvm("desktop") {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
// iOS: explicit targets, no ios() shortcut
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "SharedLogic"
isStatic = true
}
}
// Web: Kotlin/Wasm (Beta)
wasmJs { browser() }
}
Rules the DSL enforces: a project can declare one Android target per Gradle subproject; jvm("desktop") gives the
target a name (and so the source set desktopMain and the task prefix desktop) while a plain jvm() uses jvm;
a target the current host cannot build, such as iosArm64() on Linux, is ignored and not published, so the same
build script is valid on every machine. Kotlin/Wasm has the target wasmJs for JavaScript runtimes and wasmWasi for
the WASI interface; js() selects browser() or nodejs(). Which Kotlin/Native targets exist, and their tiers,
are covered in How KMP Compiles.
Type-safe source sets
Source sets are accessed through generated accessors named after the source set. This replaced the older
val commonMain by getting pattern, which is legacy and unnecessary.
// sharedLogic/build.gradle.kts (continued)
kotlin {
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.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines)
implementation(libs.koin.core)
implementation(libs.kermit)
api(libs.androidx.lifecycle.viewmodel) // part of the public API: exposed to iOS
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
implementation(libs.turbine)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.sqldelight.android.driver)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
implementation(libs.sqldelight.native.driver)
}
val desktopMain by getting { // custom target name: no generated accessor in this form
dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.sqldelight.sqlite.driver)
implementation(libs.kotlinx.coroutines.swing)
}
}
wasmJsMain.dependencies {
implementation(libs.ktor.client.js)
}
}
}
The dependency configurations are the familiar Gradle ones, scoped per source set. api makes a dependency part of
the module’s public surface; implementation hides it; compileOnly and runtimeOnly exist for the rare cases
that need them. In an iOS project the distinction matters twice over: only api dependencies can be exported into
the Swift-visible framework (see Native binaries), and a dependency that is only implementation in
sharedLogic is invisible to Swift even though the app uses it at runtime.
The desktopMain by getting form is shown on purpose: accessors such as androidMain and iosMain are generated
for names Gradle can derive from the plugin’s default hierarchy, but a source set named after a custom target
(jvm("desktop")) is addressed with getting or named("desktopMain"). The most common cause of
"unresolved reference" in a new module is putting a library in the wrong source set — for example a Ktor engine
that only exists for one platform in commonMain. The fix is to move it to the platform source set and keep only
the engine-independent ktor-client-core in commonMain.
Compiler options
compilerOptions is a typed, lazy (Property-based) configuration object. It exists at three levels, and the more
specific level wins:
| Level | Where | Applies to |
|---|---|---|
Extension |
|
All targets and all shared source sets (defaults). |
Target |
|
Every compilation of that target; overrides the extension level. |
Compilation / task |
|
One compile task, for example only |
// sharedLogic/build.gradle.kts (continued)
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
kotlin {
// Extension level: shared defaults
compilerOptions {
languageVersion.set(KotlinVersion.KOTLIN_2_4)
apiVersion.set(KotlinVersion.KOTLIN_2_4)
allWarningsAsErrors.set(providers.gradleProperty("trailmate.warningsAsErrors").map(String::toBoolean).orElse(false))
optIn.add("kotlin.time.ExperimentalTime")
freeCompilerArgs.add("-Xexpect-actual-classes") // expect/actual classes are Beta
}
// Target level: overrides the extension level for the desktop target only
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
freeCompilerArgs.add("-Xjdk-release=17")
}
}
// Compilation level: stricter settings for production code only, not tests
targets.configureEach {
compilations.configureEach {
if (name == "main") {
compileTaskProvider.configure {
compilerOptions.allWarningsAsErrors.set(true)
}
}
}
}
}
Options are set with .set() or .add(), not with assignment to a plain field, because they are Gradle
Property and ListProperty objects. The older kotlinOptions \{ \} block was removed and is a frequent leftover in
copied build scripts. A separate languageSettings \{ \} block exists per source set, but it is meant only for
settings that apply to a single shared source set; everything else belongs in compilerOptions.
Top-level dependencies (Experimental)
The Kotlin Gradle plugin also has a top-level dependencies \{ \} block inside kotlin \{ \}. Dependencies declared
there behave as if they were added to commonMain or commonTest. It is Experimental, and requires an
@OptIn(ExperimentalKotlinGradlePluginApi::class) on the block. Platform-specific dependencies still go in the
sourceSets \{ \} block of the matching target.
// sharedUI/build.gradle.kts
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
kotlin {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(projects.sharedLogic)
}
sourceSets {
androidMain.dependencies {
implementation(libs.coil.compose) // platform-specific stays in sourceSets
}
}
}
Prefer the stable sourceSets \{ commonMain.dependencies \{ \} \} form in shared libraries and in code you expect to
keep through several Kotlin releases; the Experimental block is reasonable in an application that upgrades Kotlin
on every release and wants a flatter build file. Its shape may change.
Configuring compilations
A compilation is a pairing of a target with a set of source sets that are compiled together into one output.
Every target gets a main and a test compilation by default; the Android target adds host and device test
compilations when enabled. Custom compilations exist for cases such as integration tests or benchmarks. A
compilation has a default source set (desktopMain for desktop/main) and may pull more source sets in through
associateWith and dependsOn.
// sharedLogic/build.gradle.kts (continued)
kotlin {
jvm("desktop") {
// A custom compilation that can see the internals of "main", like tests do
val main by compilations.getting
val integrationTest by compilations.creating {
associateWith(main)
defaultSourceSet {
dependencies {
implementation(kotlin("test"))
implementation(libs.ktor.client.mock)
}
}
}
// Wire it into the check lifecycle with a test task
testRuns.create("integration") {
setExecutionSourceFrom(integrationTest)
}
}
iosArm64 {
compilations.getByName("main") {
// Compilation-specific cinterop for platform libraries or .def files
cinterops.create("trailmateLocation") {
definitionFile.set(project.file("src/nativeInterop/cinterop/location.def"))
}
}
}
}
associateWith(main) is what makes a custom compilation see internal declarations of main, exactly how the
default test compilation is associated. Without it, the new compilation sees only public API. Beyond that, two
rules cover most needs. First, compilation-level compiler options are changed through
compileTaskProvider, as in the previous section. Second, the compile output of a compilation can be reached as
compilation.output.classesDirs (JVM) or the klib task for native; build scripts that need a path should ask the
task instead of hard-coding build/classes.
Native binaries
By default a Kotlin/Native target compiles to a .klib: a library that other Kotlin/Native modules can consume but
that nothing else can run. The KMP plugin creates no production binaries by default — the only binary is a
debug test executable. To produce something an iOS app, a Linux service or a C program can use, declare it in the
target’s binaries collection.
| Factory method | Binary kind | Available for |
|---|---|---|
|
Product executable |
All native targets |
|
Shared native library ( |
All native targets |
|
Static native library ( |
All native targets |
|
Objective-C/Swift framework |
macOS, iOS, watchOS and tvOS targets only |
|
Test executable |
All native targets |
Each factory takes an optional name prefix and a list of build types (DEBUG, RELEASE). Binaries are named
<optional-prefix><build-type><binary-kind>, such as releaseFramework or testDebugExecutable, and Gradle
creates link tasks with matching names (linkReleaseFrameworkIosArm64).
Framework, isStatic and baseName
// sharedLogic/build.gradle.kts (continued)
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "SharedLogic" // the Swift module name: import SharedLogic
isStatic = true // static linking; default is dynamic
binaryOption("bundleId", "com.example.trailmate.shared")
linkerOpts("-lsqlite3") // system library the SQLDelight native driver needs
// Make these dependencies visible from Swift (only api dependencies can be exported)
export(libs.androidx.lifecycle.viewmodel)
export(libs.kermit)
}
}
}
baseName becomes the framework and Swift module name, so import SharedLogic in Swift. isStatic = true
statically links the Kotlin runtime into the app and avoids shipping a dynamic framework that has to be embedded and
signed; for a single shared framework a static one is the usual choice, and it is required when several Kotlin
frameworks should not each carry a runtime copy. Dynamic frameworks are the default and are the safer pick when the
framework is consumed by several consumers that must share one runtime.
// iosApp/iosApp/TrailListView.swift
import SwiftUI
import SharedLogic // the framework baseName
struct TrailListView: View {
@State private var viewModel = TrailListViewModel() // exported Kotlin class
var body: some View {
Text("Trails: \(viewModel.trailCount)")
}
}
export()
By default a framework exposes only the module’s own public API. A dependency’s types appear in the generated
header only if it is exported, and only api dependencies of the source set can be exported. When you export a
dependency, its whole API goes into the framework, dead-code elimination is disabled for it, and binary size and
link time grow. Export is non-transitive: exporting foo, which depends on bar, exposes only foo.
transitiveExport = true changes that, and the documentation advises against it because it can increase compile
time and binary size.
A multi-module layout uses export(project(…)) to build one umbrella framework: Swift apps cannot easily load
several independent Kotlin/Native frameworks, so the shared UI module exports the shared logic module into one
ComposeApp-style framework.
// sharedUI/build.gradle.kts
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "SharedUI"
isStatic = true
export(projects.sharedLogic) // umbrella: Swift sees both modules' API in SharedUI
}
}
sourceSets {
commonMain.dependencies {
api(projects.sharedLogic) // must be api to be exportable
}
}
}
XCFramework
An XCFramework is a bundle holding one framework per platform and architecture (device and simulator). Unlike
universal ("fat") frameworks built with lipo, an XCFramework does not need unnecessary architectures stripped before
App Store submission, and it is the format used to distribute a Kotlin library to Swift consumers, typically through
a Swift package. Fat frameworks are legacy; use XCFrameworks.
// sharedLogic/build.gradle.kts (continued)
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
kotlin {
val xcf = XCFramework("SharedLogic")
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "SharedLogic"
isStatic = true
xcf.add(this)
}
}
}
# run from the repository root
./gradlew :sharedLogic:assembleSharedLogicReleaseXCFramework
# output: sharedLogic/build/XCFrameworks/release/SharedLogic.xcframework
The plugin registers assembleXCFramework, assemble<Name>DebugXCFramework and assemble<Name>ReleaseXCFramework
tasks. Bitcode is no longer supported (since Kotlin 2.0.20), so any embedBitcode setting in an old build script
should be removed. How the framework is delivered to Xcode is the subject of
iOS Integration Options, and the Swift side of Swift
packages is explained in Swift Packages and Modularization.
gradle.properties
gradle.properties configures the Gradle daemon, the Kotlin daemon and a few plugin behaviours. The values below
are a sensible starting point for TrailMate Everywhere; none of them are secrets, and per-machine overrides belong in
~/.gradle/gradle.properties.
# gradle.properties
# Gradle daemon heap and Kotlin daemon heap: multiplatform builds are memory-hungry
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -XX:+UseParallelGC
kotlin.daemon.jvmargs=-Xmx4g
# Faster builds
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
# Code style used by the IDE and tools
kotlin.code.style=official
# Do not fail when a target (for example iOS on Linux) cannot be built on this host
kotlin.native.ignoreDisabledTargets=true
# AndroidX is the only supported Android library family
android.useAndroidX=true
| Property | Effect |
|---|---|
|
Heap for the Gradle daemon. Out-of-memory crashes in |
|
Heap for the Kotlin compiler daemon, which is a separate process. |
|
Turns on the Gradle build cache; see the next section. |
|
Turns on the configuration cache; see the next section. |
|
Suppresses the warning for targets disabled on the current host. A multiplatform build always declares iOS targets, so a Linux CI runner would otherwise warn on every run. |
|
Set to |
A common mistake is to copy old flags such as kotlin.mpp.enableGranularSourceSetsMetadata or
kotlin.native.useEmbeddableCompilerJar from tutorials written for Kotlin 1.x: they have no effect today, and
the Kotlin Gradle plugin warns about deprecated properties. Delete the lines instead of keeping them.
Configuration cache and build cache
Two caches speed up different phases, and a multiplatform build benefits from both because it runs many tasks (one compile and one link per target and build type).
| Cache | What it stores | Enabled by |
|---|---|---|
Configuration cache |
The result of the configuration phase (the task graph), reused when build scripts and inputs are unchanged |
|
Build cache |
The outputs of individual tasks, keyed by their inputs, reusable across builds and (with a remote cache) machines |
|
Both are recommended by the Kotlin documentation. The plugins in this build (Kotlin, Compose, SQLDelight, AGP 9)
are compatible with the configuration cache. If it fails, the cause is almost always a custom task or script that
reads project at execution time; fix it by using Gradle Provider APIs rather than disabling the cache.
# run from the repository root
./gradlew :sharedLogic:allTests --configuration-cache # second run: "Configuration cache entry reused."
./gradlew :sharedLogic:assemble --build-cache --scan # see cache hits in the Build Scan
For Kotlin/Native specifically, the iOS targets dominate the build time. The documentation’s tuning list: keep
the Gradle and Kotlin daemons enabled and sized as above, avoid building more Apple targets than needed (develop
against iosSimulatorArm64 only and let release builds link the device target), prefer a static framework and
modularize so that unchanged modules are reused from caches, and avoid transitiveExport. In CI, build a debug
simulator framework for pull requests and the release XCFramework only on release tags.
Kotlin, Gradle, AGP and Xcode compatibility
A build works when four version lines agree: Kotlin, Gradle, the Android Gradle Plugin and Xcode. The official compatibility guide records the tested range for each Kotlin release.
| Tool | Kotlin 2.4.20 tested range | TrailMate Everywhere uses |
|---|---|---|
Gradle |
7.6.3 through 9.7.0 |
9.8.0 (newer than the tested range) |
Android Gradle Plugin |
8.5.2 through 9.3.1 (8.5.2 is the minimum) |
9.4.1 (newer than the tested range) |
Xcode |
Xcode 26.4 |
Xcode 26.4, pinned in CI |
Compose Multiplatform |
Uses the Kotlin version through |
1.12.1 (Android, iOS, desktop Stable; web Beta) |
Android-KMP library plugin |
Needs AGP 8.10 or later |
|
Two points follow. First, Gradle 9.8.0 and AGP 9.4.1 are newer than the range Kotlin 2.4.20 was tested against.
Newer versions usually work, and AGP 9 requires Gradle 9.x anyway, but a failure in a newer-than-tested combination
is something to report and, if blocking, to resolve by moving Kotlin forward (Kotlin 2.5.0 is planned for December
2026; 2.5.0-Beta1 is already available) rather than by downgrading AGP. Second, Xcode is the least forgiving:
a Kotlin release is tested against one Xcode version, and a newer Xcode can break the Kotlin/Native link step until
Kotlin catches up. A fresh macos-latest GitHub runner ships a newer Xcode (26.6 at the time of writing), so pin
26.4 explicitly. See CI/CD with GitHub Actions.
AGP 9 rejects com.android.library and com.android.application combined with the KMP plugin, and an Xcode newer
than the tested one is the usual cause of an iOS link failure after an update. The Android side is covered in
The Android Target and AGP 9.
Removed DSL: ios() shortcuts and target presets
The multiplatform DSL has shed two generations of APIs. Both are gone from Kotlin 2.4.20; they appear in older tutorials, books and Stack Overflow answers, so you should recognize them and know the replacement.
| Legacy API | Replacement | Timeline |
|---|---|---|
|
List the targets explicitly ( |
Warning in 1.9.20, error in 2.1.0, removed in 2.2.0 |
|
Call the target function directly ( |
Warning in 1.9.20, error in 2.0.0, removed from the public API in 2.2.0 |
|
|
Replaced by the typed compiler-options DSL |
The ios() shortcut created iosArm64 and iosX64 but not iosSimulatorArm64, which Apple Silicon Macs need;
changing it would have broken existing projects, so it was deprecated instead. The replacement is also shorter in
effect: you state the targets, and the plugin derives the intermediate source sets from that list. With
iosArm64 and iosSimulatorArm64 it creates iosMain and iosTest; with iosArm64 and macosArm64 it creates
appleMain and appleTest.
// sharedLogic/build.gradle.kts
// Legacy (removed in Kotlin 2.2.0) - do not write this:
// ios() // iosArm64 + iosX64, no simulator on Apple Silicon
// fromPreset(presets.iosArm64, "ios")
// Current
kotlin {
iosArm64()
iosSimulatorArm64()
// iosX64() is Tier 3; add it only if you still need Intel-Mac simulators
}
Keep an eye on target deprecations too: macosX64, tvosX64 and watchosX64 were deprecated in Kotlin 2.3.20 and
watchosArm32 in 2.4.20, and iosX64 is Tier 3. See
How KMP Compiles for the full tier list.
Common mistakes
| Mistake | Fix |
|---|---|
Applying |
Use |
Hard-coding versions in each module |
Put every version in |
|
Use |
A platform-only library in |
Move it to |
Forgetting |
Declare the dependency with |
|
Export only the types Swift really needs; it inflates the binary and slows linking. |
Building on a Mac with an Xcode newer than the Kotlin release supports |
Pin the supported Xcode version, locally and in CI. |
Using the Experimental top-level |
Use |