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.
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:
viewModelScopevive tanto como elViewModel— sobrevive a cambios de configuración (rotación, cambio de modo oscuro) y solo se cancela cuando elViewModelse 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íficamenterepeatOnLifecycle(Lifecycle.State.STARTED)dentro de él, está ligado al ciclo de vida de vista de laActivityo elFragment. Úsalo para todo lo que realmente deba detenerse cuando la pantalla no es visible — lo más común, recolectar unFlowpara 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.
// Lecturas relacionadas
Más del diario
La API In-App Review de Android: pedir una valoración sin resultar molesto
Una guía práctica de la API In-App Review de Google: cómo funciona realmente, cuándo activarla y por qué el típico popup de 'valóranos' está perjudicando en silencio tu puntuación en Play Store.
App shortcuts y Quick Settings Tiles en Android: registrar agua sin abrir la app
Una guía práctica de la API dinámica ShortcutManager y de TileService en Android — cómo hacer que una acción de un solo toque se salte la app por completo, con los errores en los que caen la mayoría de las implementaciones.
Tipos de servicio en primer plano en Android en 2026: elegir el que realmente encaja con tu función
Una guía práctica sobre las restricciones de tipos de servicio en primer plano de Android — dataSync, mediaPlayback, specialUse, shortService — y cómo elegir el correcto sin que te lo maten o te lo rechacen.