Saltar al contenido
K
Android: la plataformaPaso a paso40 min de lectura · 5 min de máquina Intermedio

Fugas de memoria

Al terminar: Reproducir una fuga de memoria con un Context, leer el informe de LeakCanary y arreglarla.

Al terminar esta lección, Marcador tendrá una fuga de memoria provocada a propósito y su arreglo. Verás cómo LeakCanary escribe en Logcat el camino exacto por el que una pantalla destruida sigue viva, y verás que con el arreglo ese informe desaparece.

Importa porque las fugas no rompen nada a la vista: la app va bien un rato y luego se queda sin memoria. Casi todas nacen de una sola línea, guardar un Context donde no toca. Temas: qué es una fuga, el Context de Activity guardado en un object, leer el informe de LeakCanary y comprobar el arreglo.

Qué es una fuga

Antes de nada, una idea que usa todo lo demás: la referencia. Una variable no contiene el objeto, lo señala: el objeto vive en la memoria y la variable guarda dónde está. Decir que «A apunta a B» es decir que A tiene una referencia a B. Si escribes val a = Pelota() y luego val b = a, no hay dos pelotas: hay una pelota y dos flechas que la señalan.

Con eso, las piezas de esta lección, una a una:

  • Memoria y heap. Los objetos de la app viven en una zona de memoria llamada heap (el «montón»).
  • Recolector de basura (garbage collector). Es el servicio que libera los objetos a los que nadie puede llegar. Un objeto es alcanzable si hay un camino de flechas que lo señale desde una raíz.
  • Raíz (en el informe, GC Root: GC, de garbage collector). Es un punto de partida del que el recolector nunca se deshace: por ejemplo las propiedades de una clase que viven mientras viva el proceso (en Java se llaman campos static) o los hilos que siguen en marcha. En esta lección «propiedad» y «campo» son lo mismo mirado desde dos lados: Kotlin lo llama propiedad, y LeakCanary, que habla el idioma de Java, lo llama campo.
  • Fuga de memoria. Un objeto que ya no sirve pero sigue siendo alcanzable desde una raíz, así que nunca se libera.
  • Heap dump. Una foto del heap en un instante, con todos los objetos y quién apunta a quién. LeakCanary la hace por ti.

Un object de Kotlin (object, companion y vararg) es un singleton: una sola instancia que vive tanto como el proceso. Esa instancia está guardada en una propiedad estática, o sea, es una raíz, y todo lo que el object guarde en sus propiedades vive con él.

Error típico: guardar la Activity en un singleton

con el bug: el singleton guarda la Activity
object RegistroDeContexto {
    var contexto: Context? = null
 
    fun guardar(nuevo: Context) {
        contexto = nuevo        // si `nuevo` es una Activity, el singleton la retiene
    }
}
// ... en la Activity:
// RegistroDeContexto.guardar(this)
corregido: guardar el applicationContext
object RegistroDeContexto {
    var contexto: Context? = null
 
    fun guardar(nuevo: Context) {
        contexto = nuevo.applicationContext   // la instancia de la Application, que dura lo que el proceso
    }
}

Por qué falla. Al rotar, Android destruye la Activity y espera que el recolector libere su memoria. Pero el object la sigue señalando desde su propiedad estática (la que Kotlin crea para su instancia única), y eso es una raíz: la Activity vieja sigue alcanzable, y con ella todas sus vistas. No hay ningún error en pantalla.

Otras fugas con la misma forma: un listener (una función que otro objeto llama cuando ocurre algo) registrado y nunca desregistrado, o una vista de la pantalla guardada en una propiedad estática. Las dos tienen la misma raíz que la de arriba: algo que vive más que la pantalla y la tiene guardada.

Un ejemplo ajeno: ¿cuántas copias quedan?

Antes de tocar Marcador, la misma trampa en una app inventada, una de recetas. Su object no guarda una sola pantalla, sino una lista con todas las que se abren, «para saber cuántas hay»:

ejemplo mínimo: un object que apunta las pantallas que abre (con comentarios)
object Historial {                                      // singleton: vive lo que el proceso
    val pantallas = mutableListOf<Activity>()           // una lista que solo crece
}
 
class RecetasActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Historial.pantallas.add(this)                   // se apunta a sí misma cada vez que Android la crea
        setContent {
            Text("Pantallas apuntadas: ${Historial.pantallas.size}")   // el tamaño de la lista
        }
    }
}

Lo que se ve en pantalla: al abrir la app, «Pantallas apuntadas: 1». Si giras el móvil, Android destruye esa Activity y crea otra, que se apunta también, y la pantalla dice «Pantallas apuntadas: 2».

Ejercicio · apunta las pantallas y mira la fuga

Crea una subcarpeta nueva dentro de ejercicios (la carpeta que creaste en Rotación y muerte del proceso): clic derecho sobre com.sebastiannarvaez.marcador → New → Package → escribe .ejercicios.historial al final. Después, clic derecho sobre historial → New → Kotlin Class/File → File → Historial. Se ejecuta con una línea temporal en MainActivity.kt y Logcat con tag:Ejercicio, como en aquella lección.

Una pieza nueva, que necesitas para contar: isDestroyed es una propiedad de la Activity que vale true cuando Android ya llamó a su onDestroy (como isFinishing, que ya conoces).

Reto · Ejercicio: el historial de pantallas, con fuga y sin ella

Tiene dos tiempos, en el mismo fichero. El que se queda es el segundo.

  1. En Historial.kt, escribe un singleton llamado Historial con una función apuntar que recibe la pantalla (un parámetro de tipo ComponentActivity). Cada vez que se llama, la añade a una lista y escribe en Logcat (etiqueta Ejercicio) cuántas hay apuntadas y cuántas de ellas Android ya ha destruido. Añade, en MainActivity.kt, justo debajo de anotar("onCreate …"), una línea temporal que llame a apuntar pasándole this (la propia Activity), con la ruta completa, sin import, y con el comentario // EJERCICIO: quitar al terminar. Ejecuta y gira el móvil tres veces. Si Android 13 o posterior te pregunta por el permiso de notificaciones (es de LeakCanary), pulsa Permitir. La última línea debe ser:

    D Ejercicio: apuntadas: 4, ya destruidas: 3

    Esas tres son la fuga: están destruidas y siguen en memoria. Puede que LeakCanary saque un aviso; lo leerás en el paso.

  2. Arréglalo sin perder la cuenta: sustituye todo el contenido del fichero por una versión que, en vez de la lista, lleve un número, y que escriba solo cuántas hay creadas. Ejecuta y gira tres veces. La última línea debe ser:

    D Ejercicio: creadas: 4

Pistas, todo ya visto: object (object, companion y vararg); una lista que crece se declara con su tipo, mutableListOf<ComponentActivity>() (Colecciones); count { … } (Lambdas con listas).

Ver solución

Primer tiempo, con la fuga:

app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/historial/Historial.kt
package com.sebastiannarvaez.marcador.ejercicios.historial
 
import android.util.Log
import androidx.activity.ComponentActivity
 
// Versión 1, CON LA FUGA: el object guarda cada pantalla que se crea.
object Historial {
    private val pantallas = mutableListOf<ComponentActivity>()   // vive lo que el proceso, y solo crece
 
    fun apuntar(pantalla: ComponentActivity) {
        pantallas.add(pantalla)
        val destruidas = pantallas.count { it.isDestroyed }       // las que Android ya destruyó
        Log.d("Ejercicio", "apuntadas: ${pantallas.size}, ya destruidas: $destruidas")
    }
}

Tres de las cuatro son fugas: las tres primeras ya están destruidas y siguen señaladas desde la lista del object, que es una raíz, así que nunca se liberan, con todas sus vistas. La cuarta es la pantalla que estás viendo: está viva y la usas, así que no es una fuga (lo será cuando se destruya). El arreglo es no guardar la Activity: si solo hace falta contar, basta un número, que no señala a ninguna pantalla y no retiene nada.

Segundo tiempo, sin fuga (el fichero que se queda):

app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/historial/Historial.kt
package com.sebastiannarvaez.marcador.ejercicios.historial
 
import android.util.Log
import androidx.activity.ComponentActivity
 
// Versión 2, SIN FUGA: el object solo cuenta; un número no señala a ninguna pantalla.
object Historial {
    private var creadas = 0
 
    fun apuntar(pantalla: ComponentActivity) {   // la recibe pero no la guarda: así no retiene nada
        creadas++
        Log.d("Ejercicio", "creadas: $creadas")
    }
}

La línea temporal de MainActivity.kt, justo debajo de anotar("onCreate …"):

MainActivity.kt: la línea temporal del ejercicio
com.sebastiannarvaez.marcador.ejercicios.historial.Historial.apuntar(this) // EJERCICIO: quitar al terminar

Antes del paso, borra la línea temporal (la que acaba en // EJERCICIO: quitar al terminar). En MainActivity.kt, Cmd+F (Ctrl+F) con la palabra ejercicios no debe dar ningún resultado; si Android Studio añadió un import, bórralo también. El fichero del ejercicio se queda en su carpeta.

Paso · RegistroDeContexto y dos botones

Objetivo. Crear un object que guarda un Context de dos maneras (la mala y la buena) y dos botones en la pantalla para probarlas. Al terminar, con el botón malo y un giro de pantalla, Logcat imprimirá un informe de LeakCanary que termina en MainActivity. Con el botón bueno, no imprimirá ninguno.

app → kotlin+java → com.sebastiannarvaez.marcador
laboratoriose crea
RegistroDeContexto.ktse crea
MainActivity.ktse modifica
Un fichero nuevo y uno modificado, tal como los ves en la vista Android.

Primero, la carpeta. En el panel, app → kotlin+java, clic derecho sobre el paquete com.sebastiannarvaez.marcador (el que no lleva (test) ni (androidTest)) → New → Package. El campo ya trae com.sebastiannarvaez.marcador; escribe .laboratorio al final (con el punto delante) y pulsa Intro. En Android Studio los paquetes se escriben con puntos, y cada punto es una carpeta: .laboratorio añade una carpeta nueva dentro de com.sebastiannarvaez.marcador. Android Studio puede juntar en una sola línea las carpetas que solo tienen una hija; si ves dos nombres unidos por un punto en una línea (como ejercicios.contador_de_creaciones), es la misma carpeta de dentro de la otra. Después, clic derecho sobre laboratorio → New → Kotlin Class/File, elige File, escribe RegistroDeContexto y pulsa Intro.

Qué hay que hacer en cada fichero (esto es obligatorio; el código completo está más abajo):

  • RegistroDeContexto.kt: un singleton con una propiedad que guarda un Context que todos pueden leer pero solo él puede cambiar, y dos funciones: guardarSinCuidado, que guarda tal cual el que le pasan, y guardarConCuidado, que guarda el de la aplicación.
  • MainActivity.kt: dentro de la Column de setContent (la que ya tienes de las lecciones anteriores), justo después de Text("Marcador en vivo: $marcadorEnVivo") y antes de los botones de los goles, añade los dos botones. Button ya está importado desde Rotación y muerte del proceso.

Piezas nuevas de este paso:

  • var contexto: Context? = null con private set: el private set de Propiedades y visibilidad deja que solo el propio object cambie el valor.
  • nuevo.applicationContext: de cualquier Context, devuelve el de la Application.
  • this@MainActivity: dentro de una lambda, this puede referirse a otra cosa; la etiqueta @MainActivity dice «la Activity» (la viste en el ejemplo de la biblioteca, en Context y Application).

Los dos botones se llamarán «Guardar Activity (FUGA)» y «Guardar applicationContext (bien)», y le pasan la Activity a cada una de las dos funciones.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
app/src/main/java/com/sebastiannarvaez/marcador/laboratorio/RegistroDeContexto.kt
package com.sebastiannarvaez.marcador.laboratorio
 
import android.content.Context
 
// Un singleton (`object`): existe mientras viva el proceso. Cualquier cosa que
// guarde en sus propiedades sigue viva con el.
object RegistroDeContexto {
    // Guarda el Context «para usarlo luego». Es la trampa clasica.
    var contexto: Context? = null
        private set
 
    // EL BUG: si `nuevo` es una Activity, el singleton la mantiene en memoria
    // despues de que Android la destruya (al rotar, por ejemplo), con toda su
    // jerarquia de vistas. Cada rotacion deja otra copia. Eso es una fuga.
    fun guardarSinCuidado(nuevo: Context) {
        contexto = nuevo
    }
 
    // EL ARREGLO: `applicationContext` es la instancia de MarcadorApplication, que
    // dura lo que el proceso, asi que no retiene ninguna pantalla.
    fun guardarConCuidado(nuevo: Context) {
        contexto = nuevo.applicationContext
    }
}

En MainActivity.kt, los dos botones van dentro de la Column, tras el Text("Marcador en vivo: $marcadorEnVivo") y antes de los botones de los goles. Como RegistroDeContexto vive ahora en laboratorio, MainActivity necesita su import: import com.sebastiannarvaez.marcador.laboratorio.RegistroDeContexto (con Option+Enter, Android Studio lo añade). El resto del fichero no cambia.

app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
                    Button(onClick = { RegistroDeContexto.guardarSinCuidado(this@MainActivity) }) {
                        Text("Guardar Activity (FUGA)")
                    }
                    Button(onClick = { RegistroDeContexto.guardarConCuidado(this@MainActivity) }) {
                        Text("Guardar applicationContext (bien)")
                    }

Reproducir la fuga

Ejecuta la app en el emulador (cómo arrancarlo, en La Activity). Los comandos adb se escriben en la Terminal de Android Studio, que se abre en la carpeta del proyecto (si adb no se reconoce, mira cómo ponerlo en el PATH en Instalar Android Studio y el SDK). También puedes mirar la ventana Logcat del IDE (View → Tool Windows → Logcat), filtrando por tag:LeakCanary.

LeakCanary no avisa en el momento: cuando una Activity se destruye la vigila unos segundos, y si sigue en memoria y la app no está a la vista, hace la foto de la memoria y la analiza. Por eso la prueba no depende de cuánto esperes, sino de lo que veas en Logcat: en vez de contar segundos, dejas Logcat escuchando y esperas a que aparezca la línea que buscas.

  1. Limpia Logcat para ver solo lo nuevo: adb logcat -c.
  2. Toca Guardar Activity (FUGA).
  3. Haz un giro completo: gira el móvil a horizontal y vuelve a vertical (con el botón de girar del panel del emulador, el de la lección El ciclo de vida de la Activity). Se destruyen dos Activity, una por cada medio giro; la que quedó guardada es la primera.
  4. Pulsa Home (el botón de inicio de Android: deja la app en segundo plano sin cerrarla). LeakCanary analiza cuando la app está oculta, por eso este paso es imprescindible.
  5. Deja Logcat escuchando: adb logcat -s LeakCanary. Sin -d esta orden no termina; va imprimiendo las líneas según llegan. Irán saliendo líneas Watching instance of …, luego Found 2 objects retained (hay objetos que no se liberan: empieza el análisis) y Analysis in progress, … % done, y al final HEAP ANALYSIS RESULT, que es el informe. Espera a esa última línea y pulsa Ctrl+C para volver a la terminal.

Los pasos 1 a 5 se repiten tal cual en la comprobación del arreglo.

Antes del informe, Logcat lista lo que LeakCanary vigila. Cada vez que se destruye una MainActivity sale una línea Watching instance of …; las demás son del propio análisis. Verás algo como esto:

D LeakCanary: Watching instance of com.sebastiannarvaez.marcador.MainActivity (com.sebastiannarvaez.marcador.MainActivity received Activity#onDestroy() callback) with key …
D LeakCanary: Found 2 objects retained, not dumping heap yet (app is visible & < 5 threshold)
D LeakCanary: Heap dump reason: 2 retained objects, app is not visible

«Found 2 objects retained» cuenta los dos objetos que seguían en memoria en ese momento: la MainActivity y una pieza interna que Android le añade (ReportFragment). El informe solo lista lo que sigue retenido tras el análisis: 1 fuga, la MainActivity. Y el informe, recortado a lo que importa (el key y los bytes cambian de una ejecución a otra):

HEAP ANALYSIS RESULT
====================================
1 APPLICATION LEAKS
 
References underlined with "~~~" are likely causes.
 
207467 bytes retained by leaking objects
┬───
│ GC Root: Thread object
│
├─ android.os.HandlerThread instance
│    Leaking: NO (PathClassLoader↓ is not leaking)
│    Thread name: 'LeakCanary-Heap-Dump'
│    ↓ Thread.contextClassLoader
├─ dalvik.system.PathClassLoader instance
│    Leaking: NO (RegistroDeContexto↓ is not leaking and A ClassLoader is never leaking)
│    ↓ ClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array
│    Leaking: NO (RegistroDeContexto↓ is not leaking)
│    ↓ Object[695]
├─ com.sebastiannarvaez.marcador.laboratorio.RegistroDeContexto class
│    Leaking: NO (a class is never leaking)
│    ↓ static RegistroDeContexto.contexto
│                                ~~~~~~~~
╰→ com.sebastiannarvaez.marcador.MainActivity instance
     Leaking: YES (ObjectWatcher was watching this because com.sebastiannarvaez.marcador.MainActivity received
     Activity#onDestroy() callback and Activity#mDestroyed is true)
     Retaining 207.5 kB in 4892 objects

Qué leer primero. Salta las líneas del principio y busca dos cosas: la línea marcada con ~~~~, que es el eslabón culpable, y la última del bloque, que es el objeto retenido y dice Leaking: YES. Lo demás es el camino entre ambos.

Cómo se lee. De arriba abajo es un camino desde una raíz (GC Root) hasta el objeto que no debería estar vivo. Los eslabones intermedios (HandlerThread, PathClassLoader) son maquinaria de Android y dicen Leaking: NO. El que importa es la línea con ~~~~: static RegistroDeContexto.contexto, tu propio campo. Termina en MainActivity con Leaking: YES: LeakCanary la vigilaba desde su onDestroy() y sigue en memoria. Retiene unos 207 kB en unos 4.900 objetos (la Activity con toda su jerarquía de vistas; las cifras varían con la máquina y la ejecución).

Comprobar el arreglo

Primero hay que vaciar el singleton, que sigue guardando la Activity del ensayo anterior mientras viva el proceso. am force-stop mata el proceso entero y borra sus objetos (cerrar la app deslizándola puede dejar el proceso vivo). Luego repite los pasos 1 a 5 de arriba, pero tocando en el paso 2 Guardar applicationContext (bien):

adb shell am force-stop com.sebastiannarvaez.marcador

Ahora no verás HEAP ANALYSIS RESULT: pasados unos segundos desde las líneas Watching … los objetos ya se han liberado, LeakCanary no tiene nada que analizar y no hay informe (1 APPLICATION LEAKS no aparece). Como la orden adb logcat -s LeakCanary no termina sola, pulsa Ctrl+C cuando hayan pasado unos 30 segundos desde que pulsaste Home: si hasta entonces no ha salido el informe, no hay fuga.

Punto de control

  • MainActivity.kt no contiene la palabra ejercicios.
  • RegistroDeContexto.kt existe y MainActivity.kt tiene los dos botones entre «Marcador en vivo» y los botones de los goles.
  • Con Guardar Activity (FUGA), un giro completo y Home, adb logcat -s LeakCanary acaba imprimiendo 1 APPLICATION LEAKS y el camino que pasa por static RegistroDeContexto.contexto hasta MainActivity.
  • Con Guardar applicationContext (bien), tras el mismo giro completo y Home, no aparece ningún HEAP ANALYSIS RESULT.
  • En la Terminal de Android Studio, ./gradlew :app:assembleDebug termina en BUILD SUCCESSFUL.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Un object de Kotlin guarda en una propiedad la Activity que recibe. Giras la pantalla. ¿Qué ocurre con la Activity antigua?

  2. 2.En el informe de LeakCanary, ¿qué línea señala el eslabón que causa la fuga?

  3. 3.Dejas la app en segundo plano tras provocar la fuga y el análisis de LeakCanary se queda en 30% done. ¿Qué haces?

  4. 4.Tu app guarda la Activity en un object y el informe de LeakCanary acaba con Leaking: YES en MainActivity. ¿Qué cambio arregla la fuga?

  5. 5.Un object guarda en una lista cada Activity que se crea. Giras la pantalla tres veces. ¿Cuántas de las Activity de la lista son una fuga?

Faltan 5 de 5