Multiplatform Development
Problem
Maintaining separate codebases for Android, iOS, and other platforms leads to code duplication, inconsistent business logic, and increased maintenance burden. Kotlin Multiplatform Mobile (KMP) allows sharing code while preserving platform-specific optimizations.
Solution
1. Basic Multiplatform Project Structure
// Project structure
my-app/
├── shared/
│ ├── src/
│ │ ├── commonMain/kotlin/ // Shared code
│ │ ├── androidMain/kotlin/ // Android-specific
│ │ ├── iosMain/kotlin/ // iOS-specific
│ │ └── jvmMain/kotlin/ // JVM-specific (desktop)
│ └── build.gradle.kts
├── androidApp/
│ └── build.gradle.kts
└── iosApp/
└── iosApp.xcodeproj
// build.gradle.kts (project level)
plugins {
kotlin("multiplatform") version "2.1.0"
id("com.android.library")
}
kotlin {
// Target platforms
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val androidMain by getting {
dependencies {
implementation("androidx.core:core-ktx:1.15.0")
}
}
val iosMain by creating {
dependsOn(commonMain)
}
}
}2. Expect/Actual Declarations for Platform APIs
// commonMain/kotlin/Platform.kt - Expect declarations
expect class Platform() {
val name: String
}
expect fun getPlatform(): Platform
// commonMain/kotlin/Logger.kt - Expect for platform logging
expect object Logger {
fun log(message: String)
fun error(message: String, throwable: Throwable?)
}
// commonMain/kotlin/Storage.kt - Expect for platform storage
expect class KeyValueStorage {
fun getString(key: String): String?
fun putString(key: String, value: String)
fun remove(key: String)
fun clear()
}
// androidMain/kotlin/Platform.kt - Android implementation
actual class Platform actual constructor() {
actual val name: String = "Android ${android.os.Build.VERSION.SDK_INT}"
}
actual fun getPlatform(): Platform = Platform()
// androidMain/kotlin/Logger.kt - Android logging
import android.util.Log
actual object Logger {
actual fun log(message: String) {
Log.d("KMP", message)
}
actual fun error(message: String, throwable: Throwable?) {
Log.e("KMP", message, throwable)
}
}
// androidMain/kotlin/Storage.kt - Android SharedPreferences
import android.content.Context
import android.content.SharedPreferences
actual class KeyValueStorage(private val context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("kmp_storage", Context.MODE_PRIVATE)
actual fun getString(key: String): String? = prefs.getString(key, null)
actual fun putString(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}
actual fun remove(key: String) {
prefs.edit().remove(key).apply()
}
actual fun clear() {
prefs.edit().clear().apply()
}
}
// iosMain/kotlin/Platform.kt - iOS implementation
import platform.UIKit.UIDevice
actual class Platform actual constructor() {
actual val name: String =
UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
actual fun getPlatform(): Platform = Platform()
// iosMain/kotlin/Logger.kt - iOS NSLog
import platform.Foundation.NSLog
actual object Logger {
actual fun log(message: String) {
NSLog("KMP: %@", message)
}
actual fun error(message: String, throwable: Throwable?) {
NSLog("KMP ERROR: %@ - %@", message, throwable?.message ?: "")
}
}
// iosMain/kotlin/Storage.kt - iOS UserDefaults
import platform.Foundation.NSUserDefaults
actual class KeyValueStorage {
private val userDefaults = NSUserDefaults.standardUserDefaults
actual fun getString(key: String): String? =
userDefaults.stringForKey(key)
actual fun putString(key: String, value: String) {
userDefaults.setObject(value, forKey = key)
userDefaults.synchronize()
}
actual fun remove(key: String) {
userDefaults.removeObjectForKey(key)
userDefaults.synchronize()
}
actual fun clear() {
val domain = NSBundle.mainBundle.bundleIdentifier
if (domain != null) {
userDefaults.removePersistentDomainForName(domain)
}
}
}3. Shared Business Logic
// commonMain/kotlin/data/User.kt
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: String,
val username: String,
val email: String
)
// commonMain/kotlin/repository/UserRepository.kt
class UserRepository(private val storage: KeyValueStorage) {
companion object {
private const val KEY_CURRENT_USER = "current_user"
}
fun saveUser(user: User) {
val json = Json.encodeToString(User.serializer(), user)
storage.putString(KEY_CURRENT_USER, json)
Logger.log("User saved: ${user.username}")
}
fun getCurrentUser(): User? {
val json = storage.getString(KEY_CURRENT_USER) ?: return null
return try {
Json.decodeFromString(User.serializer(), json)
} catch (e: Exception) {
Logger.error("Failed to parse user", e)
null
}
}
fun clearUser() {
storage.remove(KEY_CURRENT_USER)
Logger.log("User cleared")
}
}
// commonMain/kotlin/viewmodel/UserViewModel.kt
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class UserViewModel(private val repository: UserRepository) {
private val _userState = MutableStateFlow<UserState>(UserState.NotLoggedIn)
val userState: StateFlow<UserState> = _userState.asStateFlow()
sealed class UserState {
object NotLoggedIn : UserState()
data class LoggedIn(val user: User) : UserState()
}
fun login(username: String, email: String) {
val user = User(
id = generateId(),
username = username,
email = email
)
repository.saveUser(user)
_userState.value = UserState.LoggedIn(user)
}
fun logout() {
repository.clearUser()
_userState.value = UserState.NotLoggedIn
}
fun loadCurrentUser() {
val user = repository.getCurrentUser()
_userState.value = if (user != null) {
UserState.LoggedIn(user)
} else {
UserState.NotLoggedIn
}
}
private fun generateId(): String {
// Platform-specific implementation would be better
return System.currentTimeMillis().toString()
}
}4. Networking with Ktor Client
// commonMain - build.gradle.kts
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:3.0.3")
implementation("io.ktor:ktor-client-content-negotiation:3.0.3")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.3")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-okhttp:3.0.3")
}
}
val iosMain by creating {
dependencies {
implementation("io.ktor:ktor-client-darwin:3.0.3")
}
}
// commonMain/kotlin/network/ApiClient.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 ApiClient {
private val client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
}
suspend fun fetchUsers(): Result<List<User>> {
return try {
val users: List<User> = client.get("https://api.example.com/users").body()
Result.success(users)
} catch (e: Exception) {
Logger.error("API call failed", e)
Result.failure(e)
}
}
suspend fun createUser(user: User): Result<User> {
return try {
val createdUser: User = client.post("https://api.example.com/users") {
setBody(user)
}.body()
Result.success(createdUser)
} catch (e: Exception) {
Logger.error("Failed to create user", e)
Result.failure(e)
}
}
fun close() {
client.close()
}
}5. Testing Shared Code
// commonTest/kotlin/UserRepositoryTest.kt
import kotlin.test.*
class UserRepositoryTest {
private lateinit var storage: TestKeyValueStorage
private lateinit var repository: UserRepository
@BeforeTest
fun setup() {
storage = TestKeyValueStorage()
repository = UserRepository(storage)
}
@Test
fun testSaveAndRetrieveUser() {
// Given
val user = User(
id = "1",
username = "testuser",
email = "test@example.com"
)
// When
repository.saveUser(user)
val retrieved = repository.getCurrentUser()
// Then
assertNotNull(retrieved)
assertEquals(user.username, retrieved.username)
assertEquals(user.email, retrieved.email)
}
@Test
fun testClearUser() {
// Given
val user = User("1", "test", "test@example.com")
repository.saveUser(user)
// When
repository.clearUser()
val retrieved = repository.getCurrentUser()
// Then
assertNull(retrieved)
}
}
// commonTest/kotlin/TestKeyValueStorage.kt
class TestKeyValueStorage : KeyValueStorage() {
private val storage = mutableMapOf<String, String>()
override fun getString(key: String): String? = storage[key]
override fun putString(key: String, value: String) {
storage[key] = value
}
override fun remove(key: String) {
storage.remove(key)
}
override fun clear() {
storage.clear()
}
}How It Works
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
graph TD
A[Common Code] --> B{Platform Check}
B -->|Android| C[Android Actual]
B -->|iOS| D[iOS Actual]
B -->|JVM| E[JVM Actual]
C --> F[Platform-Specific API]
D --> F
E --> F
F --> G[Runtime Execution]
style A fill:#0173B2
style B fill:#DE8F05
style F fill:#029E73
style G fill:#CC78BC
Compilation Process:
- Common Code: Business logic, data models, ViewModels written once
- Expect Declarations: Define platform-specific APIs without implementation
- Actual Implementations: Each platform provides concrete implementation
- Compilation: Kotlin compiler resolves expect/actual at compile time
- Platform Binaries: Each target gets optimized platform-specific code
Variations
Hierarchical Source Sets
kotlin {
sourceSets {
// All platforms
val commonMain by getting
// All Native platforms (iOS, watchOS, tvOS, macOS)
val nativeMain by creating {
dependsOn(commonMain)
}
// All iOS platforms (device + simulator)
val iosMain by creating {
dependsOn(nativeMain)
}
// Specific iOS targets
val iosX64Main by getting {
dependsOn(iosMain)
}
val iosArm64Main by getting {
dependsOn(iosMain)
}
val iosSimulatorArm64Main by getting {
dependsOn(iosMain)
}
}
}Intermediate Source Sets for Code Reuse
// Create intermediate source set for mobile platforms
val mobileMain by creating {
dependsOn(commonMain)
}
val androidMain by getting {
dependsOn(mobileMain)
}
val iosMain by creating {
dependsOn(mobileMain)
}
// Shared mobile code (not in desktop)
// mobileMain/kotlin/mobile/MobileFeature.kt
class MobileFeature {
fun getDeviceInfo(): String {
return "Mobile platform: ${getPlatform().name}"
}
}CocoaPods Integration for iOS
kotlin {
cocoapods {
summary = "Shared module for iOS"
homepage = "https://github.com/your/project"
ios.deploymentTarget = "14.0"
framework {
baseName = "shared"
isStatic = true
}
}
}Common Pitfalls
1. Using JVM/Android APIs in Common Code
Problem: Accidentally using platform-specific APIs breaks compilation.
// ❌ Bad: Using java.util.Date in common code
import java.util.Date // Compile error!
fun getCurrentTimestamp(): Long {
return Date().time
}
// ✅ Good: Use kotlinx-datetime
import kotlinx.datetime.Clock
fun getCurrentTimestamp(): Long {
return Clock.System.now().toEpochMilliseconds()
}2. Not Handling Platform Differences
Problem: Assuming all platforms behave identically.
// ❌ Bad: Assuming file system access works the same
fun saveToFile(data: String) {
File("/data/file.txt").writeText(data) // Won't work on iOS!
}
// ✅ Good: Use expect/actual for file operations
expect class FileWriter {
fun write(filename: String, data: String)
}
// androidMain
actual class FileWriter(private val context: Context) {
actual fun write(filename: String, data: String) {
context.openFileOutput(filename, Context.MODE_PRIVATE).use {
it.write(data.toByteArray())
}
}
}
// iosMain
actual class FileWriter {
actual fun write(filename: String, data: String) {
val documentsDirectory = NSFileManager.defaultManager.URLsForDirectory(
NSDocumentDirectory,
NSUserDomainMask
).first() as NSURL
val fileURL = documentsDirectory.URLByAppendingPathComponent(filename)
data.writeToURL(fileURL!!, atomically = true, encoding = NSUTF8StringEncoding, error = null)
}
}3. Memory Leaks with Kotlin/Native
Problem: Kotlin/Native uses garbage collection - beware of iOS retain cycles.
// ❌ Bad: Strong reference cycle
class ViewController {
val callback: () -> Unit = {
// Captures 'this' strongly
this.updateUI()
}
}
// ✅ Good: Use weak references
class ViewController {
val callback: () -> Unit = { [weak self] in
self?.updateUI()
}
}
// ✅ Better: Use Kotlin's approach
class ViewController {
private var updateCallback: (() -> Unit)? = null
fun setCallback() {
updateCallback = {
updateUI()
}
}
fun cleanup() {
updateCallback = null // Break reference
}
}4. Not Testing on All Platforms
Problem: Code works on Android but fails on iOS.
// ❌ Bad: Only testing on Android
@Test
fun testFeature() {
// Test passes on Android but might fail on iOS
}
// ✅ Good: Write platform-agnostic tests in commonTest
// commonTest/kotlin/FeatureTest.kt
class FeatureTest {
@Test
fun testFeatureCrossPlatform() {
// Test runs on all platforms
val result = sharedFunction()
assertEquals("expected", result)
}
}
// Run tests on all platforms
// ./gradlew cleanAllTests allTests5. Ignoring iOS Framework Export
Problem: Types not exposed to iOS won’t be accessible in Swift.
// ❌ Bad: Internal visibility prevents iOS access
internal class UserRepository // Not visible in Swift!
// ✅ Good: Public API surface
class UserRepository { // Visible in Swift
fun getUser(): User {
return fetchUserInternal()
}
private fun fetchUserInternal(): User {
// Implementation details remain private
}
}
// Configure exported types
kotlin {
targets.withType<KotlinNativeTarget> {
binaries.framework {
export("com.example:shared-models:1.0.0")
}
}
}Related Patterns
Related Tutorial: See Advanced Tutorial - Multiplatform. Related How-To: See Handle Coroutines and Async. Related Cookbook: See Cookbook recipe “Multiplatform Patterns”.