Inicio / Artículos

Fugas de memoria en React Native: rastreo de los propietarios del heap JS y de la memoria nativa

Aprenda por qué la recolección de basura no puede salvar una aplicación React Native de las fugas en el código nativo, y cómo descubrir qué mantiene activos los callbacks, los objetos JSI y las imágenes decodificadas.

Editor: REACTAPP.TOP11 min de lectura2614 palabras

Fuentes: The Stack, the Heap, and Garbage Collection: Why Your React Native App Still Runs Out of Memory

Una pantalla de React Native se desmonta, y es razonable esperar que su memoria se libere junto con ella. Sin embargo, después de abrir y cerrar esa pantalla diez veces, la aplicación se vuelve lenta, un teléfono Android con poca memoria la detiene por completo, y Xcode indica que la aplicación superó su límite de memoria. JavaScript cuenta con un recolector de basura, ¿entonces cómo puede ocurrir esto?

La respuesta es que una aplicación de React Native existe en dos mundos de memoria, y el recolector de basura solo regula uno de ellos. Esta guía explica cómo se comportan en realidad la pila, el montón y el recolector de basura, analiza tres patrones de fugas específicos de React Native además de un concepto erróneo común sobre los hooks, y muestra cómo utilizar las herramientas de Hermes, Xcode y Android Studio para identificar qué es lo que sigue ocupando esa memoria que no deja de crecer.

Dos mundos de memoria que se mantienen vivos entre sí

Por un lado está el mundo de JavaScript: objetos, arrays, funciones, estado y propiedades de React, además de las clausuras que capturan sus callbacks. Por el otro lado está el mundo nativo: vistas de la plataforma, píxeles de imágenes decodificados, buffers de cámara y video, identificadores de archivos y datos almacenados por módulos nativos.

Los dos están conectados, y cada uno puede mantener al otro activo. Un heap de JavaScript ordenado no dice nada sobre la memoria total de la aplicación, y el desmontaje de un componente por sí solo no libera ningún recurso nativo al que haya accedido. Tener presente esta separación es la base de cada diagnóstico que se presenta a continuación.

Cómo funcionan la pila, el heap y la recolección de basura

Tres conceptos merecen ser definidos con precisión.

  • La pila de llamadas registra las funciones que se están ejecutando en ese momento. Cada llamada agrega un pequeño marco; al devolverse, este se elimina.
  • El heap es donde JavaScript almacena típicamente objetos, arrays, funciones y cadenas que pueden sobrevivir a una sola llamada.
  • El colector de basura (GC) busca datos en el heap a los que ya no puede acceder ninguna referencia activa, y recupera esa memoria para su reutilización.
  • Un pequeño ejemplo ilustra esta relación de manera concreta:

    function showName(user) {
      return user.name.toUpperCase();
    }
    
    showName({ name: "Ada" });
    

    Mientras se ejecuta toUpperCase(), la pila contiene varios marcos: la llamada a showName y el método de cadena que este invoca. El propio objeto user se encuentra en el heap. Cuando showName devuelve, su marco desaparece, pero el objeto solo puede ser recogido si ya no hay ninguna otra referencia a él. En este fragmento no hay ninguna, por lo que es apto para su recogida; si un caché, un listener o una variable a nivel de módulo hubieran mantenido una referencia, el objeto seguiría allí.

    Eso da como regla que rige todo lo demás: el GC recupera los datos inaccesibles. Que un componente se desmonte o que una variable local salga del ámbito no libera nada por sí solos; solo eliminan una ruta posible hacia los datos.

    También es importante el momento en que se realiza la recolección de basura. El GC no ofrece garantías sobre cuándo se ejecutará. Hermes utiliza un recolector concurrente llamado Hades, diseñado para mantener las pausas cortas y así que la interfaz de usuario siga funcionando sin problemas (véase las versiones de Hermes). Una consecuencia de esto es que el hecho de que la memoria no disminuya inmediatamente después de una navegación no demuestra la existencia de fugas de memoria. Como explican los ingenieros de Hermes en una discusión sobre el comportamiento del GC, los objetos inalcanzables pueden permanecer hasta que el recolector considere que la presión de memoria justifica realizar una recolección. Una fuga de memoria se produce cuando la cantidad de memoria sigue aumentando en ejecuciones repetidas del mismo flujo, no cuando su disminución es lenta.

    Dónde permanece viva la memoria en una aplicación React Native

    Hoy en día, la mayoría de las aplicaciones React Native funcionan con Hermes, aunque el motor exacto depende de la aplicación y su configuración. El motor afecta los valores del heap y las pausas de recolección de basura, pero no la primera pregunta de depuración: ¿es el aumento en la memoria JavaScript o en la memoria nativa?

    El diagrama a continuación muestra las relaciones típicas entre ambos lados:

    JavaScript runtime                 Native runtime
    ------------------                 --------------------
    state and props       ─────►       views and view layers
    arrays and objects    ─────►       decoded image pixels
    hooks and callbacks   ─────►       camera/video buffers
    JSI values            ◄─────►      TurboModules / C++ data
    

    El estado y las props controlan las vistas nativas; los arrays y objetos hacen referencia a imágenes decodificadas; los hooks y callbacks almacenan buffers de cámara o video; y los valores JSI interactúan en ambas direcciones con TurboModules y datos en C++. Considere este límite como una línea de propiedad. Cada vez que un recurso lo cruza, debe poder responder a tres preguntas:

    1. ¿Quién es el propietario de este recurso en este momento?
    2. ¿Qué lo mantiene activo?
    3. ¿Qué llamada específica lo libera?

    Si la respuesta a la tercera pregunta es “el recolector de basura se encargará de ello”, considérelo como una señal de advertencia. En el caso de recursos nativos, con frecuencia no es seguro.

    Fuga 1: un módulo nativo que almacena el callback de JavaScript

    Este patrón no tiene un equivalente real en el JavaScript de los navegadores. Un módulo nativo puede almacenar un callback que recibió de JavaScript, y ese callback mantiene viva toda su caja de cierre: estado, propiedades, un objeto grande en borrador, e incluso el modelo de una pantalla que ya no existe.

    Aquí un efecto se suscribe al progreso de la carga y devuelve una función de limpieza que elimina esa suscripción:

    useEffect(() => {
      const sub = UploadManager.onProgress((done) => {
        reportProgress(draft.id, done);
      });
      return () => sub.remove();
    }, [draft.id]);
    

    Esto solo es seguro si remove() realmente indica al lado nativo que libere el callback. Si falta la función de limpieza, o si la biblioteca tiene un error, se forma una cadena de retención como esta:

    native UploadManager → JS callback → closure → draft → image list
    

    El UploadManager nativo almacena la función de callback, esta a su vez contiene su cierre, el cierre guarda el elemento draft, y este último contiene una lista completa de imágenes. Desmontar la pantalla no interrumpe en absoluto esa cadena. Solo la limpieza de las suscripciones lo hace, por lo que es importante verificar que las suscripciones de terceros realmente se desconecten en el lado nativo, y no solo en JavaScript.

    Fuga 2: un pequeño objeto JSI que oculta un recurso nativo de gran tamaño

    JSI permite que JavaScript interactúe con objetos nativos de manera mucho más directa que el antiguo puente, lo cual es excelente para el rendimiento y hace que la propiedad sea mucho más importante. Imagine una biblioteca que abre un decodificador de video nativo, un cursor de base de datos o una sesión de cámara y le entrega a JavaScript un objeto ligero que lo representa. El objeto en JavaScript puede ser muy pequeño, y el recolector de basura no tiene motivos para apresurarse a recogerlo, mientras que el recurso nativo detrás de él consume una gran cantidad de memoria.

    El ejemplo a continuación abre una sesión de cámara, la inicia y, en la fase de limpieza, tanto la detiene como la cierra:

    useEffect(() => {
      const session = CameraSession.open();
      session.start();
      return () => {
        session.stop();
        session.close();
      };
    }, []);
    

    La llamada a close() es la que realmente importa. Las sesiones de hardware, los decodificadores, los archivos abiertos, los cursores y las asignaciones de GPU deben liberarse intencionadamente, nunca dejados al azar para que los detecte el recolector. No se puede programar una recopilación, por lo que no puede servir como plan de liberación, y los finalizadores pueden ejecutarse tarde o incluso no ejecutarse en absoluto.

    Si mantienes una biblioteca JSI o nativa, proporciona a cada objeto que posee recursos un método de liberación explícito y haz que sea idempotente, de modo que llamarlo dos veces no cause efectos negativos. En el código de la aplicación, llama a este método desde el punto de limpieza correspondiente. El equipo de Hermes da el mismo consejo en la discusión sobre GC: expone un método al estilo close() para los recursos nativos en lugar de depender de la finalización.

    Fuga 3: el componente de imagen ha desaparecido, pero los píxeles no

    Este patrón está detrás de una gran parte de los fallos por falta de memoria. Una foto que es modesta en el disco se vuelve enorme una vez decodificada, ya que los formatos comprimidos como JPEG deben expandirse en píxeles raw para su visualización. Una estimación aproximada del tamaño después de la decodificación es:

    width × height × 4 bytes
    

    Con cuatro bytes por píxel, una foto de 4,000 por 3,000 necesita alrededor de 48 MB, y eso sin contar las copias adicionales para escalado, caché o carga en la GPU. Ahora considere una miniatura que muestra dicha foto:

    <Image
      source={{ uri: fullSizePhotoUrl }}
      style={{ width: 160, height: 160 }}
    />
    

    La vista tiene solo 160 por 160 puntos, pero el dispositivo aún puede descargar y decodificar el archivo en resolución completa que está detrás de ella. En un feed con muchas imágenes grandes, la memoria nativa puede aumentar constantemente mientras que el heap de JavaScript permanece estable, lo cual es exactamente la situación que confunde a los desarrolladores que solo observan la memoria de JS.

    No toda aumento de uso de memoria se debe a una fuga. Un caché de imágenes que almacena las fotos vistas recientemente está cumpliendo su función. Se convierte en un problema cuando el uso de memoria sigue aumentando con cada paso por el mismo flujo, sin llegar a estabilizarse, lo que finalmente provoca la interrupción del proceso.

    Comience corrigiendo lo que se introduce en la cadena de procesamiento de imágenes:

    • Solicite imágenes con un tamaño cercano al que se mostrarán.
    • Use miniaturas en las listas y cargue la resolución completa solo en la pantalla de detalles.
    • Límite la cantidad de vistas de imágenes que se pueden mostrar al mismo tiempo, por ejemplo mediante virtualización de listas.
    • Conozca las reglas de caché de su biblioteca de imágenes y configure sus límites.
    • Pruebe en un dispositivo real con poca memoria, no solo en un simulador.

    Culpar a Hermes por un mapa de bits de 48 MB no es pertinente. El motor de JavaScript no puede recuperar píxeles nativos que aún están en poder de una vista de imagen, la cadena de procesamiento de imágenes, un caché o una textura de GPU.

    Cierres obsoletos en hooks: identifica qué error tienes

    Se afirma con frecuencia que un array de dependencias incorrecto provoca fugas de memoria. Puede ser así, pero con mayor frecuencia primero genera un error por valor incorrecto, y esta distinción afecta la forma en que se soluciona.

    Un cierre conserva los valores a los que hace referencia. Si un listener nativo o un temporizador mantiene ese cierre más tiempo del previsto, el cierre conserva los datos antiguos con él. En este caso, el array de dependencias está vacío, por lo que la función de callback de progreso se crea una sola vez:

    useEffect(() => {
      const sub = player.onProgress(() => {
        savePosition(episode.id);
      });
      return () => sub.remove();
    }, []); // episode may change, but this callback will not
    

    La función de callback captura episode desde la primera renderización. Cuando el usuario cambia de episodio, el progreso sigue guardándose con el ID original. Eso es un cierre obsoleto. Si la suscripción se elimina al desmontar, se trata de un error de corrección en lugar de una fuga persistente. Si el reproductor nativo mantiene la función de callback después del desmontaje, se presentan ambos problemas.

    La solución no consiste en incluir todos los objetos en el array de dependencias. Se trata de que el efecto describa con exactitud el recurso del que es dueño, utilizando como clave el valor que lo define:

    useEffect(() => {
      const sub = player.onProgress(() => {
        savePosition(episode.id);
      });
      return () => sub.remove();
    }, [episode.id]);
    

    Ahora, cada vez que episode.id cambia, React ejecuta la función de limpieza para eliminar la suscripción antigua antes de crear una nueva, y la función de callback captura únicamente el valor esencial que necesita. Depender de un ID primitivo en lugar del objeto completo episode también evita volver a suscribirse en cada renderizado cuando cambia la identidad del objeto pero no el episodio.

    Ejecutar una verdadera investigación de memoria

    Observaciones vagas como “la aplicación utiliza mucha memoria” no son útiles para tomar medidas concretas. En su lugar, defina un procedimiento reproducible: abra el feed, desplácese por él, abra diez fotos, regrese y repita toda la secuencia cinco veces.

    Luego observe qué patrón muestran las mediciones:

    • El montón de JS crece después de cada bucle: es muy probable que la fuga se encuentre en el lado de JavaScript, así que comience con capturas del montón.
    • El montón de JS se mantiene más o menos estable mientras la memoria total de la aplicación aumenta: examine la memoria nativa con las herramientas de la plataforma.

    Diagnóstico del crecimiento del montón de JavaScript

    Utilice el inspector de Hermes y tome capturas del montón donde su versión de React Native y la configuración de compilación lo permitan. Seleccione un objeto sospechoso en la captura y siga su ruta de retención hasta llegar a un punto raíz. Los culpables típicos aparecen con nombres como listeners, timers, stores, caches, callbacks o singulares a nivel de módulo.

    Tenga cuidado de no confundirlo con el perfilador de muestreo Hermes, que es principalmente una herramienta para la CPU. Muestra qué JavaScript estaba en uso, pero no por qué aumenta la memoria de imágenes nativas. La guía de React Native para el perfilado con Hermes explica cómo registrar y abrir un perfil de la CPU.

    Diagnóstico de la memoria nativa en iOS

    Ejecuta la aplicación desde Xcode en un dispositivo físico. Comienza con el indicador de memoria para observar una tendencia rápida, luego abre el Gráfico de Memoria de Depuración para ver los objetos y las referencias entre ellos. Instruments, en particular la plantilla de Asignaciones, te permite seguir las asignaciones a medida que repites el proceso. La documentación de memoria de Xcode de Apple describe cómo el gráfico de memoria muestra los objetos, las asignaciones y sus vínculos, así como cómo Asignaciones hace un seguimiento de la memoria heap y virtual.

    Cuando Instruments muestra un crecimiento constante durante un flujo con muchas fotos pero la memoria heap de Hermes apenas se mueve, deja de buscar una cierre de JavaScript. El problema está en el lado nativo.

    Diagnóstico de la memoria nativa en Android

    Utilice el Analizador de memoria de Android Studio en un dispositivo real para observar cómo evoluciona la memoria de la aplicación con el tiempo. Su análisis nativo de memoria, incluido desde Android Studio 4.1, registra las asignaciones y liberaciones de memoria nativa y resume los objetos nativos.

    Las pantallas del analizador varían entre las versiones de Android Studio, pero el método permanece igual: registra el proceso repetible, marca el inicio de cada bucle y busca memorias que aumentan sin volver a un nivel estable.

    Una lista de verificación para la limpieza en cada pantalla

    Cualquiera que sea el proceso iniciado por una pantalla, su limpieza debe realizarse al final. Una auditoría rápida incluye:

    • Se elimina el temporizador que se inició.
    • Se retira el oyente añadido.
    • Se cancela la suscripción nativa.
    • Se detiene y cierra la cámara, reproductor, archivo o cursor de base de datos.
    • El caché que se cree cuenta con un límite de tamaño o una política de eliminación.
  • Las imágenes grandes generan archivos de origen más pequeños y tienen un límite en las vistas montadas.
  • Esto no es una gestión manual de la memoria en el sentido de C. No se liberan bytes arbitrarios; se finaliza el trabajo y se devuelven los recursos en el momento en que la aplicación deja de ser su propietaria.

    Conclusión

    La recolección de basura nunca prometió que todo lo que tocara un componente desaparecería al ser desmontado. Cuando la memoria aumenta, haga dos preguntas en este orden: ¿el crecimiento ocurre en el heap de JavaScript o en el proceso nativo?, y ¿quién sigue siendo el propietario de lo que está creciendo?.

    La respuesta apunta directamente a la solución. Una llamada de retorno pendiente indica una suscripción que nunca se eliminó. Una sesión nativa significa que falta un close(). Un mapa de bits de 48 MB implica que necesitas una imagen más pequeña o un caché más eficiente. Los errores de memoria en React Native dejan de ser misteriosos una vez que dejas de esperar al recolector de basura y comienzas a encontrar al responsable para luego poner fin a su control sobre la memoria.

    En todo momento, haz pruebas en dispositivos reales. Un simulador es adecuado para reproducir un flujo, pero puede ocultar la presión en la memoria que hace que tu aplicación se cierre en el teléfono de un usuario.

    Lecturas relacionadas

  • Búsqueda de fugas de memoria en JavaScript: Alcance, retenedores y limpieza — Aprenda por qué el JavaScript con recolección de basura sigue presentando fugas de memoria, qué patrones cotidianos retienen memoria y cómo identificar al culpable mediante capturas de heap y cadenas de retenedores.