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.
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ómoY estos son dos de los tres ficheros que cumplen la promesa, enteros; el de la JVM es igual y lee System.getProperty("java.version").
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()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.
| Caso | Por qué | Ejemplo |
|---|---|---|
| Heredar de una clase de plataforma | Desde commonMain no puedes heredar de una clase que solo existe en un sistema. Hace falta expect class | UIViewController, la clase base de las pantallas de iOS |
actual typealias sobre un tipo nativo | Para que un tipo común sea un tipo de plataforma, sin envoltorio | un typealias común que en cada sistema apunta a su propio tipo de fecha |
| No hay construcción equivalente | Una 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.
// 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:
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.
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
Bateriacon una propiedad de solo lecturaporcentaje(un entero); - una clase
AvisoDeBateriaque recibe unaBateriapor su constructor y tiene una funciónmensaje(). Si el porcentaje es menor que 20, devuelveBaterí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 SUCCESSFULy, 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
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} %"
}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é.
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(): PlatformPaso 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:
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:
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:
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 vezComprué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):
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 scopeEs 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:
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/bateriacon su contrato, su clase y su test, y pasan. Greetingrecibe laPlatformpor constructor y ya no llama acurrentPlatform().- En
commonMainhay una sola declaraciónexpect(por ahora). ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULcon dos tests deGreetingTesty dos deAvisoDeBateriaTesten cada destino (cuatro por destino).FakePlatformestá escrito a mano encommonTest, sin ninguna librería de mocks../gradlew :androidApp:assembleDebugtermina enBUILD SUCCESSFULyMainActivityllama aGreeting(currentPlatform()).- El simulador de iOS sigue enseñando «Hola desde iOS N», y
ContentView.swiftllama aPlatform_iosKt.currentPlatform().
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
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.
Greetingpasa a recibir laPlatformpor constructor en vez de llamar acurrentPlatform(). ¿Qué desbloquea ese cambio?3.
FakePlatformestá escrito a mano, en tres líneas, sin ninguna librería de mocks. ¿Por qué no se usa una?4.Desde Swift,
PlatformKt.currentPlatform()respondecannot find 'PlatformKt' in scope, pero la función sí está exportada. ¿Cómo se llama de verdad la clase que genera Kotlin?5.¿Cuál de estos casos justifica de verdad un
expect class?