Kotlin - Multiplatform
Try it: Build cross-platform applications with shared business logic and platform-specific implementations.
What is Kotlin Multiplatform?
Kotlin Multiplatform (KMP) allows you to share code between different platforms while retaining the flexibility to write platform-specific code when needed. Write once, run everywhere with native performance.
Project Structure
// build.gradle.kts (Project level)
plugins {
kotlin("multiplatform") version "1.9.20"
kotlin("native.cocoapods") version "1.9.20"
id("com.android.library")
}
kotlin {
// JVM Target
jvm {
jvmToolchain(11)
withJava()
}
// Android Target
android {
compileSdk = 34
}
// iOS Targets
ios()
iosSimulatorArm64()
// Web Target
js(IR) {
browser {
testTask {
useKarma {
useChromeHeadless()
}
}
}
nodejs()
}
// Native Targets
linuxX64()
mingwX64()
macosX64()
sourceSets {
// Common code
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
// JVM specific
val jvmMain by getting {
dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.5")
}
}
// Android specific
val androidMain by getting {
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
}
}
// iOS specific
val iosMain by getting {
dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.5")
}
}
// Web specific
val jsMain by getting {
dependencies {
implementation("io.ktor:ktor-client-js:2.3.5")
}
}
}
}
Common Code
// commonMain/kotlin/User.kt
import kotlinx.serialization.Serializable
import kotlinx.datetime.Instant
@Serializable
data class User(
val id: String,
val name: String,
val email: String,
val createdAt: Instant
)
// commonMain/kotlin/UserRepository.kt
interface UserRepository {
suspend fun getUser(id: String): User?
suspend fun saveUser(user: User): User
suspend fun getAllUsers(): List<User>
}
// commonMain/kotlin/UserService.kt
class UserService(private val repository: UserRepository) {
suspend fun createUser(name: String, email: String): Result<User> {
return try {
if (!isValidEmail(email)) {
Result.failure(IllegalArgumentException("Invalid email format"))
} else {
val user = User(
id = generateId(),
name = name,
email = email,
createdAt = kotlinx.datetime.Clock.System.now()
)
val savedUser = repository.saveUser(user)
Result.success(savedUser)
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun getUserProfile(id: String): Result<UserProfile> {
return try {
val user = repository.getUser(id)
?: return Result.failure(NoSuchElementException("User not found"))
val profile = UserProfile(
user = user,
lastActive = kotlinx.datetime.Clock.System.now(),
isOnline = checkOnlineStatus(user.id)
)
Result.success(profile)
} catch (e: Exception) {
Result.failure(e)
}
}
private fun isValidEmail(email: String): Boolean {
return email.contains("@") && email.contains(".")
}
private fun generateId(): String = "user_${kotlinx.datetime.Clock.System.now().epochSeconds}"
private suspend fun checkOnlineStatus(userId: String): Boolean {
// Platform-specific implementation needed
return getPlatform().checkUserOnline(userId)
}
}
data class UserProfile(
val user: User,
val lastActive: Instant,
val isOnline: Boolean
)
// Platform interface
expect class Platform() {
val name: String
suspend fun checkUserOnline(userId: String): Boolean
}
expect fun getPlatform(): Platform
Platform-Specific Implementations
// jvmMain/kotlin/Platform.jvm.kt
import java.net.InetAddress
actual class Platform actual constructor() {
actual val name: String = "JVM ${System.getProperty("java.version")}"
actual suspend fun checkUserOnline(userId: String): Boolean {
// JVM-specific implementation using network checks
return try {
InetAddress.getByName("api.example.com").isReachable(1000)
} catch (e: Exception) {
false
}
}
}
actual fun getPlatform(): Platform = Platform()
// androidMain/kotlin/Platform.android.kt
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
actual class Platform actual constructor() {
actual val name: String = "Android ${android.os.Build.VERSION.SDK_INT}"
actual suspend fun checkUserOnline(userId: String): Boolean {
// Android-specific implementation using ConnectivityManager
val context = AndroidContext.getApplicationContext()
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
actual fun getPlatform(): Platform = Platform()
// iosMain/kotlin/Platform.ios.kt
import platform.Foundation.NSHost
import platform.Foundation.NSString
import platform.Foundation.NSURL
actual class Platform actual constructor() {
actual val name: String = "iOS ${platform.UIKit.UIDevice.currentDevice.systemVersion}"
actual suspend fun checkUserOnline(userId: String): Boolean {
// iOS-specific implementation using NSHost
return try {
val host = NSHost.hostWithName("api.example.com")
host != null
} catch (e: Exception) {
false
}
}
}
actual fun getPlatform(): Platform = Platform()
// jsMain/kotlin/Platform.js.kt
actual class Platform actual constructor() {
actual val name: String = "Web ${js("navigator.userAgent")}"
actual suspend fun checkUserOnline(userId: String): Boolean {
// Web-specific implementation using navigator.onLine
return js("navigator.onLine") as Boolean
}
}
actual fun getPlatform(): Platform = Platform()
HTTP Client with Multiplatform
// commonMain/kotlin/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.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
class ApiClient {
private val client = HttpClient {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
suspend fun getUser(id: String): User? {
return try {
client.get("https://api.example.com/users/$id").body()
} catch (e: Exception) {
println("Failed to fetch user: ${e.message}")
null
}
}
suspend fun createUser(user: User): User? {
return try {
client.post("https://api.example.com/users") {
contentType(ContentType.Application.Json)
setBody(user)
}.body()
} catch (e: Exception) {
println("Failed to create user: ${e.message}")
null
}
}
suspend fun getAllUsers(): List<User> {
return try {
client.get("https://api.example.com/users").body()
} catch (e: Exception) {
println("Failed to fetch users: ${e.message}")
emptyList()
}
}
fun close() {
client.close()
}
}
// commonMain/kotlin/ApiUserRepository.kt
class ApiUserRepository(private val apiClient: ApiClient) : UserRepository {
override suspend fun getUser(id: String): User? {
return apiClient.getUser(id)
}
override suspend fun saveUser(user: User): User {
return apiClient.createUser(user) ?: throw RuntimeException("Failed to save user")
}
override suspend fun getAllUsers(): List<User> {
return apiClient.getAllUsers()
}
}
Database Storage (Platform-Specific)
// commonMain/kotlin/DatabaseRepository.kt
expect class DatabaseUserRepository() : UserRepository {
override suspend fun getUser(id: String): User?
override suspend fun saveUser(user: User): User
override suspend fun getAllUsers(): List<User>
}
// jvmMain/kotlin/DatabaseRepository.jvm.kt
import java.sql.DriverManager
import java.sql.Connection
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
actual class DatabaseUserRepository actual constructor() : UserRepository {
private val connection: Connection by lazy {
DriverManager.getConnection("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
.also { conn ->
conn.createStatement().execute("""
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
created_at BIGINT NOT NULL
)
""")
}
}
actual override suspend fun getUser(id: String): User? {
val stmt = connection.prepareStatement("SELECT * FROM users WHERE id = ?")
stmt.setString(1, id)
val rs = stmt.executeQuery()
return if (rs.next()) {
User(
id = rs.getString("id"),
name = rs.getString("name"),
email = rs.getString("email"),
createdAt = kotlinx.datetime.Instant.fromEpochSeconds(rs.getLong("created_at"))
)
} else null
}
actual override suspend fun saveUser(user: User): User {
val stmt = connection.prepareStatement("""
INSERT INTO users (id, name, email, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
email = excluded.email,
created_at = excluded.created_at
""")
stmt.setString(1, user.id)
stmt.setString(2, user.name)
stmt.setString(3, user.email)
stmt.setLong(4, user.createdAt.epochSeconds)
stmt.executeUpdate()
return user
}
actual override suspend fun getAllUsers(): List<User> {
val stmt = connection.createStatement()
val rs = stmt.executeQuery("SELECT * FROM users ORDER BY created_at DESC")
val users = mutableListOf<User>()
while (rs.next()) {
users.add(User(
id = rs.getString("id"),
name = rs.getString("name"),
email = rs.getString("email"),
createdAt = kotlinx.datetime.Instant.fromEpochSeconds(rs.getLong("created_at"))
))
}
return users
}
}
// androidMain/kotlin/DatabaseRepository.android.kt
import android.content.Context
import androidx.room.*
import androidx.room.Room
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: String,
val name: String,
val email: String,
val createdAt: Long
)
@Dao
interface UserDao {
@Query("SELECT * FROM users WHERE id = :id")
suspend fun getUser(id: String): UserEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: UserEntity): Long
@Query("SELECT * FROM users ORDER BY createdAt DESC")
suspend fun getAllUsers(): List<UserEntity>
}
@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
actual class DatabaseUserRepository actual constructor() : UserRepository {
private val database by lazy {
Room.databaseBuilder(
AndroidContext.getApplicationContext(),
AppDatabase::class.java,
"app_database"
).build()
}
actual override suspend fun getUser(id: String): User? {
return database.userDao().getUser(id)?.let { entity ->
User(
id = entity.id,
name = entity.name,
email = entity.email,
createdAt = kotlinx.datetime.Instant.fromEpochSeconds(entity.createdAt)
)
}
}
actual override suspend fun saveUser(user: User): User {
val entity = UserEntity(
id = user.id,
name = user.name,
email = user.email,
createdAt = user.createdAt.epochSeconds
)
database.userDao().insertUser(entity)
return user
}
actual override suspend fun getAllUsers(): List<User> {
return database.userDao().getAllUsers().map { entity ->
User(
id = entity.id,
name = entity.name,
email = entity.email,
createdAt = kotlinx.datetime.Instant.fromEpochSeconds(entity.createdAt)
)
}
}
}
// iosMain/kotlin/DatabaseRepository.ios.kt
import platform.Foundation.*
actual class DatabaseUserRepository actual constructor() : UserRepository {
private val userDefaults = NSUserDefaults.standardUserDefaults
actual override suspend fun getUser(id: String): User? {
val key = "user_$id"
val userData = userDefaults.dataForKey(key) ?: return null
val jsonString = NSString.create(userData, NSUTF8StringEncoding) as String
return try {
kotlinx.serialization.json.Json.decodeFromString<User>(jsonString)
} catch (e: Exception) {
null
}
}
actual override suspend fun saveUser(user: User): User {
val key = "user_${user.id}"
val jsonString = kotlinx.serialization.json.Json.encodeToString(user)
val data = (jsonString as NSString).dataUsingEncoding(NSUTF8StringEncoding)
userDefaults.setObject(data, key)
userDefaults.synchronize()
return user
}
actual override suspend fun getAllUsers(): List<User> {
val allKeys = userDefaults.dictionaryRepresentation().keys
val userKeys = allKeys.filterIsInstance<String>().filter { it.startsWith("user_") }
return userKeys.mapNotNull { key ->
val userData = userDefaults.dataForKey(key) ?: return@mapNotNull null
val jsonString = NSString.create(userData, NSUTF8StringEncoding) as String
try {
kotlinx.serialization.json.Json.decodeFromString<User>(jsonString)
} catch (e: Exception) {
null
}
}
}
}
Testing Multiplatform Code
// commonTest/kotlin/UserServiceTest.kt
import kotlin.test.*
import kotlinx.coroutines.test.runTest
class UserServiceTest {
private class TestUserRepository : UserRepository {
private val users = mutableMapOf<String, User>()
override suspend fun getUser(id: String): User? = users[id]
override suspend fun saveUser(user: User): User {
users[user.id] = user
return user
}
override suspend fun getAllUsers(): List<User> = users.values.toList()
}
private lateinit var userService: UserService
private lateinit var repository: TestUserRepository
@BeforeTest
fun setup() {
repository = TestUserRepository()
userService = UserService(repository)
}
@Test
fun `should create user with valid email`() = runTest {
// Given
val name = "John Doe"
val email = "[email protected]"
// When
val result = userService.createUser(name, email)
// Then
assertTrue(result.isSuccess)
val user = result.getOrNull()!!
assertEquals(name, user.name)
assertEquals(email, user.email)
assertTrue(user.id.startsWith("user_"))
}
@Test
fun `should fail to create user with invalid email`() = runTest {
// Given
val name = "John Doe"
val email = "invalid-email"
// When
val result = userService.createUser(name, email)
// Then
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is IllegalArgumentException)
}
@Test
fun `should get user profile successfully`() = runTest {
// Given
val user = User(
id = "test123",
name = "Jane Doe",
email = "[email protected]",
createdAt = kotlinx.datetime.Clock.System.now()
)
repository.saveUser(user)
// When
val result = userService.getUserProfile(user.id)
// Then
assertTrue(result.isSuccess)
val profile = result.getOrNull()!!
assertEquals(user, profile.user)
assertNotNull(profile.lastActive)
}
@Test
fun `should handle user not found`() = runTest {
// Given
val nonExistentId = "does-not-exist"
// When
val result = userService.getUserProfile(nonExistentId)
// Then
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is NoSuchElementException)
}
}
// Platform-specific tests
// jvmTest/kotlin/PlatformTest.jvm.kt
import kotlin.test.*
class JvmPlatformTest {
@Test
fun `should return JVM platform name`() {
val platform = getPlatform()
assertTrue(platform.name.startsWith("JVM"))
}
@Test
fun `should check online status`() = runTest {
val platform = getPlatform()
// Note: This test depends on network connectivity
val isOnline = platform.checkUserOnline("test")
// Just verify it doesn't throw an exception
assertNotNull(isOnline)
}
}
Build Configuration
// gradle.properties
kotlin.code.style=official
kotlin.mpp.enableCInteropCommonization=true
kotlin.mpp.androidSourceSetLayoutVersion=2
android.useAndroidX=true
android.nonTransitiveRClass=true
# CocoaPods
kotlin.native.cocoapods.platform=ios
kotlin.native.cocoapods.archs=arm64
# Compose Multiplatform
org.jetbrains.compose.experimental.uikit.enabled=true
// gradle/libs.versions.toml
[versions]
kotlin = "1.9.20"
compose = "1.5.4"
ktor = "2.3.5"
coroutines = "1.7.3"
datetime = "0.4.1"
serialization = "1.6.0"
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
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" }
# Platform-specific
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" }
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Multiplatform Best Practices
- Common First: Start with common code, add platform-specific only when needed
- Expect/Actual: Use for platform-specific APIs, avoid overuse
- Dependency Management: Choose libraries with multiplatform support
- Testing: Test common code thoroughly, add platform-specific tests for unique functionality
- Architecture: Design with multiplatform in mind from the beginning
Common Challenges
- Platform APIs: Not all platform APIs have multiplatform equivalents
- Build Complexity: Managing multiple targets increases build complexity
- Debugging: Platform-specific issues can be harder to debug
- Performance: Ensure performance is acceptable on all target platforms
Use Cases
- Business Logic: Share domain models, validation, and business rules
- Networking: Common API clients and data models
- Data Processing: Shared algorithms and data transformations
- Utilities: Common utility functions and extensions
Practice Exercises
- Create a multiplatform library for user authentication
- Build a cross-platform note-taking app with local storage
- Implement a shared data synchronization layer
- Design a multiplatform analytics SDK
Architecture Notes
- Code Sharing: Typically 60-90% code sharing depending on application complexity
- Maintenance: Single codebase reduces maintenance overhead
- Team Efficiency: Enables smaller teams to target multiple platforms
- Consistency: Ensures consistent behavior across platforms