Kotlin Multiplatform: compartilhando lógica entre Android, iOS e web

1. Introdução ao Kotlin Multiplatform (KMP)

Kotlin Multiplatform (KMP) é uma tecnologia da JetBrains que permite compartilhar código-fonte entre múltiplas plataformas — Android, iOS, web e desktop — mantendo a liberdade de construir interfaces nativas em cada uma delas. Diferentemente de soluções como Flutter (que impõe um framework de UI próprio) ou React Native (que abstrai componentes nativos via JavaScript), o KMP adota uma abordagem modular: você escreve a lógica de negócio uma única vez no módulo commonMain e implementa apenas as partes específicas de cada plataforma nos módulos androidMain, iosMain e wasmJsMain.

A arquitetura básica do KMP é centrada em três camadas:
- Módulo compartilhado (shared): contém toda a lógica de negócio, modelos de dados, chamadas de API e regras de validação.
- Módulos específicos de plataforma: implementam funcionalidades nativas como armazenamento local, sensores ou acesso a hardware.
- Aplicativos finais: consomem o módulo compartilhado e constroem a UI nativa (Android com Jetpack Compose, iOS com SwiftUI, web com Compose for Web ou React).

As vantagens estratégicas são claras: redução de custos de desenvolvimento, consistência de regras de negócio entre plataformas e integração total com ecossistemas existentes (você pode adicionar KMP a um projeto Android ou iOS já em produção).

2. Configuração do Projeto KMP com Compose Multiplatform

Para iniciar um projeto KMP, utilize o IntelliJ IDEA ou Android Studio com o plugin Kotlin Multiplatform instalado. A estrutura típica contém dois módulos principais:

  • shared: módulo Kotlin puro com targets Android, iOS e Web.
  • composeApp: módulo que utiliza Compose Multiplatform para construir a UI compartilhada.

Dependências essenciais no arquivo build.gradle.kts do módulo shared:

plugins {
    kotlin("multiplatform") version "2.0.21"
    id("com.android.library")
    kotlin("plugin.serialization") version "2.0.21"
}

kotlin {
    androidTarget()
    iosX64()
    iosArm64()
    iosSimulatorArm64()
    wasmJs {
        browser()
    }

    sourceSets {
        commonMain.dependencies {
            implementation("io.ktor:ktor-client-core:3.0.1")
            implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
            implementation("app.cash.sqldelight:runtime:2.0.2")
        }
        androidMain.dependencies {
            implementation("io.ktor:ktor-client-okhttp:3.0.1")
            implementation("app.cash.sqldelight:android-driver:2.0.2")
        }
        iosMain.dependencies {
            implementation("io.ktor:ktor-client-darwin:3.0.1")
            implementation("app.cash.sqldelight:native-driver:2.0.2")
        }
        wasmJsMain.dependencies {
            implementation("io.ktor:ktor-client-js:3.0.1")
            implementation("app.cash.sqldelight:wasm-driver:2.0.2")
        }
    }
}

A configuração de targets define quais plataformas serão suportadas. Para iOS, são necessários três targets (x64, arm64 e simulador). Para web, utiliza-se wasmJs (WebAssembly) ou js (JavaScript tradicional).

3. Compartilhando Lógica de Negócio com expect/actual

O mecanismo expect/actual permite declarar funções ou classes no módulo comum que exigem implementações específicas por plataforma. Exemplo prático: acesso a preferências do usuário.

No módulo commonMain:

// commonMain
expect class Settings {
    fun getString(key: String): String?
    fun putString(key: String, value: String)
}

Implementação para Android (androidMain):

// androidMain
actual class Settings(private val context: android.content.Context) {
    private val prefs = context.getSharedPreferences("app_prefs", android.content.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()
    }
}

Implementação para iOS (iosMain):

// iosMain
actual class Settings {
    private val defaults = platform.Foundation.NSUserDefaults.standardUserDefaults

    actual fun getString(key: String): String? = defaults.stringForKey(key)
    actual fun putString(key: String, value: String) {
        defaults.setObject(value, forKey = key)
    }
}

Implementação para Web (wasmJsMain):

// wasmJsMain
actual class Settings {
    actual fun getString(key: String): String? = 
        kotlinx.browser.window.localStorage.getItem(key)

    actual fun putString(key: String, value: String) {
        kotlinx.browser.window.localStorage.setItem(key, value)
    }
}

4. Consumo de APIs com Ktor Client

Ktor Client é a biblioteca HTTP oficial da JetBrains para KMP. Configuração no módulo comum:

// commonMain
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class User(val id: Int, val name: String, val email: String)

class ApiClient {
    private val client = HttpClient {
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true })
        }
        install(HttpTimeout) {
            requestTimeoutMillis = 30_000
            connectTimeoutMillis = 10_000
        }
    }

    suspend fun getUsers(): List<User> {
        val response: HttpResponse = client.get("https://jsonplaceholder.typicode.com/users")
        return response.body()
    }
}

Cada plataforma utiliza sua engine específica: OkHttp para Android, Darwin para iOS e JS para Web. O Ktor gerencia automaticamente a engine correta com base no target.

5. Persistência de Dados com SQLDelight

SQLDelight gera código Kotlin a partir de arquivos .sq compartilhados. Crie o arquivo Task.sq no módulo commonMain:

-- commonMain/sqldelight/app/cash/sqldelight/tasks/Task.sq

CREATE TABLE Task (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    completed INTEGER NOT NULL DEFAULT 0
);

getAll:
SELECT * FROM Task ORDER BY id DESC;

insert:
INSERT INTO Task(title, completed) VALUES (?, ?);

updateCompleted:
UPDATE Task SET completed = ? WHERE id = ?;

Repositório compartilhado:

// commonMain
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import kotlinx.coroutines.flow.Flow

class TaskRepository(private val driver: SqlDriver) {
    private val database = Database(driver)
    private val queries = database.taskQueries

    fun getAllTasks(): Flow<List<Task>> = queries.getAll().asFlow().mapToList()

    fun addTask(title: String) {
        queries.insert(title, 0)
    }

    fun toggleTask(id: Long, completed: Boolean) {
        queries.updateCompleted(if (completed) 1 else 0, id)
    }
}

As implementações do driver específico são fornecidas via expect/actual ou injeção de dependência.

6. Navegação e Estado com Compose Multiplatform

Compose Multiplatform permite compartilhar a UI entre Android, iOS e Web. Exemplo de navegação simples:

// composeApp/src/commonMain
import androidx.compose.runtime.*
import androidx.compose.material3.*
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController

@Composable
fun App() {
    MaterialTheme {
        val navController = rememberNavController()
        NavHost(navController, startDestination = "home") {
            composable("home") {
                HomeScreen(
                    onNavigateToDetail = { taskId ->
                        navController.navigate("detail/$taskId")
                    }
                )
            }
            composable("detail/{taskId}") { backStackEntry ->
                val taskId = backStackEntry.arguments?.getString("taskId") ?: "0"
                DetailScreen(taskId.toLong())
            }
        }
    }
}

O gerenciamento de estado utiliza ViewModel com StateFlow, compatível com KMP via bibliotecas como androidx.lifecycle:lifecycle-viewmodel-compose (para Android) e adaptações para iOS/Web.

7. Testes e CI/CD para Projetos KMP

Testes unitários no módulo comum:

// commonTest
import kotlin.test.Test
import kotlin.test.assertEquals

class TaskRepositoryTest {
    @Test
    fun testAddTask() {
        // Teste com driver in-memory
        val driver = app.cash.sqldelight.drivers.native.NativeSqliteDriver(Database.Schema, "test.db")
        val repo = TaskRepository(driver)
        repo.addTask("Test task")
        assertEquals(1, repo.getAllTasks().first().size)
    }
}

Pipeline de CI com GitHub Actions:

name: KMP CI
on: [push]
jobs:
  build:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: 'zulu'
          java-version: '17'
      - name: Build Android
        run: ./gradlew :shared:assembleDebug
      - name: Build iOS
        run: ./gradlew :shared:iosX64MainBinaries
      - name: Build Web
        run: ./gradlew :composeApp:wasmJsBrowserDistribution
      - name: Run Tests
        run: ./gradlew :shared:allTests

8. Casos de Uso Reais e Limitações em 2025

Empresas como Netflix utilizam KMP para compartilhar lógica de streaming entre Android e iOS. Cash App (Block) usa KMP para seus modelos financeiros compartilhados. Em 2025, o suporte a bibliotecas nativas melhorou significativamente, mas ainda existem limitações:

  • Complexidade de configuração iOS: requer Xcode e conhecimento de Swift/Objective-C para bridges nativas.
  • Bibliotecas de terceiros: nem todas as bibliotecas Kotlin suportam KMP (principalmente as que dependem de reflexão ou Java puro).
  • Performance em Web: o target Wasm ainda está amadurecendo, com overhead em comparação a JavaScript puro.

O futuro do KMP inclui Kotlin 2.1 com melhorias no compilador Wasm, integração com WASI para aplicações server-side e suporte expandido a desktop via JVM e LLVM.

Referências