← Back to list

Preguntas de Entrevista Android: Dominando Corrutinas y Flows

Si te estás preparando para una entrevista técnica en Android, el manejo de la asincronía es uno de los temas donde los entrevistadores…

mzaragozaserrano · 2026-06-08 10:17 · 4 claps · 23.8 min read
#android-development #kotlin #kotlin-coroutines #interview-questions #software-engineering
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

Preguntas de Entrevista Android: Dominando Corrutinas y Flows

Si te estás preparando para una entrevista técnica en Android, el manejo de la asincronía es uno de los temas donde los entrevistadores separan a los perfiles Junior de los verdaderos Senior. Ya no basta con saber hacer un launch; hay que entender un paso más allá.

En esta guía, vamos a desgranar las preguntas más comunes que me he encontrado aplicando a entrevistas de trabajo para puestos Senior, sobre Corrutinas y Flujos en Kotlin, sin dejar de lado conceptos clave para dejar todas las ideas bien organizadas.

0. Índice

1. La base fundamental: Hilos, Corrutinas y Suspensión

  • La magia del compilador: Continuation-Passing Style (CPS).
  • Builders: launch vs async y por qué huir de runBlocking.

2. Dominando los Scopes y la anatomía del CoroutineContext

  • El ciclo de vida, la trampa del GlobalScope y la creación de Scopes personalizados.
  • Job vs SupervisorJob y la correcta elección de Dispatchers.
  • Control de Suspensión: El poder de yield().
  • Manejo de Errores: try/catch interno vs CoroutineExceptionHandler.
  • El terror de la concurrencia: Protegiendo el Shared Mutable State.
  • Ejemplos del mundo real: Analíticas y SDKs Legacy.

3. Flujos Reactivos e Integración con la UI Moderna

  • El origen del dato: Flow, StateFlow y SharedFlow.
  • StateIn y el manejo de los estados de arranque.
  • Compose al rescate: LaunchedEffect vs rememberCoroutineScope.
  • El devorador de batería: Por qué necesitas collectAsStateWithLifecycle y repeatOnLifecycle.

4. Testing de Corrutinas

  • El reloj virtual con runTest y la elección del Dispatcher de testeo correcto.
  • Cómo testear un StateFlow de forma elegante con Turbine.

1. La base fundamental: Hilos, Corrutinas y Suspensión

Vamos a empezar repasando ciertos conceptos clave. Así pues, empecemos por la típica pregunta para romper el hielo, que suele buscar un detalle que sale de la respuesta superficial.

¿Cuál es la principal diferencia entre un Hilo (Thread) y una Corrutina, y qué significa realmente que una corrutina se “suspenda”?

La respuesta de libro es que los hilos son gestionados por el Sistema Operativo (son costosos y pesados), mientras que las corrutinas son “hilos ligeros” gestionados a nivel de usuario por Kotlin.

Pero el toque Senior viene al explicar la suspensión. En un hilo tradicional de Java, si haces una llamada de red, ese hilo se bloquea; se queda “congelado” consumiendo memoria sin hacer nada útil hasta que recibe respuesta.

Cuando una corrutina se “suspende”, libera el hilo en el que se estaba ejecutando para que otras corrutinas puedan usarlo. Pero, ¿cómo sabe por dónde seguir cuando vuelve la respuesta de red?

Aquí entra la magia del compilador de Kotlin: el Continuation-Passing Style (CPS).

Por debajo, el compilador transforma tu función suspend en una Máquina de Estados (State Machine). Añade un parámetro oculto llamado Continuation, que actúa como un callback supervitaminado que guarda todas tus variables locales y la "etiqueta" exacta de la línea donde te quedaste. Fíjate en este ejemplo:

// Lo que tú escribes:
suspend fun fetchUserData(): User {
    val token = login() // Punto de suspensión 1
    val user = api.getUser(token) // Punto de suspensión 2
    return user
}

// Lo que Kotlin genera por debajo (versión muy simplificada):
fun fetchUserData(continuation: Continuation): Any {
    val stateMachine = continuation as? MyStateMachine ?: MyStateMachine(continuation)

    when (stateMachine.label) {
        0 -> {
            stateMachine.label = 1
            return login(stateMachine) // Se suspende y guarda el estado
        }
        1 -> {
            val token = stateMachine.result
            stateMachine.label = 2
            return api.getUser(token, stateMachine) // Se suspende de nuevo
        }
        2 -> {
            val user = stateMachine.result
            return user // Termina
        }
    }
}

Builders: launch vs async y el prohibido runBlocking

Un detalle a tener en cuenta es que no todas las corrutinas nacen iguales. Dependiendo de lo que necesites, tienes diferentes “constructores” (builders).

  • **launch (Fire-and-forget): Lo lanzas y te olvidas. Te devuelve un Job que sirve para monitorizar su estado o cancelarlo, pero no te devuelve un resultado**.
  • **async (Necesito el dato):** Lo usas cuando esperas un valor de vuelta. Te devuelve un Deferred<T> (una promesa). Para extraer ese valor, debes llamar a su función suspensiva .await().

Y teniendo en cuenta esto, ¿sabrías decirme cómo hacer dos peticiones de red en paralelo y esperar a que ambas terminen? Pues bien, si simplemente pensaste en lanzar dos withContext seguidos, se ejecutarán secuencialmente. La respuesta que espera un entrevistador de un perfil Senior es usar async, tal y como te muestro a continuación:

suspend fun fetchDashboardData(): Dashboard {
    return coroutineScope { 
        // Lanzamos ambas en paralelo
        val deferredUsers = async { api.getUsers() }
        val deferredPosts = async { api.getPosts() }

        // La ejecución se suspende aquí hasta que AMBAS terminen
        Dashboard(
            users = deferredUsers.await(),
            posts = deferredPosts.await()
        )
    }
}

Y si hay una cosa peor que lanzar dos withContext en paralelo, ese es el caso de runBlocking.

Seguro que en algún momento has leído o escuchado algo sobre **runBlocking sin saber muy bien para qué sirve o si debes utilizarlo. El `runBlocking** básicamente es un puente entre el mundo síncrono y el asíncrono. Su nombre lo dice todo: **BLOQUEA el hilo actual** hasta que la corrutina termina. **¿Cuándo podemos usarlo?** En scripts, en la funciónmain()de un programa de consola, o en Tests (runTestpor debajo usa filosofías similares). **¿Cuándo NO usarlo?** **Jamás en código de producción en Android**. Si metes unrunBlocking` en el hilo Main, la pantalla se congelará (ANR garantizado).

2. Dominando los Scopes y la anatomía del CoroutineContext

Ahora que hemos refrescado un poco ciertos conceptos, toca profundizar un poquito más, y es que cualquier desarrollador Android sabe lanzar una corrutina desde un ViewModel, pero ¿qué ocurre cuando tienes que construir la arquitectura de un SDK, un manejador de descargas en segundo plano o una vista personalizada (CustomView)? ¿Alguna idea? Si no lo sabes, no te preocupes, porque para esto estoy escribiendo estas líneas. Y si lo sabes, seguramente hayas pensado en poner en juego los Scopes y los Jobs.

El ciclo de vida y la trampa del GlobalScope

Para entender este bloque, es importante partir de lo que es el CoroutineScope, el cual básicamente existe para mantener un control estructurado definiendo el ciclo de vida de las corrutinas que se lanzan en su interior. La regla de oro de la concurrencia estructurada es simple: si un Scope se cancela, todas las corrutinas que viven dentro de él se cancelan automáticamente.

En Android, lo estándar es usar viewModelScope (se limpia cuando muere el ViewModel) o lifecycleScope (muere con la Activity/Fragment). De hecho, desde aquí hago apología si eres un perfil más Junior y sigues utilizándolo, que por favor, huyas del GlobalScope. El por qué de ello es simple: GlobalScope no está atado a ningún ciclo de vida específico; vive mientras viva la aplicación. Si lanzas una operación ahí y la pantalla o componente que la llamó se destruye, esa corrutina quedará ejecutándose como un zombie en segundo plano. ¿El resultado? Fugas de memoria (memory leaks) y desperdicio de los recursos del dispositivo.

Scopes Personalizados

Como decía un poco más arriba, esta es la solución para cuando necesitas crear arquitecturas propias o clases que no son componentes estándar de Android.

La forma de instanciar tu propio CoroutineScope, parte por pasarle sencillamente un CoroutineContext, el cual se compone de varios parámetros:

  1. Job / SupervisorJob: Controla el ciclo de vida y cómo se propagan los errores. Es opcional, pero obligatorio si no quieres perder el control de tu app.
  2. Dispatcher: Controla en qué hilo o hilos se ejecutará. Si quieres más información. Es opcional, pero obligatorio para evitar bloquear el hilo de la interfaz de usuario.
  3. CoroutineExceptionHandler: Una red de seguridad para atrapar excepciones no controladas. Es totalmente opcional.
  4. CoroutineName: Le da un nombre a tu corrutina, algo súper útil para rastrear problemas en los logs. Es totalmente opcional.

⚠️ ¡Importante! Si creas un Scope personalizado, es tu responsabilidad llamar a miScope.cancel() cuando esa clase ya no sea necesaria. Ten cuidado.

Job vs SupervisorJob

Si al leer en el apartado anterior no sabrías definir las diferencias entre estos dos componentes, no te preocupes, aquí vamos a entrar en más detalle.

En mi opinión, esta es una (o incluso la mayor) de las diferencias técnicas más importantes a la hora de estructurar un Scope y de las que más se preguntan en entrevistas Senior.

  • **Job:** Es destructivo por naturaleza. Si tienes varias corrutinas hijas colgando de un Job normal y una de ellas lanza una excepción, esta cancelará automáticamente a su padre y, por extensión, a todas las corrutinas "hermanas".
  • **SupervisorJob:** Actúa como un cortafuegos. El fallo de un hijo es completamente aislado y no afecta ni al padre ni a los demás hijos. Se usa muchísimo en la interfaz de usuario: si falla la carga de una imagen en una pantalla, no quieres que se cancele la carga del texto o de otros componentes visuales que la acompañan.

Dispatchers

Otro de los valores que le pasamos al CoroutineContext es el Dispatcher. El Dispatcher es el director de orquesta que decide en qué hilo o piscina de hilos (thread pools) va a correr tu corrutina. Usar el equivocado puede congelar tu app o desperdiciar batería. Veamos cuáles existen y cuándo usarlos:

  • **Dispatchers.Main: El hilo de la UI. Úsalo solo para interactuar con la vista. Aquí cabe mencionar que existe también Dispatchers.Main.immediate. La diferencia es que este último comprueba si ya estás en el hilo principal y, si es así, ejecuta el código instantáneamente sin pasar por la cola. Sin embargo el Dispatchers.Main encola los mensajes del hilo principal, incluso si la corrutina ya fue llamada desde el mismo hilo. Así que Dispatchers.Main.immediate es la opción recomendada** y es, de hecho, la que utiliza por defecto viewModelScope.
  • **Dispatchers.IO**: Optimizado para esperar. Ideal para red, bases de datos o leer archivos. Kotlin mantiene una piscina de hilos enorme aquí porque los hilos se pasan mucho tiempo "dormidos" esperando respuestas.
  • **Dispatchers.Default**: Optimizado para pensar (uso intensivo de CPU). Ordenar listas de miles de elementos, parsear JSONs gigantes o procesar imágenes. Aquí el número de hilos está limitado al número de núcleos físicos de tu dispositivo.
  • **Dispatchers.Unconfined**: Empieza en el hilo actual, pero tras una suspensión, continúa en el hilo que devolvió la respuesta. Casi nunca lo usarás en producción. Es terreno de testing.

⚠️ ¡Importante! En el resto del artículo seguramente veas hardcodeados los Dispatchers. La mejor práctica es pasarlos por el constructor mediante inyección de dependencias, meterlos hardcodeados puede considerarse incluso una red flag para un entrevistador. Aquí se usa por simplicidad de código a la hora de mostrar el ejemplo.

Llegados a este punto, y si queremos saltar un hilo a otro sin romper nada, debemos hablar de withContext(). Esta función suspende la corrutina, hace el trabajo en el nuevo Dispatcher, y te devuelve al hilo original. Podemos verlo paso a paso:

viewModelScope.launch(Dispatchers.Main) {
    // 1. Empezamos en el Main (UI)
    mostrarLoading(true)

    // 2. Saltamos a IO para la red de forma segura
    val user = withContext(Dispatchers.IO) {
        api.fetchUser()
    }

    // 3. Al salir del withContext, volvemos a estar en el Main.
    // Podemos actualizar la UI sin miedo a un crash de "WrongThreadException"
    mostrarUsuario(user)
    mostrarLoading(false)
}

Hasta aquí está todo perfecto. Ahora saltamos de hilos de forma segura, sí. Pero la concurrencia tiene más trampas porque el delay(time) es bastante conocido pero, ¿qué esyield() y por qué separa a los Juniors de los Seniors?

Para ello quería mencionar que las corrutinas son de cancelación cooperativa. Si lanzas un bucle while muy pesado en Dispatchers.Default (ej. procesar 100,000 píxeles de una imagen), y el usuario cierra la pantalla, la corrutina no se cancelará automáticamente porque está acaparando la CPU sin llegar a ningún punto de suspensión.

Gracias a yield() podemos decirle de forma educada al sistema: "Oye, voy a pausarme un microsegundo. Si la corrutina ha sido cancelada, muero aquí. Si hay otras corrutinas esperando para usar este hilo de CPU, se lo cedo un momento".

Fíjate en el ejemplo de cómo implementar el bucle haciendo uso de yield():

suspend fun procesarImagenPesada(pixeles: List<Pixel>) = withContext(Dispatchers.Default) {
    for (pixel in pixeles) {
        // yield() comprueba si el Job sigue activo (no cancelado) 
        // y permite que otras corrutinas en Default respiren.
        yield() 
        aplicarFiltroComplejo(pixel)
    }
}

Manejando Errores en Corrutinas

Si has llegado hasta este apartado, prepárate, porque el entrevistador va a ir a por uno de los puntos más críticos, tratando de marcar una diferencia entre un perfil más Mid/Senior y uno Senior de verdad.

Y es que un verdadero Senior, sabe cómo evitar que su app crashee cuando algo falla en segundo plano. Pero cuidado, porque el manejo de excepciones en corrutinas rompe las reglas tradicionales de Java/Kotlin a las que estamos acostumbrados. Pero no te agobies, veamos las dos opciones donde suelen caer más perfiles y las opciones que prefieren los Senior:

A. Try/catch

En alguna ocasión me ha pasado que han querido ir a pillar con una pregunta del estilo: “Si envuelvo un launch en un try/catch y la petición de red falla, ¿capturo el error?".

La respuesta es NO. Y es una buena pregunta para demostrar que entiendes cómo funciona la asincronía.

Como hemos visto en el Bloque de los Builders, la función launch es fire-and-forget. Se ejecuta tan rápido que cuando la corrutina realmente intenta hacer la llamada de red y falla, la ejecución principal ya ha pasado de largo del bloque catch.

Por tanto, la opción correcta, sería lanzar el try/catch dentro de la propia corrutina. Veamos unos ejemplos:

// ❌ 1. ERROR TÍPICO CON LAUNCH (Junior)
// Esto NO capturará la excepción. La app sufrirá un crash.
try {
    viewModelScope.launch {
        api.fetchUserData() // Falla asíncronamente
    }
} catch (e: Exception) {
    Log.e("Error", "¡Nunca entrará aquí!") 
}

// ✅ 2. LA FORMA CORRECTA CON LAUNCH (Senior)
// El try/catch envuelve el código asíncrono desde DENTRO
viewModelScope.launch {
    try {
        api.fetchUserData()
    } catch (e: Exception) {
        // Aquí sí lo capturamos, el error no escapa de la corrutina
        uiState.value = UiState.Error(e.message)
    }
}

// ✅ 3. LA FORMA CORRECTA CON ASYNC (Senior)
// Al usar async, la excepción se encapsula y solo "estalla" al llamar a await()
viewModelScope.launch {
    // supervisorScope actúa como cortafuegos temporal. 
    // Si el async falla, no cancelará este launch principal.
    supervisorScope {
        val deferredUser = async { api.fetchUserData() }

        try {
            val user = deferredUser.await()
            uiState.value = UiState.Success(user)
        } catch (e: Exception) {
            uiState.value = UiState.Error("Fallo al obtener usuario: ${e.message}")
        }
    }
}

Como te habrás dado cuenta, en el tercer ejemplo, hemos añadido supervisorScope, una opción que no habíamos mencionado hasta ahora.

Pues bien, resulta que async tiene un problema como hijo de un launch y es que cualquier excepción no controlada dentro del async se propaga inmediatamente hacia arriba en la jerarquía de Jobs, provocando de esta manera que el launch padre se cancele y, a su vez, que surja un crash en la aplicación incluso aunque envolvieras el .await() en un try/catch. supervisorScope evita precisamente este problema que presenta el async.

B. CoroutineExceptionHandler

A veces, no quieres (o no puedes) poner un try/catch en cada una de tus corrutinas. Necesitas una red de seguridad global tipo "catch-all" para registrar errores imprevistos o mostrar un mensaje genérico al usuario sin que la app colapse.

Para eso usamos el CoroutineExceptionHandler, una de las opciones que le podemos añadir a nuestro CoroutineContext.

Pero ¡ojo con este detalle! El CoroutineExceptionHandler tiene una regla estricta: solo funciona si se inyecta en el Scope padre (raíz) o en la corrutina de nivel superior. Si lo pones en una corrutina hija, será completamente ignorado. Te lo muestro:

// 1. Creamos nuestro CoroutineExceptionHandler
val crashHandler = CoroutineExceptionHandler { _, exception ->
    Log.e("CrashReporter", "Error capturado globalmente: ${exception.message}")
    // Aquí enviaríamos el error a Crashlytics/Sentry silenciosamente
}

// ❌ 2. EL ERROR CLÁSICO (Junior)
// Inyectarlo en una corrutina hija. El error delega al padre inmediatamente y la app crashea.
viewModelScope.launch {
    // AÑADIRLO AQUÍ NO SIRVE DE NADA. 
    launch(crashHandler) { 
        throw RuntimeException("¡Crash inminente!") 
    }
}

// ✅ 3. LA FORMA CORRECTA EN VIEWMODEL (Senior)
// Inyectarlo en el launch principal (raíz). 
// Como viewModelScope usa un SupervisorJob internamente, este launch actúa como raíz.
viewModelScope.launch(crashHandler) {
    throw RuntimeException("¡Fallo catastrófico de red!") // El handler lo atrapa y salva la app
}

// ✅ 4. LA FORMA CORRECTA EN SCOPES CUSTOM (Senior)
// Inyectarlo directamente en el CoroutineContext del Scope al crearlo.
val safeScope = CoroutineScope(SupervisorJob() + Dispatchers.Main + crashHandler)

safeScope.launch {
    throw RuntimeException("¡Fallo catastrófico de base de datos!") // El handler lo atrapa
}

¿Por qué ocurre esto? Porque en la jerarquía de las corrutinas, los hijos delegan inmediatamente el manejo de sus excepciones a sus padres. Para cuando la corrutina hija se da cuenta de que tiene un ExceptionHandler, el error ya ha viajado hacia arriba y ha hecho explotar el Scope principal.

El terror de la concurrencia: Shared Mutable State

Ya sabemos lanzar corrutinas, darles un contexto y evitar que crasheen. Pero imagina que lanzas 1.000 corrutinas y todas intentan sumar +1 a una misma variable entera. Si lo haces sin protección, el resultado no será 1.000. Será 983, o 850. Acabas de crear una condición de carrera (race condition).

¿Cómo protegemos una variable (Shared Mutable State) cuando múltiples corrutinas la atacan a la vez?

  1. Estructuras Thread-Safe: Usar variables atómicas (AtomicInteger).
  2. Confinement: Obligar a que todas las modificaciones pasen por un único hilo (como veremos más adelante en el Escenario B: SDK de una Base de Datos Legacy).
  3. El Mutex: El equivalente al Lock de Java, pero hecho para corrutinas. En lugar de bloquear el hilo (lo cual es carísimo), Mutex suspende la corrutina hasta que se libera el "candado".

Te pongo un ejemplo con Mutex para que entiendas mejor su uso:

class ContadorSeguro {
    private val mutex = Mutex()
    private var contador = 0

    suspend fun incrementar() {
        // withLock se encarga de pedir la llave y soltarla al terminar, 
        // incluso si ocurre una excepción dentro.
        mutex.withLock {
            contador++ // Solo una corrutina puede ejecutar esto a la vez
        }
    }
}

Ejemplos reales de Scopes Personalizados

Una vez hemos desengranado las distintas partes que componen un CoroutineScope, que gestionamos errores y que sabemos protegernos de las race condition, no hay mejor manera para entender cómo mezclar las diferentes opciones que nos plantean los Scopes Personalizados, que verlos en dos escenarios totalmente opuestos sacados del mundo real.

Escenario A: El Scope de Analíticas

A veces necesitas lanzar tareas (como enviar eventos de analíticas) que sobrevivan a la pantalla actual. Si el usuario pulsa “Comprar” y cierra la app instantáneamente, el evento debe enviarse.

Si para ello, no queremos usar el antipatrón de GlobalScope, la solución Senior es crear nuestro propio ApplicationScope y proveerlo mediante Inyección de Dependencias (ej. con Hilt/Dagger).

Creamos el Scope en nuestro módulo de Inyección de Dependencias:

@Module
@InstallIn(SingletonComponent::class)
object CoroutinesModule {

    // Creamos una anotación personalizada (opcional, pero muy Senior)
    @Retention(AnnotationRetention.RUNTIME)
    @Qualifier
    annotation class ApplicationScope

    @Provides
    @Singleton
    @ApplicationScope
    fun provideApplicationScope(): CoroutineScope {
        return CoroutineScope(
            SupervisorJob() + 
            Dispatchers.Default + 
            CoroutineName("AnalyticsAppScope")
        )
    }
}

Utilizamos SupervisorJob() (porque si falla un evento, no mueren los demás) + Dispatchers.Default (que nos permite procesar datos en background) + CoroutineName.

Lo consumimos en nuestro manejador:

class AnalyticsManager @Inject constructor(
    // Inyectamos el Scope que acabamos de crear
    @ApplicationScope private val appScope: CoroutineScope,
    private val api: AnalyticsApi
) {
    fun trackButtonClick(buttonId: String) {
        // Lanzamos y nos olvidamos. Si la pantalla se destruye, 
        // este scope sobrevive porque está atado al Singleton de Hilt.
        appScope.launch {
            try {
                api.sendEvent(buttonId)
            } catch (e: Exception) {
                // Falla silenciosamente. Gracias al SupervisorJob, 
                // esto no afectará al próximo botón que el usuario pulse.
            }
        }
    }
}

Escenario B: SDK de una Base de Datos Legacy

Imagina que estás aislando una base de datos antigua que se corrompe si dos corrutinas intentan escribir en ella exactamente al mismo tiempo. Tienes que forzar que todo pase por una especie de embudo de un solo hilo (Single Thread Confinement).

Aquí encapsulamos la creación dentro de la misma clase, ya que el Scope es de uso exclusivo interno para este SDK.

class LegacyDatabaseSDK {

    // 1. Creamos un hilo exclusivo y lo convertimos en Dispatcher
    private val dbDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()

    // 2. Aquí usamos Job() normal intencionadamente.
    // Si la base de datos colapsa, el Job cancelará todas las peticiones en cola.
    private val sdkScope = CoroutineScope(Job() + dbDispatcher + CoroutineName("DbScope"))

    fun insertUser(user: User) {
        // 3. Todo lo que entre aquí se encolará en ese único hilo
        sdkScope.launch {
            database.performInsert(user)
        }
    }

    fun shutdownDatabase() {
        // 4. Limpieza VITAL
        sdkScope.cancel() // Cancelamos trabajos pendientes
        dbDispatcher.close() // ¡No olvides liberar el hilo nativo de Java!
    }
}

Utilizamos Job() (porque si hay un fallo crítico, abortamos todo) + Dispatcher Personalizado (ojo, para crear un Dispatcher de un único Hilo, utilizamos **Executors.newSingleThreadExecutor().asCoroutineDispatcher()**).

Y te diría que con eso ya estaría, pero… siento decirte que aún nos queda.

Porque resulta que lo que te acabo de explicar es una práctica algo obsoleta. Lo normal sería que al terminar de leer el último bloque de código te hubieses preguntado: “¿Es esta la única forma de aislar trabajo en Kotlin? A mi me suena haber visto otras formas”.

Y si es así, enhorabuena, porque estarías totalmente en lo cierto.

Aunque esta aproximación con Executors es perfectamente válida y muy común (especialmente cuando interactuamos con código Java legacy), el ecosistema de Coroutines ha evolucionado para ofrecernos alternativas más eficientes.

A continuación, vamos a ver cómo podemos modernizar este mismo escenario y qué otras herramientas tenemos a nuestra disposición para crear Dispatchers personalizados.

Tipos de Dispatchers Personalizados

El gran inconveniente de utilizar Executors.newSingleThreadExecutor() es que estamos obligando al sistema a instanciar un hilo nativo completamente nuevo. Esto consume recursos y, como vimos en el método shutdownDatabase(), nos impone la responsabilidad vital de cerrarlo manualmente con un .close() para evitar fugas de memoria (memory leaks).

Hoy en día, la práctica recomendada es utilizar la función limitedParallelism sobre un Dispatcher que ya existe. Esta maravilla nos permite crear precisamente ese embudo que mencionábamos antes (o de límite de concurrencia), pero reutilizando inteligentemente los hilos que Kotlin ya tiene listos en su pool interno.

Si refactorizamos nuestro SDK utilizando esta técnica, el código se vuelve no solo más eficiente, sino también más seguro y limpio:

class LegacyDatabaseSDK {

    // 1. Creamos el límite reutilizando los hilos de IO (Máxima eficiencia)
    // Garantizamos que solo 1 corrutina operará al mismo tiempo.
    private val dbDispatcher = Dispatchers.IO.limitedParallelism(1)

    // 2. El Scope se mantiene igual, gestionando el ciclo de vida internamente.
    private val sdkScope = CoroutineScope(Job() + dbDispatcher + CoroutineName("DbScope"))

    fun insertUser(user: User) {
        sdkScope.launch {
            database.performInsert(user)
        }
    }

    fun shutdownDatabase() {
        // 3. Limpieza: Cancelamos las peticiones pendientes.
        sdkScope.cancel() 

        // 🎉 Ya no necesitamos hacer dbDispatcher.close()
        // El hilo de IO volverá al pool general automáticamente.
    }
}

Pero… ¡ESPERA! Que aún hay más. Porque dependiendo de los requerimientos específicos de tu arquitectura, podrías cruzarte (o necesitar) otras opciones para crear contextos personalizados:

  • Adaptar otros Executors de Java: Si en lugar de un solo hilo, tu base de datos antigua soportara exactamente 4 conexiones simultáneas, podrías adaptar un Fixed Thread Pool utilizando Executors.newFixedThreadPool(4).asCoroutineDispatcher(). La regla de oro aquí se mantiene: si tú lo creas desde Java, tú debes llamar a su .close().
  • Funciones nativas (APIs Delicadas): Es muy probable que en tutoriales o bases de código un poco más antiguas te encuentres con newSingleThreadContext("DbThread"). Esta función nativa de Kotlin es muy atractiva porque te permite bautizar al hilo con un nombre personalizado (lo cual es increíble para leer los logs), pero actualmente está marcada como una API delicada/obsoleta y el propio equipo de Kotlin recomienda migrar siempre a limitedParallelism.

3. Flujos Reactivos e Integración con la UI Moderna

Llegamos a la prueba de fuego y a una de las partes más importantes de cualquier entrevista técnica. Puedes tener la capa de datos más limpia y optimizada del mundo, pero si no sabes conectar tus flujos asíncronos con la Interfaz de Usuario (ya sea Jetpack Compose o XML), tu app sufrirá fugas de memoria, gastará batería en segundo plano o se congelará.

Vamos a ver el viaje completo del dato: desde que nace como un flujo continuo, hasta que el usuario aprieta un botón en la pantalla.

El origen del dato: Flow, StateFlow y SharedFlow

Todo Senior debería saber ya que mientras una función suspend clásica te devuelve un único resultado de forma asíncrona, un **Flow** es un flujo de datos que emite múltiples valores a lo largo del tiempo de forma secuencial (ideal para leer una base de datos en tiempo real o recibir actualizaciones de ubicación).

Pero en la capa de presentación (nuestro ViewModel), solemos transformar estos flujos “fríos” en flujos “calientes” (Hot Flows, es decir, que emiten datos aunque nadie los esté escuchando). Lo que no todos los Senior saben es… ¿por qué?

  1. Para compartir recursos (ej: no encender el sensor GPS dos veces si tienes dos vistas escuchando).
  2. Para evitar que la base de datos repita una consulta pesada cada vez que giras la pantalla y la Activity se recrea.

Si eres Junior, y has visto StateFlow y SharedFlow en la misma frase, lo mismo te gustaría profundizar sobre ello para entender perfectamente las diferencias que existen entre esos nombres que tanto se parecen.

Y por suerte, realmente no es tan difícil porque partimos de que ambos son Hot Flows y además:

  • **StateFlow:* Siempre tiene un estado actual inicial y retiene el último valor emitido. Ideal para representar el estado de la UI (el reemplazo moderno de LiveData). Es estrictamente conflado*: si la vista es más lenta que el emisor, se saltará estados intermedios, porque a la UI solo le importa pintar el estado final.
  • **SharedFlow:** No tiene valor inicial por defecto y se usa más para emitir "eventos" únicos (como un click, mostrar un Snackbar o una navegación). No se salta estados salvo que tú configures su caché/búfer explícitamente.

StateIn o cómo calentar un flujo.

Aquí la cosa se pone un poco más peliaguda. Cualquier Junior/Mid puede saber que para convertir un flujo frío en un StateFlow, se utiliza el operador stateIn, aunque sea haciendo un copy/paste de otra parte del código. Pero este operador esconde una de las preguntas estrella que te puede hacer destacar: ¿Cómo le indicas a la corrutina cuándo empezar y cuándo detenerse? Hablemos pues, del parámetro started:

  • **SharingStarted.WhileSubscribed(5000): El flujo comienza cuando la UI se conecta. Se detiene cuando la vista desaparece, pero espera 5 segundos antes de apagarse**. Es el salvavidas para los cambios de configuración. Cuando giras el móvil, la Activity se destruye y se recrea en milisegundos. Si no pones ese retraso de 5 segundos, el flujo se apagaría y volvería a encenderse, obligando a la base de datos a rehacer todo el trabajo.
  • **SharingStarted.Lazily:** Comienza con el primer suscriptor, pero nunca se detiene (permanece vivo en memoria hasta que el ViewModel se destruye). Ideal para cachés permanentes en la vida de esa pantalla.
  • **SharingStarted.Eagerly:** Arranca inmediatamente al instanciar el ViewModel, haya suscriptores o no. Rara vez se usa, salvo para "calentar" cachés de datos críticos al inicio de la app.

LaunchedEffect vs rememberCoroutineScope

Una vez que el ViewModel tiene el dato listo, la UI tiene que reaccionar. En Jetpack Compose, no puedes (ni debes) lanzar una corrutina “en el aire” dentro de un @Composable, debes utilizar o **rememberCoroutineScope o `rememberCoroutineScope`*. La pregunta de entrevista obligada es: "¿Cuándo usas uno u otro?".*

La respuesta la podemos resumir en una frase: ¿Quién aprieta el gatillo? ¿El ciclo de vida o el usuario?

  • **LaunchedEffect (ciclo de vida):** Se usa cuando quieres que una corrutina nazca y muera atada a la presencia del Composable en la pantalla. Útil para animaciones de entrada o peticiones de red iniciales. Si el Composable desaparece (sale de la composición), la corrutina se cancela automáticamente.
  • **rememberCoroutineScope (usuario):* Devuelve un Scope para que tú lances corrutinas en respuesta a eventos manuales. Los callbacks* como el onClick de un botón no son funciones suspend ni composables, así que necesitas este "puente" para lanzar el trabajo.
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {

    // ✅ CASO A: El ciclo de vida manda
    // Se ejecuta al entrar a la pantalla y se cancela si el usuario sale.
    LaunchedEffect(Unit) {
        viewModel.trackScreenViewed()
    }

    // ✅ CASO B: El usuario manda
    // Guardamos el scope atado a esta pantalla
    val scope = rememberCoroutineScope()

    Button(onClick = {
        // onClick NO es suspend, necesitamos el scope para lanzar la corrutina
        scope.launch { 
            viewModel.saveProfile() 
        }
    }) {
        Text("Guardar Perfil")
    }
}

Recolectando Flows: El devorador de batería oculto

En la vista recogemos el dato, y llegados a este punto viene una pregunta clásica: “¿Por qué está mal usar collectAsState() para observar el estado del ViewModel en Android?"

Si usas collectAsState(), la UI seguirá recolectando datos del StateFlow aunque la app esté minimizada (en segundo plano). La base de datos seguirá emitiendo, la memoria se consumirá y la batería del usuario se agotará por culpa de una pantalla que ni siquiera está viendo.

La solución en Compose es collectAsStateWithLifecycle(). Esta función suspende la recolección de forma segura cuando la vista ya no está activa, respetando el ciclo de vida real del sistema operativo.

¿Y si el proyecto usa XML (Fragments/Activities)? La alternativa directa y obligatoria es utilizar la función repeatOnLifecycle().

// ❌ EL ERROR DE PRINCIPIANTE (Compose)
val state by viewModel.uiState.collectAsState() 

// ✅ LA FORMA SENIOR (Compose)
val state by viewModel.uiState.collectAsStateWithLifecycle()

// ✅ LA FORMA SENIOR (XML / Fragment)
viewLifecycleOwner.lifecycleScope.launch {
    // Suspende la recolección si la app va a segundo plano y la reanuda al volver
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            renderState(state)
        }
    }
}

¡OJO! Si respondes bien a lo anterior, el entrevistador te pondrá a prueba con esto: “¿Por qué le pasas el estado STARTED a la función repeatOnLifecycle? ¿Por qué no usar RESUMED o CREATED?"

  • Si usas CREATED: Estarías recolectando datos incluso cuando la app está en segundo plano (minimizada). Volvemos al problema inicial: desperdicio de batería.
  • Si usas DESTROYED: Carece de sentido; en este estado la vista muere definitivamente y todo se cancela, no se vuelve a iniciar.
  • Si usas RESUMED (La trampa mortal): Imagina que el usuario usa su móvil en Pantalla Dividida (Split Screen). Tiene tu app arriba y WhatsApp abajo. Si está escribiendo en WhatsApp, tu app está visible, pero no tiene el foco. Para el ciclo de vida de Android, tu app no está en RESUMED, está en STARTED. Si usaras RESUMED, tu interfaz dejaría de actualizarse (quedaría congelada) a pesar de que el usuario la está viendo fijamente.

Por lo tanto, STARTED es el estado perfecto: Mantiene la UI actualizada mientras el usuario la está viendo de forma directa o indirecta. Ni un segundo más, ni un segundo menos.

4. Testing de Corrutinas

Imagina la escena: has superado las preguntas de arquitectura, has evitado memory leaks aislando tus Scopes y has demostrado que sabes conectar flujos con la UI sin fundir la batería del usuario. El entrevistador asiente con la cabeza; parece que la entrevista técnica está en el bolsillo. Pero entonces, se recuesta en la silla y lanza la verdadera prueba del algodón: “Todo esto está genial, pero ¿cómo testeas exactamente este código asíncrono?”

Muchos candidatos se quedan en blanco llegados a este punto. Un perfil Senior no solo escribe código asíncrono robusto en producción, sino que sabe cómo domar esa asincronía en un entorno de pruebas de forma determinista, controlando el tiempo y los hilos a su antojo para que los tests no sean una lotería de crashes en el CI/CD.

La API moderna para testear corrutinas se basa en la función runTest, la cual utiliza un reloj virtual (TestCoroutineScheduler). Esto es casi brujería: permite que un delay(5000) de 5 segundos se ejecute en un milisegundo en tus tests, ahorrándote tiempos de espera infinitos.

Pero la magia del tiempo virtual no funciona sola; necesita un director de orquesta. Y aquí es donde el entrevistador te pondrá el primer anzuelo conectando con lo que vimos en el Bloque 2: “Si en tu código de producción usas inyección de dependencias para los Dispatchers, ¿qué Dispatcher de testeo le inyectas a tu componente y cómo afecta eso a la ejecución?”

Tu respuesta debe demostrar que conoces las dos herramientas clave que nos da el framework y, sobre todo, cómo se comportan en la práctica:

  • StandardTestDispatcher: Es el más riguroso y predecible. Encola las corrutinas nuevas que se lanzan, lo que significa que no se ejecutan inmediatamente. Como desarrollador, tienes que tomar el control manual del tiempo virtual utilizando funciones como advanceUntilIdle() o runCurrent() para empujar la ejecución hacia adelante y evaluar los resultados paso a paso.
  • UnconfinedTestDispatcher: Ejecuta las corrutinas de forma “eager” (inmediata) en el hilo actual, sin encolarlas. Es la opción ideal y más rápida para tests sencillos donde no te importa el control milimétrico del tiempo de suspensión, sino simplemente verificar el resultado final.

Para que puedas entenderlo mejor, te dejo este ejemplo de cómo cambia la misma lógica dependiendo del Dispatcher:

// 1. EJEMPLO CON STANDARD: Requiere control manual
@Test
fun `test con StandardTestDispatcher`() = runTest { 
    var resultado = "Inicial"

    launch { 
        resultado = "Actualizado" 
    }

    // ❌ La corrutina está encolada, aún NO se ha ejecutado.
    assertEquals("Inicial", resultado) // Esto da verde ✅

    // ⏩ Empujamos el tiempo virtual manualmente
    advanceUntilIdle() 

    // ✅ Ahora sí se ha ejecutado el bloque del launch
    assertEquals("Actualizado", resultado)
}

// 2. EJEMPLO CON UNCONFINED: Ejecución inmediata
@Test
fun `test con UnconfinedTestDispatcher`() = runTest(UnconfinedTestDispatcher()) {
    var resultado = "Inicial"

    launch { 
        resultado = "Actualizado" 
    }

    // ✅ La corrutina se ejecutó instantáneamente al lanzarse. 
    // No necesitamos llamar a advanceUntilIdle().
    assertEquals("Actualizado", resultado) 
}

¿Cómo testeas un un StateFlow?

Te prometo que ya te dejo tranquilo, demasiada información por el momento, pero es que si hay una pregunta que les gusta a los entrevistadores sobre testing y que esté relacionado con las corrutinas es precisamente esta.

Como recordarás, un StateFlow es un flujo caliente: está constantemente activo y nunca se completa. Si en tu test unitario intentas hacer un .collect{} tradicional para verificar sus valores, el test se quedará colgado ejecutándose hasta el infinito (o hasta dar un TimeoutException). Necesitas recolectar el flujo en una corrutina secundaria, pero ¿con qué Dispatcher?

Si usas el StandardTestDispatcher, tu test fallará o se volverá extremadamente complejo porque las emisiones del flujo se quedarán atrapadas en la cola de ejecución esperando a que avances el reloj manual de forma síncrona. Por eso, la recomendación oficial para testear flujos calientes es recolectarlos usando el UnconfinedTestDispatcher, ya que al ejecutar las emisiones de forma inmediata, registra cada cambio de estado al instante en el orden exacto en que ocurren.

Para ejecutar esta recolección sin bloquear el test, un verdadero Senior maneja dos grandes vías:

1. La solución nativa (backgroundScope): La API de runTest nos provee de un backgroundScope. Puedes lanzar la recolección de tu flujo dentro de este Scope hijo. La gran ventaja es que runTest está diseñado para cancelar automáticamente todo lo que viva dentro de backgroundScope una vez que el código principal del test termine, evitando que el proceso se quede colgado.

2. La solución de la industria (Turbine): Esta es la respuesta que el entrevistador está deseando escuchar. La librería estándar de facto en la industria de Android para testear flujos es Turbine (desarrollada por CashApp). Turbine encapsula toda la complejidad que acabamos de ver (el manejo de los Scopes, la cancelación y los Dispatchers) y te permite ir reclamando y evaluando las emisiones una a una de forma secuencial y súper limpia a través de funciones suspensivas.

Fíjate en lo elegante y legible que luce un test Senior utilizando Turbine bajo el paraguas de runTest:

@Test
fun `al cargar la pantalla, el estado cambia de Loading a Success`() = runTest {
    // Usamos la función .test{} que nos provee Turbine
    viewModel.uiState.test {
        // 1. Comprobamos el estado inicial (gracias a que StateFlow retiene el último valor)
        assertEquals(UiState.Loading, awaitItem()) 

        // Simularíamos la acción del usuario o el fin de la llamada de red...
        // (que internamente se ejecuta instantáneamente gracias al reloj virtual)

        // 2. Comprobamos el nuevo estado emitido
        val successState = awaitItem()
        assertTrue(successState is UiState.Success)

        // 3. ¡Vital! Cancelamos la recolección para que el test finalice correctamente
        cancelAndIgnoreRemainingEvents() 
    }
}

En resumen: De Junior a Senior hay un “hilo” de diferencia

Superar una entrevista técnica en Android hoy en día no consiste en memorizar las funciones de la librería de Coroutines, sino en entender por qué y cómo suceden las cosas por debajo de ese código tan limpio que escribimos.

A modo de resumen, recuerda que en tu próxima entrevista debes demostrar que:

  • Entiendes la maquinaria: La suspensión no es magia, es el compilador gestionando una máquina de estados (CPS).
  • Controlas el entorno: Huyes de malas prácticas como GlobalScope o el abuso de runBlocking, y sabes gestionar el ciclo de vida y los errores de forma predecible creando tus propios Scopes cuando la arquitectura lo requiere.
  • Proteges tus datos: Sabes qué es una condición de carrera y cómo aislar el estado compartido mediante embudos de hilos o Mutex.
  • Respetas al usuario: Conectas eficientemente la capa de datos con la UI, respetando el ciclo de vida real del sistema operativo (usando STARTED) para no drenar la batería del dispositivo en segundo plano.
  • Garantizas la fiabilidad: Escribes código asíncrono pensando desde el minuto uno en su testeabilidad, dominando el control del tiempo virtual y evaluando flujos infinitos sin miedo gracias a herramientas como Turbine.

Si dominas estos cuatro grandes bloques y los defiendes con seguridad, no habrá pregunta sobre asincronía que te pille desprevenido.

¡Mucha suerte en tu próxima entrevista técnica!


메타데이터
post_id
d423cabed6d6
slug
preguntas-de-entrevista-android-dominando-corrutinas-y-flows-d423cabed6d6
url
https://medium.com/@miguelzaragozaserrano/preguntas-de-entrevista-android-dominando-corrutinas-y-flows-d423cabed6d6
canonical_url
https://medium.com/@miguelzaragozaserrano/preguntas-de-entrevista-android-dominando-corrutinas-y-flows-d423cabed6d6
author_url
https://medium.com/@miguelzaragozaserrano
status
ok
fetched_at
2026-06-09 15:37:30