Kotlin Multiplatform Project — Shared Code Tutorial
In this tutorial, you will learn about Kotlin Multiplatform Project. We cover key concepts, practical examples, and best practices to help you master this topic.
Kotlin Multiplatform (KMP) is a cross-platform development approach that shares business logic across Android, iOS, web, and desktop while allowing platform-specific UI, reducing code duplication without sacrificing native performance.
What You Will Learn
- Structuring a KMP project with shared and platform modules
- Writing common business logic in the shared module
- Using Kotlin Multiplatform for networking, data storage, and state management
- Building platform-specific UIs for Android with Compose and iOS with SwiftUI
- Configuring Gradle for multi-target builds
- Testing shared code across platforms
Why It Matters
Cross-platform frameworks like React Native and Flutter share both logic and UI across platforms, but they introduce a non-native rendering layer that can feel foreign on each platform. KMP takes a different approach: share only the logic layer (networking, validation, database, state management) and write the UI natively for each platform. This gives you the code reuse benefits of cross-platform development while maintaining native look, feel, and performance. Companies like Netflix, McDonald's, and VMWare use KMP to share code between their Android and iOS apps.
Real-World Use
The DodaTech mobile app shares its networking layer, data models, and business logic across Android and iOS using KMP. The API client, cache layer, and user authentication logic reside in the shared module. Each platform renders the UI using its native toolkit (Compose on Android, SwiftUI on iOS), ensuring a natural experience on each platform.
Learning Path
flowchart LR A[KMP Basics] --> B[KMP Project\nYou are here] B --> C[Testing + Dependency Injection] style B fill:#f90,color:#fff
Project Overview
We will build a Weather App that fetches current weather data from a public API and displays it on Android and iOS. The shared module will contain:
- Data models for weather responses
- An HTTP client using Ktor
- A repository for Caching and data transformation
- ViewModel-like state holders using Kotlin coroutines and Flow
- Platform-specific modules for Android (Compose) and iOS (SwiftUI)
The project will use the Gradle Multiplatform plugin with the androidTarget, iosX64, iosArm64, and iosSimulatorArm64 targets.
Project Structure
A KMP project typically follows this structure:
weather-app/
├── shared/
│ ├── src/
│ │ ├── commonMain/ # Shared business logic
│ │ ├── androidMain/ # Android-specific implementations
│ │ └── iosMain/ # iOS-specific implementations
│ └── build.gradle.kts
├── androidApp/
│ ├── src/main/
│ └── build.gradle.kts
├── iosApp/
│ ├── iosApp.xcodeproj
│ └── iosApp/
└── build.gradle.kts
The shared module contains three source sets: commonMain for code shared across all platforms, androidMain for Android-specific code (using expect/actual declarations), and iosMain for iOS-specific code.
Setting Up the Shared Module
The shared module's build.gradle.kts declares targets and dependencies:
// shared/build.gradle.kts
plugins {
kotlin("multiplatform")
id("com.android.library")
kotlin("plugin.serialization")
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions { jvmTarget = "1.8" }
}
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "shared"
isStatic = true
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:2.3.7")
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-android:2.3.7")
}
}
val iosX64Main by getting
val iosArm64Main by getting
val iosSimulatorArm64Main by getting
val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this)
dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.7")
}
}
}
}
Three iOS targets cover all Apple devices: iosX64 for simulators on Intel Macs, iosArm64 for physical devices, and iosSimulatorArm64 for simulators on Apple Silicon Macs. The iosMain source set aggregates them with the Darwin-specific Ktor engine.
Defining Data Models
The shared module defines weather data classes using kotlinx.Serialization:
// shared/src/commonMain/kotlin/com/dodatech/weather/model/Weather.kt
import kotlinx.serialization.Serializable
@Serializable
data class WeatherResponse(
val main: MainData,
val weather: List<WeatherDescription>,
val name: String
)
@Serializable
data class MainData(
val temp: Double,
val feelsLike: Double,
val humidity: Int
)
@Serializable
data class WeatherDescription(
val description: String,
val icon: String
)
These classes are shared between Android and iOS. The @Serializable annotation enables automatic JSON conversion in the Ktor client.
Building the API Client
The API client uses Ktor's multiplatform HTTP client to fetch weather data:
// shared/src/commonMain/kotlin/com/dodatech/weather/api/WeatherApi.kt
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
class WeatherApi(private val apiKey: String) {
private val client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
}
suspend fun getWeather(city: String): WeatherResponse {
return client.get("https://api.openweathermap.org/data/2.5/weather") {
parameter("q", city)
parameter("appid", apiKey)
parameter("units", "metric")
}.body()
}
fun close() {
client.close()
}
}
The HttpClient is configured once with JSON content negotiation. The getWeather function is a suspend function that makes an HTTP GET request and deserializes the response into WeatherResponse. The platform-specific engine (Android or Darwin) is selected automatically based on the source set.
Creating the Repository
The repository caches the last fetched result and provides a Flow for the UI to observe:
// shared/src/commonMain/kotlin/com/dodatech/weather/data/WeatherRepository.kt
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class WeatherRepository(private val api: WeatherApi) {
private val _weather = MutableStateFlow<WeatherResponse?>(null)
val weather: StateFlow<WeatherResponse?> = _weather.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
suspend fun fetchWeather(city: String) {
_isLoading.value = true
_error.value = null
try {
_weather.value = api.getWeather(city)
} catch (e: Exception) {
_error.value = e.message ?: "Unknown error"
} finally {
_isLoading.value = false
}
}
}
The repository exposes three StateFlow properties: the weather data, a loading flag, and an error message. The UI collects these flows and reacts to changes. Using StateFlow instead of LiveData keeps the shared module platform-agnostic.
Expect/Actual for Platform-Specific Code
Some APIs differ across platforms. Use expect to declare a common API and actual to provide platform-specific implementations. For example, getting the platform name:
// shared/src/commonMain/kotlin/com/dodatech/weather/Platform.kt
expect fun getPlatformName(): String
// shared/src/androidMain/kotlin/com/dodatech/weather/Platform.android.kt
actual fun getPlatformName(): String = "Android ${android.os.Build.VERSION.SDK_INT}"
// shared/src/iosMain/kotlin/com/dodatech/weather/Platform.ios.kt
import platform.UIKit.UIDevice
actual fun getPlatformName(): String =
UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
The expect keyword tells the compiler that each target must provide an actual implementation. This pattern is used for platform-specific APIs like file storage, network state, and system preferences.
Building the Android App
The Android app uses Jetpack Compose to display weather data. It imports the shared module's classes:
// androidApp/src/main/java/com/dodatech/weather/android/MainActivity.kt
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.dodatech.weather.api.WeatherApi
import com.dodatech.weather.data.WeatherRepository
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val repository = WeatherRepository(WeatherApi(apiKey = BuildConfig.API_KEY))
setContent {
MaterialTheme {
WeatherScreen(repository)
}
}
}
}
@Composable
fun WeatherScreen(repository: WeatherRepository) {
val weather by repository.weather.collectAsState()
val isLoading by repository.isLoading.collectAsState()
val error by repository.error.collectAsState()
val scope = rememberCoroutineScope()
var city by remember { mutableStateOf("London") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
TextField(
value = city,
onValueChange = { city = it },
label = { Text("City") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { scope.launch { repository.fetchWeather(city) } }) {
Text("Get Weather")
}
Spacer(modifier = Modifier.height(16.dp))
when {
isLoading -> CircularProgressIndicator()
error != null -> Text("Error: $error", color = MaterialTheme.colorScheme.error)
weather != null -> {
Text("City: ${weather!!.name}", style = MaterialTheme.typography.headlineMedium)
Text("Temperature: ${weather!!.main.temp}°C")
Text("Humidity: ${weather!!.main.humidity}%")
Text("Conditions: ${weather!!.weather.firstOrNull()?.description ?: "N/A"}")
}
}
}
}
The Android app calls the same WeatherRepository.fetchWeather method as the iOS app. The Compose UI observes the StateFlow through collectAsState(), which triggers recomposition on every emission.
Building the iOS App
The iOS app uses SwiftUI and imports the shared framework:
// iosApp/iosApp/ContentView.swift
import SwiftUI
import shared
struct ContentView: View {
@StateObject private var viewModel = WeatherViewModel()
var body: some View {
VStack(spacing: 16) {
TextField("City", text: $viewModel.city)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.horizontal)
Button("Get Weather") {
viewModel.fetchWeather()
}
.buttonStyle(.borderedProminent)
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.error {
Text("Error: \(error)")
.foregroundColor(.red)
} else if let weather = viewModel.weather {
Text("City: \(weather.name)")
.font(.title)
Text("Temperature: \(weather.main.temp, specifier: "%.1f")°C")
Text("Humidity: \(weather.main.humidity)%")
Text("Conditions: \(weather.weather.first?.description ?? "N/A")")
}
Text("Platform: \(Platform_iosKt.getPlatformName())")
.font(.footnote)
.padding(.top)
}
.padding()
}
}
class WeatherViewModel: ObservableObject {
private let repository = WeatherRepository(
api: WeatherApi(apiKey: ApiKeyProvider.apiKey)
)
@Published var city = "London"
@Published var weather: WeatherResponse? = nil
@Published var isLoading = false
@Published var error: String? = nil
func fetchWeather() {
isLoading = true
error = nil
repository.fetchWeather(city: city) { [weak self] result, error in
DispatchQueue.main.async {
self?.isLoading = false
if let error = error {
self?.error = error.localizedDescription
} else {
self?.weather = result
}
}
}
}
}
The iOS app uses a WeatherViewModel that wraps the shared Kotlin repository. Since Kotlin suspend functions are exposed to Swift as completion handlers, the fetchWeather function takes a callback. The SwiftUI view observes the @Published properties and updates automatically.
Testing Shared Code
The shared module can be tested on the JVM without an emulator:
// shared/src/commonTest/kotlin/com/dodatech/weather/WeatherRepositoryTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
class WeatherRepositoryTest {
@Test
fun `repository starts with null weather`() = runTest {
val api = WeatherApi(apiKey = "test")
val repository = WeatherRepository(api)
assertEquals(null, repository.weather.value)
}
@Test
fun `repository sets loading state during fetch`() = runTest {
val api = WeatherApi(apiKey = "test")
val repository = WeatherRepository(api)
assertFalse(repository.isLoading.value)
}
}
Use kotlin.test for unit tests in the shared module. Tests run on the JVM during ./gradlew :shared:test. For integration tests that make actual network calls, use a mock HTTP client.
Running the Project
Build and run the Android app with ./gradlew :androidApp:installDebug. For iOS, open iosApp/iosApp.xcodeproj in Xcode, build the shared framework with ./gradlew :shared:linkDebugFrameworkIosArm64, and run the iOS target.
Output on both platforms: a text field to enter a city name, a "Get Weather" button, and a display showing the city name, temperature in Celsius, humidity percentage, and weather description.
Common Mistakes
Not syncing iOS framework builds with Gradle: The iOS app needs the shared framework built before it can compile. Run
./gradlew :shared:linkDebugFrameworkIosArm64before building in Xcode, or configure a build script phase to invoke Gradle.Using platform-specific APIs in commonMain:
commonMaincannot use Android or iOS APIs directly. Useexpect/actualdeclarations to abstract platform-specific functionality. Common examples include file storage, network state, and UUID generation.Forgetting to add the serialization plugin to multiplatform builds: The
kotlin("plugin.serialization")plugin must be applied in the shared module'sbuild.gradle.kts. Without it,@Serializableannotations are ignored and serialization fails at runtime.Incorrect CocoaPods or framework configuration: If the iOS framework is not configured as static, you may encounter duplicate symbol errors. Set
isStatic = truein the framework binary configuration.Handling suspend functions in Swift incorrectly: Kotlin suspend functions are exported to Swift as functions with completion handlers. You must call them with a trailing closure and handle the result asynchronously.
Practice Questions
- What is the role of
commonMainin a KMP project, and what code should it contain? - How do
expectandactualdeclarations enable platform-specific code in KMP? - Why does KMP use
StateFlowinstead ofLiveDatain the shared module? - How would you add a desktop target to this project using Compose Multiplatform?
- Challenge: Add a settings screen that persists the user's preferred city using
expect/actualwith SharedPreferences on Android and UserDefaults on iOS.
Mini Project
Build a KMP Currency Converter app:
- Shared module: API client using ExchangeRate-API, repository with caching, conversion logic
- Android: Compose UI with dropdown selectors for source and target currencies
- iOS: SwiftUI with Picker components
- Support at least 10 currencies and update rates every hour
FAQ
What is Next
Now that you have built a KMP app, explore Testing with Kotest to write comprehensive tests for your shared module. Learn Dependency Injection with Koin to manage dependencies across platforms. You can also dive into Compose Multiplatform for sharing UI as well as logic.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro