¿Cuáles son las fugas de memoria más comunes en apps Android, y cómo las detectás?
La mayoría de las fugas en Android son referencias fuertes comunes —un campo estático, un listener, una lambda— que sobreviven en silencio a la Activity que señalan.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cuáles son las fugas de memoria más comunes en apps Android, y cómo las detectás?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.Una fuga de memoria en un runtime con recolector de basura como ART no significa que la memoria "desaparezca" — significa que un objeto que debería poder liberarse sigue siendo alcanzable desde una raíz del GC, así que el recolector está obligado a conservarlo. En Android, en particular, los objetos que hacen esto costoso son Activity, Fragment, View y Context, porque cada uno es la raíz de un grafo retenido enorme —una jerarquía de vistas inflada, adapters, bitmaps, listeners— así que dejar viva una sola Activity ya destruida puede fácilmente retener varios megabytes, y filtrar una en cada rotación de pantalla convierte "un poco de desperdicio" en un OutOfMemoryError unas cuantas navegaciones después.
El patrón detrás de casi todas las fugas en Android tiene la misma forma: algo que vive más tiempo que el componente guarda una referencia de vuelta hacia él. El vehículo más común es un singleton —un object en Kotlin, un campo estático, un scope singleton de inyección de dependencias— que guarda un Context, una View, o un listener cuya lambda captura implícitamente this@Activity. Los singletons viven todo el tiempo que vive el proceso, así que lo que retengan, directamente o a través de una cadena de campos, vive ese mismo tiempo, sin importar lo que haga onDestroy().
Un segundo vehículo común es un callback registrado que nunca se desregistra: un LocationListener o SensorEventListener que queda corriendo, un LiveData observado con observeForever() en lugar de observe(lifecycleOwner) (la sobrecarga consciente del ciclo de vida remueve el observer automáticamente al destruirse; la versión "forever" cumple literalmente lo que dice), una suscripción de EventBus, un Disposable de RxJava que nunca se agrega a un CompositeDisposable que se limpia en onDestroy(), o un BroadcastReceiver registrado por código al que le falta su unregisterReceiver() correspondiente.
Un tercer vehículo, más sutil, es el trabajo diferido: Handler.postDelayed() llamado desde una clase interna no estática captura implícitamente la Activity que la contiene, y si el usuario navega a otra pantalla antes de que venza el delay, ese Runnable —y la Activity detrás de él— queda en la cola de mensajes todo ese tiempo, o para siempre si el handler está atado a algo de vida más larga. El equivalente con corrutinas es un GlobalScope.launch { ... } (o cualquier scope que no se cancele al destruirse) cuya lambda captura una View o una Activity; a diferencia de viewModelScope o lifecycleScope, nada lo cancela nunca, así que la captura vive hasta que la corrutina misma termine.
LeakCanary es la forma estándar de detectar esto automáticamente en builds de debug: se instala mediante un ContentProvider de auto-inicialización, engancha los callbacks de ciclo de vida de activities y fragments, y en el momento en que un componente se destruye empieza a vigilar la instancia con una referencia débil atada a una ReferenceQueue. Si la instancia no fue recuperada después de un par de GC forzados, LeakCanary vuelca el heap y corre Shark para calcular el camino más corto de referencias fuertes desde una raíz del GC hasta el objeto filtrado —que es exactamente la cadena que necesitás leer para arreglarlo—. El Memory Profiler de Android Studio hace la versión manual de la misma idea: tomás un heap dump, navegás a una pantalla sospechosa y volvés unas cuantas veces, tomás otro dump, y comparás la cantidad de instancias de tus clases Activity/Fragment —si el número sigue subiendo en lugar de volver a cero, algo las está reteniendo.
Acá va una versión realista de la fuga de singleton con listener, y su arreglo consciente del ciclo de vida. El singleton guarda una lista de lambdas listener y un LocationListener crudo registrado que nunca se remueve —cada lambda captura implícitamente su Activity contenedora a través de la llamada al método que hay adentro.
// FUGA: un singleton con vida del proceso retiene una Activity a través// de un listener registrado que nunca se remueve.object LocationTracker {private val listeners = mutableListOf<(Location) -> Unit>()private lateinit var locationManager: LocationManagerprivate val rawListener = LocationListener { location ->listeners.forEach { it(location) }}fun start(context: Context, onLocation: (Location) -> Unit) {listeners += onLocationlocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManagerlocationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 5_000L, 10f, rawListener)}}class MapActivity : AppCompatActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)LocationTracker.start(this) { location -> updateMapPin(location) } // captura this@MapActivity}}
LocationTracker sobrevive a cada instancia de MapActivity por diseño —es un singleton— así que la lambda que queda en listeners, y la MapActivity que captura en silencio para llamar a updateMapPin, quedan retenidas para siempre, o hasta que el proceso muera. Rotá el dispositivo diez veces y filtrás diez Activities.
// ARREGLADO: el registro y el desmontaje están atados al mismo ciclo de vida,// y la Activity nunca se le entrega a nada que viva más que ella misma.class MapActivity : AppCompatActivity(), DefaultLifecycleObserver {private val locationManager by lazy {getSystemService(Context.LOCATION_SERVICE) as LocationManager}private val locationListener = LocationListener { location -> updateMapPin(location) }override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)lifecycle.addObserver(this)}override fun onStart(owner: LifecycleOwner) {locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 5_000L, 10f, locationListener)}override fun onStop(owner: LifecycleOwner) {locationManager.removeUpdates(locationListener) // desmontaje simétrico}}
El arreglo se apoya en dos ideas: nada que viva más que la Activity guarda jamás una referencia directa hacia ella (el listener vive en la propia Activity, no en un singleton), y cada onStart tiene un onStop que lo deshace — el registro y el desmontaje son simétricos dentro del mismo par de callbacks del ciclo de vida, no repartidos entre onCreate/onDestroy, donde es fácil olvidarse de la segunda mitad.
Más allá de este patrón puntual, la lista de chequeo que evita la mayoría de las fugas en Android es corta: preferí applicationContext sobre un Context de Activity en cualquier cosa que pueda sobrevivir a la pantalla; tratá WeakReference como último recurso, no como primer instinto —usualmente significa que evitaste arreglar el verdadero desajuste de tiempos de vida—; atá cada scope de corrutinas a viewModelScope o lifecycleScope en lugar de GlobalScope; y auditá cada llamada a registerX/addListener buscando su unregisterX/removeListener correspondiente en el callback espejo del ciclo de vida.