Saltar al contenido
K
Cimientos del código compartidoPaso a paso42 min de lectura · 12 min de máquina Intermedio

expect/actual frente a interfaces

Al terminar: Elegir bien entre expect/actual y una interfaz con implementaciones por plataforma.

expect y actual son las palabras clave con las que KMP declara algo en el código común y lo implementa en cada plataforma. Son lo primero que se enseña de KMP, y también lo primero que casi todo el mundo usa de más. Esta lección va sobre cuándo no usarlos.

Al terminar, el módulo compartido tendrá una sola declaración expect (más adelante llegará una segunda, pequeña e interna, con su motivo), y no la llamará el dominio: la llamará quien arranca cada app. El cambio concreto es minúsculo —Greeting pasa a recibir la Platform por constructor— y desbloquea un test que hasta hoy no se podía escribir.

De paso verás el primer nombre feo que Kotlin/Native le pone a algo en Swift. Se deja a la vista a propósito: entenderlo es la lección Los tipos que cruzan a Swift y arreglarlo es Las funciones que cruzan a Swift.

Qué hacen, para empezar por lo fácil

En commonMain declaras una forma sin cuerpo con la palabra expect («se espera»), y cada plataforma la rellena con actual («real»). Esta es la parte común del fichero Platform.kt del proyecto, en resumen: una interfaz y la función sin cuerpo que promete devolverla.

Platform.kt, la parte común (resumen, con comentarios)
interface Platform {            // el contrato: algo que sabe decir su nombre
    val name: String
}
 
expect fun currentPlatform(): Platform   // la promesa: sin cuerpo; cada plataforma dirá cómo

Y estos son dos de los tres ficheros que cumplen la promesa, enteros; el de la JVM es igual y lee System.getProperty("java.version").

shared/src/androidMain/kotlin/com/sebastiannarvaez/pokedex/Platform.android.kt
package com.sebastiannarvaez.pokedex
 
import android.os.Build
 
private class AndroidPlatform : Platform {
    override val name: String = "Android ${Build.VERSION.SDK_INT}"
}
 
actual fun currentPlatform(): Platform = AndroidPlatform()
shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/Platform.ios.kt
package com.sebastiannarvaez.pokedex
 
import platform.UIKit.UIDevice
 
private class IosPlatform : Platform {
    override val name: String =
        UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
 
actual fun currentPlatform(): Platform = IosPlatform()

El compilador se encarga de que ninguna plataforma se quede sin su actual. Si te falta uno, no compila. Con el de la JVM borrado, el error es este:

e: …/Platform.kt:22:1 The 'expect' declaration 'currentPlatform' has no 'actual' declaration in module '<commonMain> for JVM'.

El compilador te dice qué promesa no se cumplió (currentPlatform) y para qué destino (la JVM). Hasta aquí, todo bien.

La recomendación oficial va en contra del uso ingenuo

Esto sorprende, porque expect/actual es lo que aparece en casi todos los tutoriales de KMP:

Traducido: una interfaz en commonMain con implementaciones por plataforma inyectadas es el camino por defecto. expect/actual es la excepción.

Cuándo expect/actual sí es la respuesta

No está prohibido. Hay tres razones donde no hay alternativa razonable.

CasoPor quéEjemplo
Heredar de una clase de plataformaDesde commonMain no puedes heredar de una clase que solo existe en un sistema. Hace falta expect classUIViewController, la clase base de las pantallas de iOS
actual typealias sobre un tipo nativoPara que un tipo común sea un tipo de plataforma, sin envoltorioun typealias común que en cada sistema apunta a su propio tipo de fecha
No hay construcción equivalenteUna interfaz no puede expresar una anotación (una etiqueta como @Test) que solo existe en una plataforma@Parcelize, de Android, que en iOS no existe

El primer caso, con un ejemplo mínimo: así se promete una clase que en iOS es un UIViewController.

ejemplo mínimo: heredar de una clase de plataforma (con comentarios)
// commonMain: la promesa. No sabe nada de iOS.
expect class PantallaBase()
 
// iosMain: el cumplimiento. Aquí sí se puede nombrar UIViewController.
actual class PantallaBase actual constructor() : UIViewController(nibName = null, bundle = null)

Esto compila en la JVM, en Android y en iOS (cada una con su propio actual). Una interfaz no podría hacerlo, porque una interfaz no puede ser «un UIViewController».

Fíjate en que ninguno de los tres es «tengo algo distinto en cada plataforma». Eso, por sí solo, no justifica expect/actual: la diferencia está en si la plataforma te impone la forma —heredar de una clase suya, ser un tipo suyo— o solo el contenido.

Y hay una alternativa más que se olvida: pasar una función

Muchas veces no necesitas ni una cosa ni la otra: con pasar una función ya está resuelto. Greeting, en el paso 2, recibirá una interfaz (algo que sabe decir su nombre) y no le importará de dónde viene. Si solo necesitara el nombre, podría recibir directamente una función, y funcionaría igual:

ejemplo mínimo: recibir una función en vez de una interfaz (con comentarios)
class Saludo(private val nombre: () -> String) {   // en vez de una interfaz, una función sin parámetros que devuelve texto
    fun frase(): String = "Hola desde ${nombre()}"   // la llama cuando la necesita
}
 
Saludo { "Plataforma de prueba" }.frase()          // en el test: una lambda de una línea; devuelve «Hola desde Plataforma de prueba»

Cómo se conectan las implementaciones

La documentación es explícita, y adelanta la lección Koin: un solo grafo en commonMain:

Es decir: elige un mecanismo. Mezclar los dos para resolver el mismo problema deja un proyecto donde nunca sabes por dónde buscar quién construye qué. Aquí currentPlatform() sobrevivirá, pero acabará siendo una definición más del grafo en Koin: un solo grafo en commonMain.

Ejemplo: una interfaz y su doble

Antes de tocar el proyecto, la idea de la recomendación oficial con datos ajenos: un saludador que dice «Buenos días» o «Buenas tardes» según la hora. Si el saludador pidiera la hora al sistema, el test dependería de cuándo lo ejecutes; si recibe un reloj, en el test le das uno de mentira.

ejemplo mínimo: una interfaz y su doble (con comentarios)
import kotlin.test.Test
import kotlin.test.assertEquals
 
// El contrato: cualquier cosa que sepa decir qué hora es.
interface Reloj {
    val hora: Int                          // de 0 a 23
}
 
// Recibe el reloj por el constructor: no sabe si es el del móvil o uno de mentira.
class Saludador(private val reloj: Reloj) {
    fun saludo(): String =
        if (reloj.hora < 12) "Buenos días" else "Buenas tardes"
}
 
// En el test: un reloj de mentira, de una línea, que marca la hora que le digas.
private class RelojFalso(override val hora: Int) : Reloj
 
class SaludadorTest {
 
    @Test
    fun porLaMananaDiceBuenosDias() {
        assertEquals("Buenos días", Saludador(RelojFalso(9)).saludo())   // a las 9, «Buenos días»
    }
 
    @Test
    fun porLaTardeDiceBuenasTardes() {
        assertEquals("Buenas tardes", Saludador(RelojFalso(15)).saludo()) // a las 15, «Buenas tardes»
    }
}

Los dos tests pasan, en la JVM, en Android y en el simulador de iOS. Y si el test espera mal (por ejemplo, «Buenos días» a las 15), el fallo se lee así en la JVM y en Android:

org.junit.ComparisonFailure: expected:<Buen[os día]s> but was:<Buen[as tarde]s>

JUnit (la herramienta de tests de la JVM) marca entre corchetes lo que difiere entre lo esperado y lo que salió. Ese formato es de la JVM; en el simulador de iOS el mismo fallo sale con otro aspecto, sin corchetes:

kotlin.AssertionError: Expected <Buenos días>, actual <Buenas tardes>.

Reto · Ejercicio: una batería que se puede fingir

Este es el primer ejercicio del curso de KMP, y vive en una carpeta llamada ejercicios: es práctica tuya, no forma parte de la app que estás construyendo. No se borra, y el proyecto debe compilar siempre con ella dentro. Hay una ejercicios en commonMain (para el código) y otra en commonTest (para los tests); son dos carpetas distintas, y los demás ejercicios del curso dirán en cuál va cada fichero.

Para crearlas, con la vista Project, haz clic derecho sobre la carpeta shared → New → File y escribe la ruta completa src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/bateria/Bateria.kt (Android Studio crea las carpetas que falten). Para el test, lo mismo con src/commonTest/kotlin/com/sebastiannarvaez/pokedex/ejercicios/bateria/AvisoDeBateriaTest.kt. En los demás ejercicios del curso bastará con crear una subcarpeta nueva dentro de ejercicios.

En Bateria.kt, en el paquete com.sebastiannarvaez.pokedex.ejercicios.bateria, declara:

  • un contrato Bateria con una propiedad de solo lectura porcentaje (un entero);
  • una clase AvisoDeBateria que recibe una Bateria por su constructor y tiene una función mensaje(). Si el porcentaje es menor que 20, devuelve Batería baja: 15 % (con el número que sea); si no, Batería bien: 80 %.

En el test escribe una batería de mentira que devuelva el número que le pases, y dos tests: con 15 el mensaje es exactamente Batería baja: 15 % y con 80, Batería bien: 80 %. Ejecuta ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test.

Resultado esperado:

BUILD SUCCESSFUL

y, en los informes de tests, AvisoDeBateriaTest con 2 tests en cada destino. Los informes son páginas web que Gradle deja en shared/build/reports/tests/, una carpeta por destino (jvmTest, testAndroidHostTest, iosSimulatorArm64Test); se abren con, por ejemplo, open shared/build/reports/tests/jvmTest/index.html.

Ver solución
shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/bateria/Bateria.kt
package com.sebastiannarvaez.pokedex.ejercicios.bateria
 
// El contrato: cualquier cosa que sepa decir cuánta batería queda.
interface Bateria {
    val porcentaje: Int
}
 
// Recibe la batería por constructor: no sabe si es la de un móvil o una de mentira.
class AvisoDeBateria(private val bateria: Bateria) {
    fun mensaje(): String =
        if (bateria.porcentaje < 20) "Batería baja: ${bateria.porcentaje} %"
        else "Batería bien: ${bateria.porcentaje} %"
}
shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/ejercicios/bateria/AvisoDeBateriaTest.kt
package com.sebastiannarvaez.pokedex.ejercicios.bateria
 
import kotlin.test.Test
import kotlin.test.assertEquals
 
// Una batería de mentira: devuelve el porcentaje que le digas.
private class BateriaFalsa(override val porcentaje: Int) : Bateria
 
class AvisoDeBateriaTest {
 
    @Test
    fun conPocaBateriaAvisa() {
        assertEquals("Batería baja: 15 %", AvisoDeBateria(BateriaFalsa(15)).mensaje())
    }
 
    @Test
    fun conBateriaSuficienteNoAvisa() {
        assertEquals("Batería bien: 80 %", AvisoDeBateria(BateriaFalsa(80)).mensaje())
    }
}

Con expect fun bateriaDelSistema() este test no se podría escribir: la batería sería la del sitio donde corre. Es lo mismo que vas a hacer ahora con Greeting.

Los pasos: la plataforma entra por el constructor

Paso 1 · Platform.kt. Por eso el fichero común del proyecto tiene la forma que tiene. En la vista Project, abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/Platform.kt. Sustituye su contenido por este: el código no cambia, los comentarios sí, y dejan por escrito el porqué.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/Platform.kt
package com.sebastiannarvaez.pokedex
 
/**
 * El nombre y la version del sistema donde corre este codigo.
 *
 * Es una interfaz y no una `expect class` a proposito. JetBrains desaconseja
 * las clases `expect` cuando basta una interfaz, y la razon practica se ve en
 * los tests: de una interfaz se escribe una implementacion falsa en tres
 * lineas, y de una `expect class` no se puede.
 */
interface Platform {
    val name: String
}
 
/**
 * Una de las fronteras `expect` del modulo (la otra es `ioDispatcher()`).
 *
 * No se llama desde el codigo de dominio: la llama quien arranca la app, una
 * sola vez, y a partir de ahi la `Platform` viaja como parametro. Cuantas menos
 * llamadas tenga esta funcion, menos codigo queda atado a la plataforma.
 */
expect fun currentPlatform(): Platform
shared/src
commonMain/kotlin/com/sebastiannarvaez/pokedex
Platform.ktsolo comentarios (paso 1)
Greeting.ktse modifica (paso 2)
commonTest/kotlin/com/sebastiannarvaez/pokedex
GreetingTest.ktse modifica (paso 3)
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android
MainActivity.ktse modifica (paso 4)
iosApp/iosApp
ContentView.swiftse modifica (paso 5)
Un cambio en commonMain y tres llamadores que se enteran: la app de Android, el test y la app de iOS.

Paso 2 · Greeting.kt. Ábrelo en shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/Greeting.kt y sustituye su contenido. Deja de llamar a currentPlatform() por dentro y recibe la Platform. Es el fichero entero:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/Greeting.kt
package com.sebastiannarvaez.pokedex
 
import com.sebastiannarvaez.pokedex.core.Log
 
/**
 * La prueba de humo del proyecto: si esta frase aparece en las dos pantallas,
 * el codigo compartido esta llegando a Android y a iOS.
 *
 * Recibe la `Platform` en vez de pedirsela a `currentPlatform()`. Es la
 * diferencia entre una clase que se puede probar y una que no: en un test se le
 * pasa una implementacion falsa y el resultado deja de depender de donde corra.
 */
class Greeting(private val platform: Platform) {
 
    fun greet(): String {
        val saludo = "Hola desde ${platform.name}"
        // La misma linea de codigo acaba en Logcat y en la consola de Xcode.
        Log.i(saludo)
        return saludo
    }
}

Parece cosmético. No lo es: el dominio ha dejado de saber que existe una frontera de plataforma. Esa llamada vive ahora arriba del todo, en quien arranca la app, y eso se nota donde más importa.

La prueba de que el cambio vale está en el test

Antes solo se podía comprobar la forma del saludo, porque el texto cambiaba según dónde corriera. Ahora hay dos tests distintos. Paso 3 · GreetingTest.kt. Ábrelo en shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/GreetingTest.kt y sustituye su contenido. Es el fichero entero:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/GreetingTest.kt
package com.sebastiannarvaez.pokedex
 
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
 
/**
 * Una plataforma de mentira. Tres lineas, sin libreria de mocks.
 *
 * Los mocks por reflexion no funcionan en Kotlin/Native, asi que en KMP los
 * dobles se escriben a mano. No es un apano: es lo que recomienda hacer.
 */
private class FakePlatform(override val name: String) : Platform
 
class GreetingTest {
 
    @Test
    fun elSaludoNombraLaPlataformaQueSeLePasa() {
        // Sin la interfaz, este test no se podria escribir: diria una cosa en
        // la JVM, otra en Android y otra en el simulador de iOS.
        val saludo = Greeting(FakePlatform("Plataforma de prueba")).greet()
 
        assertEquals("Hola desde Plataforma de prueba", saludo)
    }
 
    @Test
    fun cadaPlataformaRealSeIdentifica() {
        // Este si depende de donde corra, y por eso solo comprueba la forma.
        // Es el unico test del modulo que no puede afirmar un valor concreto.
        val saludo = Greeting(currentPlatform()).greet()
 
        assertTrue(saludo.startsWith("Hola desde "), "saludo inesperado: $saludo")
        assertTrue(saludo.length > "Hola desde ".length, "falta el nombre de la plataforma")
    }
}

Quién da la Platform: las dos apps

Alguien tiene que darle esa Platform a Greeting, y ese alguien son las dos apps, cada una en su lenguaje. Hasta que las cambies, ninguna de las dos compila: llaman a Greeting() sin argumentos.

Paso 4 · Android. Abre la MainActivity.kt que viste en El módulo compartido y la prueba de humo (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/MainActivity.kt). Son dos cambios pequeños, no hace falta tocar nada más:

MainActivity.kt: las dos líneas que cambian (con comentarios)
import com.sebastiannarvaez.pokedex.currentPlatform   // import nuevo, junto al de Greeting
 
// en MainActivity.kt, donde antes decía Greeting().greet():
Saludo(texto = Greeting(currentPlatform()).greet())    // la app de Android es quien pide la Platform, una sola vez

Compruébalo: ./gradlew :androidApp:assembleDebug termina en BUILD SUCCESSFUL. En Swift, en cambio, la llamada obvia no compila, como verás en el paso siguiente.

Paso 5 · iOS. Abre la app en Xcode (como en Ejecutar en iOS) y, en el panel izquierdo (el navegador de proyecto), iosApp → iosApp → ContentView.swift (la ruta iosApp/iosApp/ContentView.swift del proyecto). Sustituye todo su contenido por esta primera versión, que copia el patrón de Android: la función currentPlatform() está declarada en el fichero Platform.kt, y Kotlin pone las funciones sueltas de un fichero en una clase con el nombre del fichero más Kt (en la JVM esa clase se llama PlatformKt), así que Swift buscaría PlatformKt. Pulsa Cmd + B (compilar):

ContentView.swift: primer intento (no compila)
import SwiftUI
import Shared
 
struct ContentView: View {
    // El saludo viene del módulo compartido.
    private let saludo = Greeting(platform: PlatformKt.currentPlatform()).greet()
 
    var body: some View {
        Text(saludo)
            .font(.title2)
            .padding()
    }
}

Xcode responde:

ContentView.swift:6:45: error: cannot find 'PlatformKt' in scope

Es la línea 6 de este primer intento, la de private let saludo, y la columna 45 es donde empieza PlatformKt.

Vuelve a sustituir el contenido de ContentView.swift, ahora con el nombre feo y un comentario que dice que es temporal. Es el fichero entero:

iosApp/iosApp/ContentView.swift
import SwiftUI
import Shared
 
struct ContentView: View {
    // El saludo viene del modulo compartido: es la prueba de que iOS esta
    // consumiendo el mismo Kotlin que Android.
    //
    // `Platform_iosKt` chirria, y con razon: el nombre sale del fichero donde
    // vive el `actual`, que es Platform.ios.kt. Se arregla en la leccion de
    // funciones que cruzan a Swift; de momento se deja a la vista.
    private let saludo = Greeting(platform: Platform_iosKt.currentPlatform()).greet()
 
    var body: some View {
        Text(saludo)
            .font(.title2)
            .padding()
    }
}
 
#Preview {
    ContentView()
}

Dejarlo feo es deliberado. Es un arco de tres lecciones: lo escribes hoy, entiendes por qué pasa (con la lectura completa del header) en Los tipos que cruzan a Swift, y lo arreglas en Las funciones que cruzan a Swift.

Punto de control

  • Tienes ejercicios/bateria con su contrato, su clase y su test, y pasan.
  • Greeting recibe la Platform por constructor y ya no llama a currentPlatform().
  • En commonMain hay una sola declaración expect (por ahora).
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con dos tests de GreetingTest y dos de AvisoDeBateriaTest en cada destino (cuatro por destino).
  • FakePlatform está escrito a mano en commonTest, sin ninguna librería de mocks.
  • ./gradlew :androidApp:assembleDebug termina en BUILD SUCCESSFUL y MainActivity llama a Greeting(currentPlatform()).
  • El simulador de iOS sigue enseñando «Hola desde iOS N», y ContentView.swift llama a Platform_iosKt.currentPlatform().

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.JetBrains desaconseja usar clases expect «en casos sencillos en los que bastarían interfaces». ¿Cuál es la razón práctica que da la propia documentación?

  2. 2.Greeting pasa a recibir la Platform por constructor en vez de llamar a currentPlatform(). ¿Qué desbloquea ese cambio?

  3. 3.FakePlatform está escrito a mano, en tres líneas, sin ninguna librería de mocks. ¿Por qué no se usa una?

  4. 4.Desde Swift, PlatformKt.currentPlatform() responde cannot find 'PlatformKt' in scope, pero la función sí está exportada. ¿Cómo se llama de verdad la clase que genera Kotlin?

  5. 5.¿Cuál de estos casos justifica de verdad un expect class?

Faltan 5 de 5