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

Koin: un solo grafo en commonMain

Al terminar: Entender Koin desde cero y montar un único grafo de dependencias en commonMain, con un test que lo verifica.

Inyectar una dependencia es dársela a quien la usa desde fuera, en vez de que la construya él. Lo hiciste en Marcador con Hilt, empezando por Hilt: instalarlo y el primer módulo y ordenado por capas en Inyección en todas las capas; en iOS, con un contenedor escrito a mano, ContenedorDeLaApp, en Inyección de dependencias en todas las capas. En KMP deja de ser una comodidad y se vuelve estructural, y la razón es aritmética: hay dos aplicaciones nativas montando el mismo grafo de objetos, y cada una lo describe en su propio lenguaje.

Hasta hoy se notaba poco. El grafo es el conjunto de objetos de la app y de quién necesita a quién; el nuestro son cuatro: los dispatchers, la plataforma, Pokedex (que necesita los dispatchers) y HomeViewModel (que necesita a Pokedex). Ya se nota: ContentView.swift construye un Pokedex() y se lo pasa a homeViewModel(...), y HomeScreen.kt construye otro por su cuenta. La misma cadena descrita dos veces, en dos idiomas, que se desincronizan a la primera de cambio.

Al terminar esta lección habrá un solo grafo, escrito en commonMain, con un test que comprueba que está completo. Las dos apps lo arrancan en la lección siguiente, Arrancar Koin desde Android y desde iOS: ahí Swift no tendrá que saber qué es Koin, solo llamará a una función de arranque. Lo propio de KMP es dónde vive cada pieza de Koin: en commonMain, en androidMain, en iosMain y en las dos apps.

Koin: la lista de recetas

Qué es. Koin es el contenedor de siempre, escrito como lista: un objeto que construye los demás objetos por ti, a partir de una lista de recetas. Esa lista se llama módulo (de Koin: no es un módulo de Gradle como shared, solo una lista de recetas). Piensa en la carta de una cafetería: pides «un espresso» y la cocina sabe qué necesita (un molinillo, una cafetera) y en qué orden. Tú no montas nada; pides, y la cocina lo prepara.

El vocabulario cabe en seis palabras. Tres ya las leíste en el recuadro «Lo mismo con Koin» de Inyección en todas las capas (module, single y get()); hoy se suman factory, startKoin y koin.get<T>().

  • module { … }: la lista de recetas.
  • single { … }: «construye esto una sola vez y reutilízalo siempre».
  • factory { … }: «construye uno nuevo cada vez que se pida».
  • get(): dentro de una receta, «dame el objeto de este tipo que ya esté registrado».
  • startKoin { modules(…) }: arrancar el contenedor con sus módulos.
  • koin.get<T>(): pedirle un objeto de tipo T.

Un ejemplo ajeno al proyecto, una cafetería, para ver las seis a la vez. Es para leer, no para escribir:

ejemplo mínimo: una cafetería con Koin (con comentarios)
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
 
// Las piezas de una cafetería: un molinillo, y una cafetera que lo necesita.
class Molinillo {
    // Este mensaje se imprime cuando el molinillo se construye.
    init { println("Molinillo montado") }
    fun moler() = "café molido"
}
 
class Cafetera(private val molinillo: Molinillo) {
    fun preparar() = "Un espresso con ${molinillo.moler()}"
}
 
class Ticket(val numero: Int)
 
// El número del siguiente ticket.
var siguienteTicket = 1
 
// El grafo: qué piezas hay y cómo se construye cada una.
val cafeteriaModule = module {
    single { Molinillo() }                 // single: una sola vez, y se reutiliza
    single { Cafetera(get()) }             // get(): «dame el Molinillo registrado»
    factory { Ticket(siguienteTicket++) }  // factory: uno nuevo cada vez que se pide
}
 
fun main() {
    // Arranca el grafo y guarda el contenedor en `koin`.
    val koin = startKoin { modules(cafeteriaModule) }.koin
    println("Grafo arrancado")
    println(koin.get<Cafetera>().preparar())   // pide una cafetera
    println(koin.get<Cafetera>().preparar())   // la pide otra vez
    println("Ticket ${koin.get<Ticket>().numero}")
    println("Ticket ${koin.get<Ticket>().numero}")
    stopKoin()                                 // apaga el contenedor
}

Salida:

Grafo arrancado
Molinillo montado
Un espresso con café molido
Un espresso con café molido
Ticket 1
Ticket 2

Tres cosas que enseña la salida:

  • «Molinillo montado» sale después de «Grafo arrancado». Koin no construye nada al arrancar: el molinillo se monta cuando alguien lo pide por primera vez (aquí, al pedir la cafetera, que lo necesita).
  • Se monta una sola vez aunque haya dos espressos: es single.
  • Cada ticket es nuevo (el 1 y el 2): es factory.

Son tus módulos de Hilt de Marcador (RedModule, DatosModule…), escritos como lista. Y una diferencia importante: Hilt comprueba el grafo al compilar (un MissingBinding te lo dijo en Inyección en todas las capas); Koin resuelve al ejecutar, no al compilar. Si a la cafetería le quitas la receta del molinillo, el proyecto compila igual, y el error aparece cuando alguien pide una cafetera. Por eso esta lección termina con un test que lo comprueba antes.

Paso 1 · Instala Koin en shared

Objetivo: que shared pueda describir el grafo de dependencias en commonMain. Es la receta de Los ficheros de Gradle de tu proyecto con una novedad: no todas las librerías valen para todas las plataformas, y por eso cada una va al source set (la carpeta de código de un grupo de plataformas, de Qué es KMP y qué no) que le corresponde.

gradle
libs.versions.tomlse modifica: [versions] y [libraries]
shared
build.gradle.ktsse modifica: commonMain y androidMain
Los dos ficheros de la instalación.

1 · Qué instalas y por qué ahora. Koin es el contenedor de dependencias. Se reparte en varias librerías, y hoy usas tres en shared: koin-core, el contenedor, y koin-core-viewmodel, que sabe registrar ViewModels, existen para iOS y valen en commonMain. koin-android, en cambio, no existe para iOS, lógicamente: trae lo propio de Android (el Context). Por eso va solo en androidMain y no en común: si lo pones arriba, el error que sale habla de buscar una versión de iOS que no hay, y no de Android.

2 · La versión, en [versions]. Koin no tiene número en el catálogo. Abre gradle/libs.versions.toml y añade al final de la sección [versions], antes de la cabecera [libraries]:

libs.versions.toml: la línea nueva de [versions]
# Un solo número para todas las librerías de Koin: se publican juntas.
koin = "4.2.2"

3 · Las librerías, en [libraries]. Al final de [libraries], antes de [plugins], añade tres líneas (otras dos las instalarás cuando las necesites: koin-test en el paso 3 y koin-compose-viewmodel en la lección siguiente):

libs.versions.toml: las tres líneas nuevas de [libraries]
# El contenedor: single, module, startKoin. Vale en todas las plataformas.
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
# viewModelOf: registra un ViewModel con el ciclo de vida que espera cada plataforma.
koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" }
# Lo propio de Android: androidContext. No tiene versión para iOS.
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }

4 · Las dependencias, cada una en su source set. Abre shared/build.gradle.kts. En commonMain.dependencies { … }, añade las dos de Koin; la primera es api porque son las apps quienes arrancan Koin, desde fuera de shared:

build.gradle.kts de shared: commonMain, con las líneas nuevas
        commonMain.dependencies {
            // Ya estaba: el ViewModel de El ViewModel compartido.
            api(libs.androidx.lifecycle.viewmodel)
            // api: initKoin, que es pública, recibe y devuelve tipos de koin-core
            // (KoinApplication), así que asoman en la firma de shared.
            api(libs.koin.core)
            // implementation: solo lo usa el código de shared (viewModelOf).
            implementation(libs.koin.core.viewmodel)
            // Lo que ya tenías debajo no cambia.
            implementation(libs.kotlinx.coroutines.core)
            api(libs.kermit)
        }

¿Y si fuera implementation? Hoy compilaría igual, porque koin-android arrastra koin-core hasta androidApp. Con api no dependes de eso: lo que aparece en la firma pública de un módulo lo declara ese módulo (la regla está en La primera dependencia).

Y, dentro de sourceSets { … }, a continuación del bloque de commonMain, un bloque nuevo, androidMain, el source set que solo compila para Android:

build.gradle.kts de shared: androidMain, bloque nuevo
        // Solo Android: koin-android no tiene versión para iOS. Hoy no se usa
        // desde aquí: se instala ya porque es lo que permitirá al módulo de
        // plataforma de Android pedir el Context (androidContext()) cuando
        // lleguen la red y la base de datos.
        androidMain.dependencies {
            implementation(libs.koin.android)
        }

5 · Sync Now, y comprueba. Pulsa Sync Now. Debe terminar sin rojo. Si sale en rojo, repasa la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto. Dos errores de esta instalación:

  • koin-android en commonMain. Si lo pones arriba, compilar para iOS falla con un mensaje largo que empieza con Could not resolve all files for configuration ':shared:iosSimulatorArm64CompileKlibraries' y, más abajo, dice No matching variant of androidx.lifecycle:lifecycle-runtime-ktx:2.11.0 was found: Gradle busca una versión de iOS de algo que solo existe para Android. El mensaje no nombra a Koin; la pista es que la ruta de dependencias pasa por io.insert-koin:koin-android. Muévelo a androidMain.
  • Un alias que no existe. Unresolved reference 'koin' en build.gradle.kts: compara con el catálogo, leyendo los guiones como puntos.

Paso 2 · El grafo, escrito una sola vez

El archivo. Modules.kt es el grafo: la lista de qué objetos existen y cómo se construye cada uno. Va en commonMain, en un paquete nuevo di (de dependency injection), para que lo comparta todo el mundo.

Créalo con el método de El módulo compartido y la prueba de humo: clic derecho sobre el nodo shared → New → File, con la ruta desde la carpeta del módulo:

src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt
shared
src/commonMain/kotlin/.../di/Modules.ktse crea
src/androidMain/kotlin/.../di/Modules.android.ktse crea
src/iosMain/kotlin/.../di/Modules.ios.ktse crea
src/jvmMain/kotlin/.../di/Modules.jvm.ktse crea
src/jvmTest/kotlin/.../di/ModulesTest.ktse crea (paso 3)
Un módulo común, tres vacíos por plataforma y su test. Rutas desde la carpeta de `shared`.

El concepto nuevo de este fichero. viewModelOf(::HomeViewModel) registra el ViewModel en el grafo. ::HomeViewModel es una referencia al constructor (en Lambdas con listas: filter, map y reglas viste :: apuntando a una función; aquí apunta al constructor de la clase): le dice a Koin «así se construye un HomeViewModel», y Koin averigua solo qué necesita (un Pokedex) y lo busca en el grafo. Lo registra con el ciclo de vida que espera cada plataforma, de modo que Android lo entregue con koinViewModel() (lo verás en la lección siguiente) y la fábrica de iOS lo pida al grafo.

Escríbelo, entero:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt
package com.sebastiannarvaez.pokedex.di
 
import com.sebastiannarvaez.pokedex.Pokedex
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import com.sebastiannarvaez.pokedex.core.PlatformAppDispatchers
import com.sebastiannarvaez.pokedex.currentPlatform
import com.sebastiannarvaez.pokedex.feature.home.HomeViewModel
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.core.module.Module
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
 
/**
 * El grafo que comparten las dos apps.
 *
 * Sin esto, cada interfaz tendria que montar la misma cadena de dependencias
 * por su cuenta: una en Kotlin y otra en Swift, describiendo lo mismo dos
 * veces y desincronizandose a la primera de cambio.
 */
val pokedexModule: Module = module {
    // <AppDispatchers>: se registra con el tipo de la interfaz, y quien pida un
    // AppDispatchers recibe esta implementación (la de cada plataforma).
    single<AppDispatchers> { PlatformAppDispatchers() }
    single { currentPlatform() }
    single { Pokedex(get()) }
 
    // viewModelOf y no factory: Koin registra el ViewModel con el ciclo de
    // vida que espera cada plataforma, y en Android lo entrega koinViewModel().
    viewModelOf(::HomeViewModel)
}
 
/**
 * Lo que solo existe en una plataforma. Hoy esta vacio; se llena cuando lleguen
 * el cliente de red y la base de datos, que si necesitan algo del sistema.
 */
expect val platformModule: Module
 
/**
 * Arranca Koin. La llaman las dos apps, cada una a su manera, y en ningun otro
 * sitio: un grafo arrancado dos veces lanza excepcion.
 */
fun initKoin(extra: KoinApplication.() -> Unit = {}): KoinApplication =
    startKoin {
        extra()
        modules(pokedexModule, platformModule)
    }

Cuatro cosas del grafo:

  • single { Pokedex(get()) } usa el constructor internal de Corrutinas en commonMain (internal quiere decir «visible solo dentro de este módulo de Gradle»), que el grafo sí puede llamar porque vive en shared. Y single<AppDispatchers> { … } lleva el tipo entre <> porque se registra con el de la interfaz: quien pida un AppDispatchers recibe la implementación de su plataforma.
  • get() resuelve por tipo, en ejecución: dentro de la lambda, Koin busca entre las definiciones la que produce un AppDispatchers. El compilador no comprueba que exista; si falta, falla cuando alguien pide el objeto (más abajo lo ves).
  • single { currentPlatform() } registra la plataforma en el grafo como ejemplo de un valor que sale de una función expect. Hoy nadie la pide (Pokedex llama a currentPlatform() por su cuenta), así que si la borras no cambia nada.
  • expect val platformModule es un hueco que cada plataforma rellena. Es la tercera expect del proyecto (más en expect/actual frente a interfaces). Se podría pasar el módulo como parámetro de initKoin. Pero entonces cada app, la de Kotlin y la de Swift, tendría que construir su módulo de Koin, y Swift no sabe hacerlo. Con expect, además, olvidarse del actual de una plataforma es un error de compilación y no un fallo en ejecución.

Las tres piezas. Hoy están vacías a propósito: se llenarán cuando lleguen el cliente de red y la base de datos, que sí necesitan algo del sistema. Crea los tres ficheros con el mismo método, uno por source set, y escribe lo mismo en los tres. Los import los añade Option+Enter sobre el nombre subrayado, o los escribes tú:

src/androidMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.android.kt
src/iosMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.ios.kt
src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.jvm.kt
shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.ios.kt
package com.sebastiannarvaez.pokedex.di
 
import org.koin.core.module.Module
import org.koin.dsl.module
 
actual val platformModule: Module = module { }

(El de Android y el de la JVM son idénticos; solo cambia el nombre del fichero y el source set. La JVM también lo necesita: expect exige un actual en cada destino, y la JVM es el de los tests rápidos. El sufijo del nombre, .android, .ios, .jvm, es la convención de Anatomía del proyecto.)

Paso 3 · El test del grafo, y dónde vive

Instala koin-test. La receta corta otra vez. Es la primera librería que solo existe para tests de la JVM en shared, así que va a jvmTest.dependencies. En gradle/libs.versions.toml, al final de [libraries]:

libs.versions.toml: la línea nueva de [libraries]
# verify(): comprueba que el grafo de Koin está completo, sin arrancarlo.
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }

Y en shared/build.gradle.kts, dentro de sourceSets { … }, como un bloque más (el orden de los bloques no importa; al final, detrás del último que haya, queda ordenado):

build.gradle.kts de shared: jvmTest, bloque nuevo
        // koin-test verifica el grafo por reflexión (inspeccionar las propias clases
        // mientras corre el programa), y Kotlin/Native no tiene reflexión: por eso
        // solo se instala para los tests de la JVM.
        jvmTest.dependencies {
            implementation(libs.koin.test)
        }

Pulsa Sync Now.

El archivo. ModulesTest.kt comprueba que pokedexModule está completo. Y va en jvmTest, no en commonTest. Créalo: clic derecho sobre shared → New → File, con la ruta:

src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/di/ModulesTest.kt
shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/di/ModulesTest.kt
package com.sebastiannarvaez.pokedex.di
 
import kotlin.test.Test
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.test.verify.verify
 
/**
 * Vive en `jvmTest` y no en `commonTest` porque `verify()` funciona por
 * reflexion, y **Kotlin/Native no la tiene**. En commonTest el compilador
 * responde `Unresolved reference 'verify'` al compilar para el simulador.
 *
 * No se pierde nada: el grafo es el mismo en las tres plataformas, asi que
 * comprobarlo una vez basta.
 */
class ModulesTest {
 
    /**
     * Comprueba que el grafo se puede construir entero sin arrancar la app.
     *
     * No llama a `startKoin`: arrancar el grafo en un test deja estado global
     * entre tests, y el siguiente falla por razones que no tienen que ver con
     * lo que probaba. `verify` recorre las definiciones y avisa de cualquier
     * parámetro de constructor que nadie sepa construir. No ve los `get()`
     * escritos dentro de una lambda: se explica más abajo.
     */
    @OptIn(KoinExperimentalAPI::class)
    @Test
    fun elGrafoSePuedeConstruirEntero() {
        pokedexModule.verify()
    }
}

Ejecuta, en la Terminal de Android Studio, desde la carpeta de tu proyecto: ./gradlew :shared:jvmTest. Debe acabar en BUILD SUCCESSFUL, con elGrafoSePuedeConstruirEntero entre los tests: :shared:jvmTest pasa 10 tests con tus ejercicios (los 9 de antes y este) y 6 sin ellos. Los otros dos destinos no cambian: 9 con ejercicios, 5 sin ellos.

Qué atrapa este test, y qué no

El test es lo único que protege el grafo, así que conviene saber hasta dónde llega. Si quieres verlo con tus ojos, comenta la línea single { Pokedex(get()) } de pokedexModule, ejecuta ./gradlew :shared:jvmTest y descoméntala antes de seguir.

  • El proyecto compila igual. Con Hilt, una dependencia que falta es un error de compilación; con Koin, que resuelve en ejecución, no lo es.
  • El test falla al instante y nombra al culpable: MissingKoinDefinitionException: Missing definition for '[field:'pokedex' - type:'com.sebastiannarvaez.pokedex.Pokedex']' in definition '[Factory: 'com.sebastiannarvaez.pokedex.feature.home.HomeViewModel']'. verify() recorre las definiciones y encuentra que HomeViewModel pide un Pokedex que nadie sabe construir.
  • La app, en cambio, falla cuando alguien abre la pantalla que necesitaba ese HomeViewModel, con un error de Koin en ejecución (NoDefinitionFoundException: No definition found for type 'com.sebastiannarvaez.pokedex.Pokedex'…). Si esa pantalla estuviera tres toques más adentro, el fallo saldría en el móvil de alguien, no en tu portátil.
  • Hay un caso que verify() no ve. Si en vez de esa línea comentas single<AppDispatchers> { … }, el test pasa: verify() comprueba los parámetros de los constructores de lo que registras, pero no lo que pide un get() escrito dentro de una lambda como Pokedex(get()). El test es una red, no una garantía.

Por eso ese test no es opcional en un proyecto que usa Koin: es la red que recupera lo que Hilt te daba gratis al compilar. Devuelve el fallo al momento de construir, que es donde se arregla barato.

Punto de control

  • pokedexModule está en commonMain y declara AppDispatchers, currentPlatform(), Pokedex y viewModelOf(::HomeViewModel).
  • Hay un actual val platformModule vacío en Android, iOS y JVM.
  • koin-android está en androidMain.dependencies de shared (todavía sin uso) y koin-test, solo en jvmTest.
  • ./gradlew :shared:jvmTest pasa 10 tests con tus ejercicios (6 sin ellos), entre ellos elGrafoSePuedeConstruirEntero; en Android y el simulador siguen 9 (5 sin ejercicios).
  • Las dos apps siguen funcionando igual que antes: todavía no usan el grafo (lo conecta la lección siguiente).

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Por qué en KMP la inyección de dependencias deja de ser una comodidad y pasa a ser estructural?

  2. 2.En shared, koin-android va en androidMain.dependencies y no en commonMain. ¿Por qué?

  3. 3.El test del grafo vive en jvmTest y no en commonTest. ¿Cuál es el motivo?

  4. 4.Se borra una definición de pokedexModule. ¿Quién avisa, y cuándo?

  5. 5.En el ejemplo de la cafetería, ¿por qué «Molinillo montado» sale después de «Grafo arrancado»?

Faltan 5 de 5