Skip to content

Kotlin Multiplatform Project — Shared Code Tutorial

DodaTech Updated 2026-06-28 10 min read

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

  1. Not syncing iOS framework builds with Gradle: The iOS app needs the shared framework built before it can compile. Run ./gradlew :shared:linkDebugFrameworkIosArm64 before building in Xcode, or configure a build script phase to invoke Gradle.

  2. Using platform-specific APIs in commonMain: commonMain cannot use Android or iOS APIs directly. Use expect/actual declarations to abstract platform-specific functionality. Common examples include file storage, network state, and UUID generation.

  3. Forgetting to add the serialization plugin to multiplatform builds: The kotlin("plugin.serialization") plugin must be applied in the shared module's build.gradle.kts. Without it, @Serializable annotations are ignored and serialization fails at runtime.

  4. Incorrect CocoaPods or framework configuration: If the iOS framework is not configured as static, you may encounter duplicate symbol errors. Set isStatic = true in the framework binary configuration.

  5. 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

  1. What is the role of commonMain in a KMP project, and what code should it contain?
  2. How do expect and actual declarations enable platform-specific code in KMP?
  3. Why does KMP use StateFlow instead of LiveData in the shared module?
  4. How would you add a desktop target to this project using Compose Multiplatform?
  5. Challenge: Add a settings screen that persists the user's preferred city using expect/actual with 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

How does KMP differ from Flutter and React Native?

Flutter and React Native share both logic and UI across platforms using their own rendering engines. KMP shares only the logic layer and lets you write native UI for each platform, resulting in a more authentic native feel.

Can I use KMP for an existing Android or iOS app?

Yes. KMP is designed for incremental adoption. You can start by extracting networking or data layer code into a shared module without rewriting your existing UI.

Do I need to learn Swift to use KMP for iOS?

Yes, because the iOS UI layer is written in Swift/SwiftUI. However, the business logic, networking, and data models are all in Kotlin and shared across platforms.

How does dependency injection work in KMP?

Koin and Kodein both support KMP. You can configure DI in the shared module and inject platform-specific implementations using expect/actual for modules that differ per platform.

Does KMP support Apple Watch or tvOS?

Yes. You can add watchosArm64 and tvosArm64 targets in the shared module's build configuration and write platform-specific UI for each device family.

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