Kotlin 2.2: Multiplatform Development
Kotlin 2.2 has finally turned the long‑awaited “write once, run everywhere” dream into a practical reality for many teams. With its refined compiler, tighter Gradle integration, and first‑class support for iOS, Web, and desktop, Kotlin Multiplatform (KMP) is no longer a niche experiment—it’s a production‑ready toolkit. In this article we’ll walk through the core concepts, set up a real‑world shared module, and explore two practical examples that you can copy‑paste into your own projects.
Why Kotlin Multiplatform Matters in 2024
Multiplatform development isn’t new, but Kotlin’s approach stands out because it lets you share **business logic** while still writing idiomatic code for each UI layer. This means you avoid duplicated networking, validation, and domain models, reducing bugs and speeding up feature delivery.
Companies like Square, Netflix, and Lyft already ship shared Kotlin code to Android, iOS, and even JavaScript front‑ends. The payoff is measurable: up to 40 % fewer lines of platform‑specific code and a unified test suite that runs on the JVM, iOS simulators, and browsers.
Getting Started with Kotlin 2.2
Kotlin 2.2 ships with the new kmp Gradle plugin, which simplifies module configuration. The plugin automatically creates source sets for commonMain, androidMain, iosMain, and jsMain. All you need is a recent version of Android Studio (Arctic Fox or newer) and the Kotlin plugin set to 2.2.x.
plugins {
kotlin("multiplatform") version "2.2.0"
id("com.android.library")
}
Next, declare the targets you want to support. Kotlin 2.2 introduces the ios() shortcut, which creates both simulator and device binaries in one line.
kotlin {
android()
ios() // creates iosX64, iosArm64, iosSimulatorArm64
js(IR) {
browser()
}
sourceSets {
val commonMain by getting
val androidMain by getting
val iosMain by getting
val jsMain by getting
}
}
Structuring a Shared Module
A well‑structured shared module follows the classic “clean architecture” layers: domain, data, and presentation. Each layer lives in commonMain, while platform‑specific implementations (e.g., SQLite on Android, CoreData on iOS) sit in their respective source sets.
Here’s a quick folder layout:
src/commonMain/kotlin/com/example/shared/domainsrc/commonMain/kotlin/com/example/shared/datasrc/commonMain/kotlin/com/example/shared/presentationsrc/androidMain/kotlin/com/example/shared/data/androidsrc/iosMain/kotlin/com/example/shared/data/ios
Keeping the platform‑agnostic code pure Kotlin (no Android or iOS imports) ensures you can run the same unit tests on the JVM.
Example 1: A Simple Calculator Library
Let’s build a tiny arithmetic library that can be used from an Android app, an iOS SwiftUI view, and a React‑like Kotlin/JS UI. The core logic lives in commonMain and is deliberately pure.
Domain Model
package com.example.shared.domain
sealed class Operation {
data class Add(val a: Double, val b: Double) : Operation()
data class Subtract(val a: Double, val b: Double) : Operation()
data class Multiply(val a: Double, val b: Double) : Operation()
data class Divide(val a: Double, val b: Double) : Operation()
}
Calculator Service
package com.example.shared.domain
class Calculator {
fun compute(op: Operation): Double = when (op) {
is Operation.Add -> op.a + op.b
is Operation.Subtract -> op.a - op.b
is Operation.Multiply -> op.a * op.b
is Operation.Divide -> {
require(op.b != 0.0) { "Division by zero" }
op.a / op.b
}
}
}
Because this class uses only Kotlin stdlib, it compiles to JVM bytecode, native binaries, and JavaScript without any changes.
Android Usage
class MainActivity : AppCompatActivity() {
private val calculator = Calculator()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val result = calculator.compute(Operation.Add(3.0, 5.0))
findViewById<TextView>(R.id.result).text = "Result: $result"
}
}
iOS Bridging
Expose the Kotlin class to Swift by adding an expect/actual wrapper (optional for pure Kotlin). The Gradle plugin automatically generates a framework you can import.
import SharedCalculator // generated framework name
let calc = Calculator()
let result = calc.compute(operation: .add(a: 3, b: 5))
print("Result: \\(result)")
JavaScript Integration
import { Calculator, Operation } from 'shared-calculator'
const calc = new Calculator()
const result = calc.compute(new Operation.Add(3, 5))
document.getElementById('output').textContent = `Result: ${result}`
Pro tip: When you need to expose Kotlin sealed classes to Swift/JS, generate companion factory functions in commonMain. They make interop painless and keep your UI code tidy.
Example 2: Cross‑Platform Networking with Ktor
Most real‑world apps need HTTP, so let’s create a small API client that works everywhere. Kotlin 2.2 ships with Ktor 2.3, which already supports JVM, Native, and JS transports.
API Contract (commonMain)
package com.example.shared.data
import io.ktor.client.*
import io.ktor.client.request.*
data class Post(val id: Int, val title: String, val body: String)
interface PostRepository {
suspend fun fetchPosts(): List<Post>
}
Implementation Using Ktor (commonMain)
package com.example.shared.data
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
class KtorPostRepository(
private val client: HttpClient = HttpClient()
) : PostRepository {
override suspend fun fetchPosts(): List<Post> = client.get("https://jsonplaceholder.typicode.com/posts").body()
}
The same class works on Android, iOS, and the browser because Ktor abstracts the underlying engine. You only need to add platform‑specific dependencies if you want to tune the engine (e.g., OkHttp on Android, Darwin on iOS).
Platform‑Specific Engine Configuration
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:2.3.0")
implementation("io.ktor:ktor-client-content-negotiation:2.3.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.0")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.0")
}
}
val iosMain by getting {
dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.0")
}
}
val jsMain by getting {
dependencies {
implementation("io.ktor:ktor-client-js:2.3.0")
}
}
}
}
Consuming the Repository on Android
class PostsViewModel : ViewModel() {
private val repo = KtorPostRepository()
private val _posts = MutableLiveData<List<Post>>()
val posts: LiveData<List<Post>> = _posts
init {
viewModelScope.launch {
_posts.value = repo.fetchPosts()
}
}
}
iOS SwiftUI Example
import SwiftUI
import SharedData // generated framework
struct ContentView: View {
@State private var posts: [Post] = []
var body: some View {
List(posts, id: \.id) { post in
Text(post.title)
}
.onAppear {
Task {
let repo = KtorPostRepository()
posts = try await repo.fetchPosts()
}
}
}
}
Web Usage with Kotlin/JS
import kotlinx.browser.document
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
val repo = KtorPostRepository()
val scope = MainScope()
scope.launch {
val posts = repo.fetchPosts()
val list = document.createElement("ul")
posts.forEach {
val li = document.createElement("li")
li.textContent = it.title
list.appendChild(li)
}
document.body?.appendChild(list)
}
Note: When targeting iOS, remember to enable the useIR flag in your Xcode project to avoid runtime crashes caused by mismatched Kotlin/Native binaries.
Platform‑Specific Implementations
Even with a shared core, you’ll inevitably need platform‑specific code—for example, accessing the device’s secure storage or using Android’s Jetpack DataStore. Kotlin’s expect/actual mechanism lets you declare an API in commonMain and provide concrete implementations where they belong.
Secure Token Storage
package com.example.shared.data
expect class SecureStorage {
fun save(key: String, value: String)
fun read(key: String): String?
}
Android implementation:
package com.example.shared.data.android
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import com.example.shared.data.SecureStorage
actual class SecureStorage(private val context: Context) {
private val prefs = EncryptedSharedPreferences.create(
"secure_prefs",
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
actual fun save(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}
actual fun read(key: String): String? = prefs.getString(key, null)
}
iOS implementation (Native):
package com.example.shared.data.ios
import platform.Foundation.NSUserDefaults
import com.example.shared.data.SecureStorage
actual class SecureStorage {
private val defaults = NSUserDefaults.standardUserDefaults
actual fun save(key: String, value: String) {
defaults.setObject(value, forKey = key)
}
actual fun read(key: String): String? = defaults.stringForKey(key)
}
Testing and Debugging Across Targets
Kotlin 2.2 makes it easier to run the same test suite on every platform. Place your tests in commonTest for pure logic, and add platform‑specific tests in androidTest, iosTest, or jsTest when you need to verify integration points.
package com.example.shared.domain
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class CalculatorTest {
private val calc = Calculator()
@Test fun `addition works`() {
assertEquals(8.0, calc.compute(Operation.Add(3.0, 5.0)))
}
@Test fun `division by zero throws`() {
assertFailsWith<IllegalArgumentException> {
calc.compute(Operation.Divide(10.0, 0.0))
}
}
}
Run the suite with a single Gradle command:
./gradlew allTests
For iOS, the plugin automatically generates an Xcode scheme that runs the Kotlin tests on the simulator, letting you view results alongside native XCTest reports.
Build Optimizations and Incremental Compilation
Kotlin 2.2 introduces a new incremental compilation pipeline for native targets. The compiler now caches intermediate IR files, cutting down rebuild times from minutes to seconds on typical MacBook hardware.
- Enable caching:
kotlin.native.cacheKind = "experimental"ingradle.properties. - Use the
kotlinx-serializationplugin only where needed to avoid unnecessary code generation. - Leverage
cinteropstubs sparingly; each native library adds to the linking step.
When you combine these tricks with Gradle’s configuration cache, the overall CI pipeline can drop from 12 minutes to under 5 minutes for a full multiplatform build.
Pro Tips for Scaling KMP Projects
1️⃣ Keep the shared module thin. A 200 KB shared library is easier to reason about than a 5 MB monolith. Split large domains into separate Gradle sub‑projects if they evolve independently.
2️⃣ PreferFlowover callbacks. Kotlin’skotlinx.coroutines.flowworks natively on all targets and gives you a unified reactive API.
3️⃣ Guard against API drift. Use thekmp-compatibilityGradle task (added in 2.2) to compare public signatures across targets and catch accidental platform‑only changes early.
4️⃣ Automate iOS framework packaging. Add acopyFrameworktask that produces an .xcframework, then reference it directly in Xcode’sFramework Search Paths.
5️⃣ Monitor binary size. Run./gradlew :shared:linkReleaseFrameworkIosX64and inspect the generated.klib. Theklib‑size‑reportplugin highlights which dependencies contribute most to the final size.
Future Roadmap: What’s Coming After 2.2?
Kotlin 2.3, slated for late 2026, promises first‑class support for SwiftUI previews, a unified compose runtime for web and native, and improved coroutine debugging on Native. The community also expects a “KMP Gradle DSL” that will replace the current kotlin { … } block with a more declarative syntax, making multi‑target configuration even more approachable.
Meanwhile, the Kotlin Multiplatform Mobile (KMM) SDK is being refactored to expose a single kmm artifact that bundles the most common libraries (Ktor, SQLDelight, and Coroutines). This will further lower the entry barrier for small teams that want to share code without wrestling with dependency versions.
Conclusion
Kotlin 2.