Esta pagina se ve mejor con JavaScript habilitado

De Retrofit/OkHttp a Ktor en Kotlin Multiplatform: una primera migración práctica

 ·  ☕ 6 minutos lectura  ·  ✍️ Ignacio Carrión

De Retrofit/OkHttp a Ktor en Kotlin Multiplatform: una primera migración práctica

Si quieres comenzar a migrar una app Android existente a Kotlin Multiplatform (KMP), la capa de red es un excelente primer paso. Ktor Client funciona en múltiples plataformas y te permite mantener una sola pila HTTP para Android, iOS, Desktop y más. Esta guía muestra cómo migrar de Retrofit/OkHttp a Ktor con motores CIO u OkHttp, manteniendo el impacto limitado a la capa remota cuando tu arquitectura está bien diseñada.


¿Por qué empezar por red? ¿Y qué debería cambiar?

Con una arquitectura modular limpia (por ejemplo, Clean Architecture + Repository/DataSources), solo deberían cambiar tus implementaciones de DataSources remotas:

  • La capa de dominio (entidades, casos de uso) permanece intacta
  • Los contratos de repositorio permanecen iguales
  • Las fuentes de datos locales (por ejemplo, Room/SQLDelight) permanecen iguales
  • Solo la implementación remota cambia de Retrofit → Ktor

Esto reduce el riesgo y permite una migración incremental.


Dependencias y configuración (Módulo compartido KMP)

Añade Ktor Client y Kotlinx Serialization a tu módulo compartido. Usa el motor CIO u OkHttp en Android; usa CIO o Darwin en iOS (CIO permite compartir el mismo motor en móviles). Mantén las versiones alineadas con tu configuración de Kotlin/AGP.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// build.gradle.kts del módulo compartido
plugins {
    kotlin("multiplatform")
    id("com.android.library")
    kotlin("plugin.serialization") // para kotlinx.serialization
}

kotlin {
    androidTarget()
    iosX64(); iosArm64(); iosSimulatorArm64()

    sourceSets {
        val ktorVersion = "2.3.12"
        val commonMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-core:$ktorVersion")
                implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
                implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
                implementation("io.ktor:ktor-client-logging:$ktorVersion")
                // Opcional: Resources, Auth, etc.
                // implementation("io.ktor:ktor-client-auth:$ktorVersion")
            }
        }
        val androidMain by getting {
            dependencies {
                // Elige UNO: OkHttp o CIO
                implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
                // implementation("io.ktor:ktor-client-cio:$ktorVersion")
            }
        }
        val iosMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-cio:$ktorVersion") // o usa Darwin para la pila nativa
                // implementation("io.ktor:ktor-client-darwin:$ktorVersion")
            }
        }
    }
}

Consejo: Prefiere CIO tanto en Android como en iOS para compartir el mismo motor: usa ktor-client-cio en ambos. Alternativamente, mantén OkHttp en Android (ktor-client-okhttp) y Darwin en iOS (ktor-client-darwin).


Factoría de HttpClient con motores (CIO u OkHttp)

Crea un único lugar donde configuras tu HttpClient. Usa expect/actual para proporcionar el motor por plataforma y mantener el resto del código en común.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// commonMain (imports omitidos en el snippet)
expect fun provideEngine(): HttpClientEngineFactory<*>

fun createHttpClient(baseUrl: String, enableLogs: Boolean = true): HttpClient =
    HttpClient(provideEngine()) {
        expectSuccess = false // gestionaremos manualmente las respuestas no 2xx

        install(ContentNegotiation) {
            json(
                Json {
                    ignoreUnknownKeys = true
                    isLenient = true
                    encodeDefaults = true
                }
            )
        }

        install(HttpTimeout) {
            requestTimeoutMillis = 15_000
            connectTimeoutMillis = 10_000
            socketTimeoutMillis = 15_000
        }

        if (enableLogs) {
            install(Logging) {
                logger = Logger.DEFAULT
                level = LogLevel.INFO
            }
        }

        // Cabeceras por defecto o base URL se pueden manejar por petición o con un helper
    }

Motores por plataforma:

1
2
// androidMain (imports omitidos)
actual fun provideEngine(): HttpClientEngineFactory<*> = OkHttp
1
2
// jvmMain (si aplica) (imports omitidos)
actual fun provideEngine(): HttpClientEngineFactory<*> = CIO
1
2
// iosMain (imports omitidos)
actual fun provideEngine(): HttpClientEngineFactory<*> = CIO // o Darwin si prefieres la pila nativa

Configuración específica de OkHttp (opcional):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// androidMain - dentro de HttpClient(OkHttp) { engine { ... } } si lo incrustas
engine {
    config {
        followRedirects(true)
        retryOnConnectionFailure(true)
    }
    addInterceptor { chain ->
        val request = chain.request().newBuilder()
            .header("X-App-Version", "1.0")
            .build()
        chain.proceed(request)
    }
}

Ajustes de CIO:

1
2
3
4
5
6
// jvmMain - con CIO
engine {
    requestTimeout = 15_000
    threadsCount = 4
    pipelining = true
}

De servicios de Retrofit a llamadas con Ktor

Servicio típico de Retrofit:

1
2
3
4
5
6
7
8
// Retrofit
interface UserService {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: String): UserDto

    @POST("users")
    suspend fun createUser(@Body body: CreateUserRequest): UserDto
}

Sustitución con Ktor en la implementación remota (mantén la interfaz que ya usa tu repositorio):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Contrato remoto común usado por el Repositorio
data class CreateUserRequest(val name: String)

interface UserRemote {
    suspend fun getUser(id: String): UserDto
    suspend fun createUser(body: CreateUserRequest): UserDto
}

class UserRemoteImpl(
    private val client: HttpClient,
    private val baseUrl: String,
) : UserRemote {
    override suspend fun getUser(id: String): UserDto =
        client.get("$baseUrl/users/$id").body()

    override suspend fun createUser(body: CreateUserRequest): UserDto =
        client.post("$baseUrl/users") {
            contentType(io.ktor.http.ContentType.Application.Json)
            setBody(body)
        }.body()
}

Parámetros de query y cabeceras mapean directamente:

1
2
3
4
5
6
7
client.get("$baseUrl/search") {
    url {
        parameters.append("q", "john")
        parameters.append("page", "1")
    }
    headers.append("Authorization", "Bearer $token")
}

Gestión de errores y mapeo

A diferencia de Retrofit, Ktor no lanza excepción para códigos no-2xx si expectSuccess = false. Maneja las respuestas explícitamente o captura excepciones de cliente/servidor.

1
2
3
4
5
6
7
8
suspend fun <T> HttpResponse.safeBody(): T {
    if (status.isSuccess()) return body()
    val raw = bodyAsText()
    // Parsea el error de dominio si tu backend devuelve JSON estructurado
    throw ApiException(status, raw)
}

class ApiException(val status: HttpStatusCode, message: String) : Exception(message)

Uso:

1
2
val response = client.get("$baseUrl/users/$id")
val user: UserDto = response.safeBody()

Alternativamente, configura expectSuccess = true y captura ClientRequestException/ServerResponseException.


Autenticación, interceptores y logging

  • DefaultRequest: cabeceras aplicadas a cada petición
  • Auth plugin: flujos Bearer/Basic si lo prefieres
  • Logging plugin: logs de request/response (evita PII en producción)
1
2
3
4
5
6
7
8
9
HttpClient(provideEngine()) {
    install(DefaultRequest) {
        headers.append("Accept", "application/json")
        // headers.append("Authorization", "Bearer $token") // inyecta el token vía DI
    }
    install(Logging) {
        level = LogLevel.HEADERS
    }
}

Con el motor OkHttp, también puedes reutilizar Interceptors existentes (p. ej., inspección de red, reintentos personalizados), como se mostró antes.


Mantén intactos los límites de arquitectura

  • Mantén tu interfaz UserRemote (o similar) en commonMain
  • Migra solo la implementación de Retrofit → Ktor
  • Los repositorios dependen de la interfaz, por lo que el resto de la app no cambia
1
2
3
class UserRepository(private val remote: UserRemote) {
    suspend fun getUser(id: String) = remote.getUser(id)
}

Testear con MockEngine de Ktor

Puedes testear tu implementación remota sin tocar la red usando MockEngine.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fun testClient(): HttpClient = HttpClient(MockEngine) {
    engine {
        addHandler { request ->
            if (request.url.fullPath == "/users/123") {
                respond(
                    content = """{"id": "123", "name": "Jane"}""",
                    headers = headersOf(HttpHeaders.ContentType, "application/json"),
                    status = HttpStatusCode.OK
                )
            } else respondError(HttpStatusCode.NotFound)
        }
    }
    install(ContentNegotiation) { json() }
}

Checklist de migración

  • Añade dependencias de Ktor y kotlinx-serialization al módulo compartido
  • Crea una factoría de HttpClient con motor por plataforma (Android: OkHttp o CIO; iOS: CIO o Darwin)
  • Instala ContentNegotiation(Json), HttpTimeout, Logging
  • Porta tus servicios de Retrofit a una implementación remota basada en Ktor
  • Mantén repositorio/dominio sin cambios; intercambia solo la implementación remota en el DI
  • Maneja errores de forma consistente; considera un wrapper Result/Either
  • Escribe tests con MockEngine para casos de éxito y error

Decisiones frecuentes

  • ¿CIO vs OkHttp en Android?
    • OkHttp: reutiliza interceptors/TLS/pinning existentes; depuración familiar
    • CIO: motor Ktor/JVM puro, menos dependencias externas
  • Serialización: kotlinx-serialization es idiomática en KMP; migra tus DTOs a @Serializable
  • Caché: impleméntala en la capa de repositorio; Ktor no impone políticas

Conclusión

Migrar de Retrofit/OkHttp a Ktor es un primer paso enfocado y de bajo riesgo hacia KMP. Con una arquitectura limpia, solo cambias las interfaces/implementaciones remotas mientras el resto de la app permanece estable. Empieza con una funcionalidad, valida con tests y expande con confianza en tu base de código.

compartir en

Ignacio Carrión
Escrito por
Ignacio Carrión
Android Developer