Coroutines estructuradas en Kotlin: por qué un scope mal elegido filtra memoria en apps Android
Cada vez que revisamos un reporte de memory leak en una app Android que ya usa coroutines, el patrón se repite: alguien lanzó una coroutine desde GlobalScope, o desde un scope propio que nunca se cancela, y esa coroutine sigue viva mucho después de que la vista que la originó desapareció. El resultado no es distinto del leak clásico de un AsyncTask con referencia implícita a la Activity, solo que ahora ocurre dentro de una API que promete resolver justo ese problema. Structured concurrency no es magia que evita fugas por sí sola; es una disciplina que Kotlin hace fácil de seguir cuando se elige el scope correcto, y fácil de romper cuando no.
Qué es lo que structured concurrency resuelve en realidad
Antes de coroutines, el ciclo de vida de una tarea asíncrona en Android se manejaba a mano. Un AsyncTask guardaba una referencia a la Activity para actualizar la UI al terminar, y si la Activity se destruía antes de que la tarea terminara, esa referencia seguía viva dentro del hilo en background hasta que la tarea completara, arrastrando consigo toda la vista y su árbol de objetos. La solución habitual era anular manualmente el callback en onDestroy, lo cual funcionaba solo si nadie se olvidaba de escribir esa línea.
Structured concurrency invierte la responsabilidad: en lugar de que cada tarea individual decida cuándo cancelarse, cada coroutine nace dentro de un CoroutineScope que tiene su propio ciclo de vida, y cancelar ese scope cancela automáticamente todas las coroutines que se lanzaron dentro de él, incluidas las que a su vez lanzaron otras coroutines hijas. La jerarquía de Jobs es la pieza que lo hace posible: cada launch o async crea un Job hijo del Job del scope que lo contiene, y cancelar un Job padre se propaga hacia toda su descendencia.
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
scope.launch {
val user = fetchUser() // coroutine hija 1
launch {
syncAnalytics(user) // coroutine hija 2, anidada
}
}
scope.cancel() // cancela las tres: el launch externo y ambas hijas
El problema no es la API. El problema es que esta garantía solo existe si el scope que se usa efectivamente se cancela en el momento correcto, y ahí es donde GlobalScope rompe la promesa.
Por qué GlobalScope filtra memoria
GlobalScope no tiene ciclo de vida. Es un CoroutineScope que vive tanto como el proceso de la aplicación, sin ningún Job padre que alguien vaya a cancelar cuando una pantalla desaparece. Lanzar una coroutine desde GlobalScope dentro de un Fragment o una Activity equivale a desconectar esa coroutine del ciclo de vida de la vista que la originó:
class ProfileFragment : Fragment() {
private lateinit var userRepo: UserRepository
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
GlobalScope.launch {
val user = userRepo.fetchUser() // llamada de red lenta
withContext(Dispatchers.Main) {
binding.username.text = user.name // binding puede ya no existir
}
}
}
}
Si el usuario navega fuera de ProfileFragment antes de que fetchUser() termine, la coroutine sigue corriendo. Cuando finalmente completa, intenta escribir en binding.username, que en el mejor de los casos ya fue liberado por el ciclo de vida del Fragment y lanza una excepción, y en el peor de los casos mantiene vivo el binding completo —con su referencia a la vista y a todo lo que esa vista retiene— porque la lambda capturó esa referencia y la coroutine que la contiene todavía no terminó. Ese es exactamente el mecanismo del leak: no es que GlobalScope en sí ocupe memoria, es que cada coroutine lanzada desde ahí retiene, mientras vive, todo lo que capturó en su clausura.
Los scopes que sí tienen ciclo de vida
Android Jetpack expone dos scopes pensados para este caso concreto, y la diferencia entre ellos es a qué nivel del ciclo de vida están atados.
lifecycleScope, disponible en cualquier LifecycleOwner (Activity o Fragment), cancela sus coroutines cuando el Lifecycle llega a DESTROYED. Sirve para trabajo que tiene sentido solo mientras esa vista concreta existe:
class ProfileFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
val user = userRepo.fetchUser()
binding.username.text = user.name
}
}
}
Nótese el uso de viewLifecycleOwner y no this como LifecycleOwner en un Fragment: la vista de un Fragment puede destruirse y recrearse mientras el Fragment mismo sigue vivo (por ejemplo, en un ViewPager), y usar this.lifecycleScope en ese caso mantiene coroutines corriendo contra un binding que ya no existe, reproduciendo el mismo leak que GlobalScope pero de forma más sutil porque el scope sí se cancela, solo que en el momento equivocado.
viewModelScope, disponible en cualquier ViewModel, se cancela cuando ViewModel.onCleared() se invoca, es decir, cuando la Activity o el Fragment padre se destruyen de forma definitiva, no en cada rotación de pantalla. Es el scope correcto para trabajo que debe sobrevivir a cambios de configuración pero no a la destrucción real de la pantalla:
class ProfileViewModel(private val userRepo: UserRepository) : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user
fun loadUser() {
viewModelScope.launch {
_user.value = userRepo.fetchUser()
}
}
}
Una rotación de pantalla no cancela esta coroutine porque el ViewModel sobrevive a la recreación de la Activity; salir de la pantalla definitivamente sí la cancela, porque ahí es cuando onCleared() se dispara. Esa distinción —sobrevive a rotación, no sobrevive a destrucción real— es la razón por la que viewModelScope casi siempre es la elección correcta para trabajo iniciado desde la capa de presentación, y lifecycleScope queda reservado para trabajo que de verdad depende de que una vista concreta esté en pantalla, como animaciones o actualizaciones de UI que no tiene sentido reanudar en un ViewModel.
Qué pasa cuando una coroutine sobrevive a la vista que la lanzó
El síntoma más visible de un scope mal elegido no siempre es un OutOfMemoryError claro. Con más frecuencia es una NullPointerException intermitente sobre un binding nulo, un crash reportado como poco frecuente porque solo ocurre si el usuario navega rápido, o un consumo de memoria que crece lentamente en sesiones largas porque cada navegación deja atrás coroutines colgadas que retienen fragmentos de la jerarquía de vistas anterior. Ninguno de estos síntomas apunta obviamente a coroutines como causa, lo cual hace que este tipo de leak tarde en diagnosticarse: el stack trace de la excepción muestra el punto donde falló, no el punto donde se lanzó la coroutine que debió haberse cancelado antes.
Detectarlo en desarrollo es más simple que en producción. El profiler de memoria de Android Studio permite forzar una rotación de pantalla o una navegación hacia atrás y observar si el heap dump retiene instancias de la Activity o Fragment anterior; si las retiene, casi siempre hay una coroutine con el scope equivocado sosteniendo esa referencia. LeakCanary detecta el mismo patrón de forma automatizada y en el trace suele señalar directamente la cadena de referencias que pasa por un Continuation de coroutine, lo cual es la pista más directa de que el problema es un scope, no un listener olvidado.
Lo que structured concurrency no resuelve solo
Elegir el scope correcto evita el leak más común, pero no resuelve todo. Un SupervisorJob mal usado dentro de viewModelScope puede ocultar excepciones: si una coroutine hija lanza una excepción no capturada y el padre es un SupervisorJob, esa excepción no cancela a las coroutines hermanas, lo cual es deseable para no tumbar todo el ViewModel por un fallo aislado, pero si nadie instala un CoroutineExceptionHandler o envuelve la llamada en un try/catch, la excepción simplemente desaparece sin registro, y el síntoma pasa a ser un dato que nunca se actualizó, sin ningún crash que lo delate.
Tampoco resuelve fugas causadas por scopes propios creados a mano fuera del ciclo de vida de Jetpack —por ejemplo, un scope de aplicación creado con CoroutineScope(SupervisorJob() + Dispatchers.IO) en una clase singleton— donde la responsabilidad de cancelar vuelve a caer en quien escribió ese scope, exactamente el mismo punto de fallo humano que existía con AsyncTask. La diferencia real que trae Kotlin no es que sea imposible filtrar memoria con coroutines, es que la API ya no obliga a implementar la cancelación desde cero: basta con delegarla en un scope que Jetpack ya ató al ciclo de vida correcto, y reservar los scopes manuales para los pocos casos donde de verdad hace falta controlar ese ciclo de vida uno mismo.