Saltar al contenido
Todas las entradas

Concurrencia estructurada en Android en 2026: scopes, cancelación y las fugas que evita

Una guía práctica de concurrencia estructurada con Kotlin en Android — viewModelScope frente a lifecycleScope, por qué se propaga la cancelación y las fugas de coroutines que elimina en silencio.

MFKAPPS 5 min de lectura

La mayoría de los errores de coroutines que he depurado en Android no eran por una función suspend haciendo algo mal — eran por una coroutine que sobrevivía a lo que la había iniciado. Una llamada de red termina después de que su pantalla desaparece y falla al intentar actualizar una vista destruida. Un colector de Flow sigue ejecutándose en segundo plano, consumiendo batería para una pantalla que ya nadie mira. La concurrencia estructurada es la disciplina que elimina estos casos por construcción, no por recordar limpiar — y entender lo que realmente garantiza vale más que memorizar qué Scope usar en cada sitio.

La idea central: una coroutine no puede sobrevivir a su scope

Toda coroutine en Kotlin pertenece a un CoroutineScope, y ese scope define su ciclo de vida. Lanza una coroutine dentro de un scope y se convierte en hija del job de ese scope. Cancela el scope, y cada hija —sin importar cuán profundamente anidada esté, sin importar en cuántas llamadas async se haya ramificado— se cancela con él. Esta es toda la garantía: no puedes filtrar accidentalmente una coroutine fuera del scope que la posee, porque el lenguaje no te da forma de hacerlo.

class BudgetViewModel(private val repo: TransactionRepository) : ViewModel() {
    fun refreshMonthlyTotal() {
        viewModelScope.launch {
            val total = repo.computeMonthlyTotal() // se suspende aquí
            _monthlyTotal.value = total
        }
    }
}

Si el usuario navega a otra pantalla y el ViewModel se limpia a mitad de computeMonthlyTotal(), viewModelScope se cancela automáticamente, y la coroutine de arriba se detiene — nunca llega a la línea que escribe en _monthlyTotal. No hace falta comprobar ninguna bandera manual, ni una guarda isActive antes de escribir, porque la coroutine simplemente no se reanuda después de que la cancelación alcanza un punto de suspensión.

viewModelScope, lifecycleScope, y elegir el correcto

Android ofrece dos scopes ligados a ciclos de vida de componentes, y elegir el incorrecto es el error de concurrencia estructurada más común que veo:

  • viewModelScope vive tanto como el ViewModel — sobrevive a cambios de configuración (rotación, cambio de modo oscuro) y solo se cancela cuando el ViewModel se limpia, típicamente cuando el usuario abandona la pantalla para siempre. Úsalo para trabajo cuyo resultado la pantalla sigue necesitando tras una rotación: cargar datos, escribir en un repositorio, calcular un valor derivado.
  • lifecycleScope, y específicamente repeatOnLifecycle(Lifecycle.State.STARTED) dentro de él, está ligado al ciclo de vida de vista de la Activity o el Fragment. Úsalo para todo lo que realmente deba detenerse cuando la pantalla no es visible — lo más común, recolectar un Flow para actualizar la UI:
class ReminderListFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.reminders.collect { reminders ->
                    adapter.submitList(reminders)
                }
            }
        }
    }
}

Recolectar directamente en viewModelScope en su lugar mantendría el colector —y cada consulta Room que dispara— ejecutándose mientras la app está en segundo plano, ya que viewModelScope no sabe que la vista desapareció. Eso no es una fuga en el sentido de un fallo; es un costo más silencioso y lento: CPU desperdiciada, batería desperdiciada, y un Flow reemitiendo hacia una UI que nadie puede ver. repeatOnLifecycle es la solución precisamente porque re-deriva un scope nuevo ligado a STARTED, cancelando y reiniciando la recolección a medida que el ciclo de vida entra y sale de ese estado.

La concurrencia estructurada hace honesto el manejo de errores

Antes de la concurrencia estructurada, un patrón común era lanzar una coroutine desnuda con GlobalScope.launch — sin scope, sin dueño, fuera de cualquier supervisión de un padre. Si lanzaba una excepción, esta no iba a ningún lugar útil, y si la pantalla ya había desaparecido cuando terminaba, no quedaba nada para capturar el fallo. La concurrencia estructurada obliga a que toda coroutine tenga un padre, lo que significa que toda excepción tiene un lugar definido adonde propagarse: subiendo por la jerarquía de jobs hasta lo que instaló un CoroutineExceptionHandler, o hacia la propia cancelación del scope si nada lo hizo.

private val handler = CoroutineExceptionHandler { _, throwable ->
    _uiState.value = UiState.Error(throwable.message)
}

fun syncPantryFromReceipt(bitmap: Bitmap) {
    viewModelScope.launch(handler) {
        val items = ocrEngine.extract(bitmap) // puede lanzar excepción
        repo.insertAll(items)
    }
}

Este es el patrón detrás de cómo Stocky maneja un escaneo de recibo fallido — el paso de OCR puede fallar por una docena de razones (mala iluminación, una fuente ilegible, un formato de recibo que nunca ha visto), y la concurrencia estructurada garantiza que ese fallo salga a la superficie en exactamente un lugar en lugar de desaparecer en silencio dentro de una coroutine de lanzar-y-olvidar.

async/await: la cancelación funciona en ambas direcciones

Los builders coroutineScope { } y async extienden la misma garantía a las coroutines hermanas: si una hija de un coroutineScope lanza una excepción, todas las demás hijas también se cancelan, y la excepción se propaga hacia afuera solo después de que todas hayan terminado. Esto importa cada vez que reparte trabajo concurrente que solo tiene sentido en conjunto:

suspend fun loadDashboard(): DashboardState = coroutineScope {
    val totalDeferred = async { repo.computeMonthlyTotal() }
    val trendDeferred = async { repo.computeSpendingTrend() }
    DashboardState(totalDeferred.await(), trendDeferred.await())
}

Si computeSpendingTrend() lanza una excepción, computeMonthlyTotal() también se cancela — nunca acabas con la mitad de un panel renderizado en silencio a partir de una solicitud que falló parcialmente. Sin concurrencia estructurada, ese segundo async seguiría ejecutándose después de que el primero fallara, su resultado esperado por nadie, su trabajo desperdiciado.

La conclusión

La concurrencia estructurada no es una preferencia de estilo — es lo que convierte “¿olvidé cancelar esto?” de un error en tiempo de ejecución en una imposibilidad en tiempo de compilación. La regla que importa en la práctica: lanza dentro del scope que coincide con cuánto tiempo se necesitará el resultado, no el scope que resulta estar cerca. viewModelScope para trabajo que a la pantalla le sigue importando tras una rotación, lifecycleScope con repeatOnLifecycle para todo lo que deba detenerse cuando la vista no es visible, y un CoroutineExceptionHandler en cualquier lugar donde un fallo necesite un destino definido en vez de ninguno.