Herramientas y rendimiento

Solución de problemas de memoria con Firebase Crashlytics

Bocinas

  • Polina Govorkova

    Desarrollador de software sénior

Acerca de

Acerca de la charla

Mantén el espacio en memoria de tu app reducido y sin errores de segmentación con Firebase Crashlytics. En esta sesión, exploraremos nuestro conjunto integral de herramientas para solucionar problemas de memoria disponibles en Firebase console y Android Studio. Comenzaremos explicando cómo aprovechar las estadísticas de ANR y los informes de GWP-ASan para diagnosticar errores de memoria difíciles de detectar y, luego, profundizaremos en nuestras herramientas más recientes para eventos de memoria insuficiente (OOM) y Memory Limiter Kill (MLK), diseñadas específicamente para ayudarte a navegar y adaptarte a los nuevos límites de memoria que se introdujeron en Android 17.

Resumen

  • Los errores de Aplicación no responde suelen deberse a cuellos de botella en segundo plano o al agotamiento de la memoria, más que a la carga activa de la CPU. Firebase Crashlytics consulta ApplicationExitInfo para mostrar seguimientos de varios subprocesos y, así, identificar los subprocesos de bloqueo raíz.
  • Las fallas por falta de memoria ocurren cuando las apps superan los límites de montón de Java en el entorno de ejecución. Crashlytics se integra en ProfilingManager de Android 17 para recopilar instantáneas del montón desofuscadas que se pueden inspeccionar en Perfetto o a través de la CLI de Android.
  • El limitador de memoria de Android 17 finaliza los procesos individuales que consumen demasiada RAM total, incluidas las asignaciones nativas. Crashlytics agrupa estas finalizaciones en el panel junto con los volcados de montón para ayudar a separar las fugas de memoria de C++ de las asignaciones de montón administradas.
  • La corrupción de la memoria nativa suele aparecer en los subprocesos del sistema, en lugar de en el código de la app. Crashlytics admite GWP-ASan y Arm MTE, y los informes de GWP-ASan identifican los sitios exactos de asignación y desasignación de errores, como el uso después de liberar.

Recursos

Gemini Notebook

Temas

  • firebase-crashlytics
  • android-memory
  • anr
  • memoria insuficiente
  • profiling-manager
  • perfetto
  • gwp-asan
  • arm-mte

Transcripción

Descargar transcripción

Introducción

  • Polina Govorkova

    Hola a todos.

  • Te damos la bienvenida a Solución de problemas de memoria con Firebase Crashlytics.

  • Un momento, qué título corporativo.

  • Cambiemos un poco las cosas.

  • Mantén las apps eficientes y eficaces con Firebase Crashlytics.

  • Eso suena mejor.

  • Soy Polina, desarrolladora de software sénior del equipo de Firebase Crashlytics y tu presentadora de hoy.

  • En algún momento de tu ciclo de desarrollo, sentirás que resolviste todos los errores.

  • Esos errores tipográficos de análisis de JSON, las verificaciones de punteros nulos olvidadas, ¡todo desapareció!

  • Y tal vez te preguntes: "¿Ya terminé?".

  • Bueno, tengo malas noticias para ti, porque no lo eres.

  • Porque lo que te quedará son, en realidad, los problemas difíciles.

  • Y hoy afirmo que las dos categorías de los problemas más difíciles que enfrentarás serán la capacidad de respuesta y la memoria.

  • La buena noticia es que te respaldamos.

  • Hoy, analizaremos las herramientas existentes y nuevas de Android y Crashlytics para ayudarte a encontrar la causa raíz de los problemas de capacidad de respuesta que se manifiestan a través de errores de aplicación no responde, excepciones de memoria insuficiente, fugas de memoria y corrupción de la memoria nativa.

  • Metámonos de lleno en esto.

  • ¿Por qué son tan notorios los problemas de capacidad de respuesta y memoria?

  • Tomemos un error clásico: declaras una cadena, la estableces como nula y, en algún momento, intentas leer su longitud.

  • ¿Qué obtienes?

  • Una buena y vieja NullPointerException.

  • Sin embargo, en realidad, la vida es fácil aquí, porque el seguimiento de pila de excepciones apunta a la línea exacta de código que dañó tu app.

  • Observa el seguimiento de pila de la izquierda.

    java.lang.NullPointerException
      at MainActivity.onCreate(MainActivity.kt:42)
      at android.app.Activity.performCreate
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • Podemos ver de inmediato que el problema está en la línea 42 del archivo MainActivity.kt.

  • Este enfoque es válido para la mayoría de los errores triviales.

  • Ahora, cuando se produce un problema de capacidad de respuesta o de memoria, como este error de Aplicación no responde a la derecha, el seguimiento de pila casi nunca apunta al verdadero culpable.

    "main" prio=5 tid=1 Blocked
      | group="main" sCount=1 dsCount=0 flags=1
      | state=S schedstat=( 1450239 48192 145 )
      at DataManager.getData(DataManager.kt:25)
      - waiting to lock <0x0a1b> held by thread 12
      at MainActivity.onResume(MainActivity.kt:50)
      at android.app.Activity.performResume
      at android.os.Handler.dispatchMessage
      at android.os.Looper.loopOnce
      at android.app.ActivityThread.main
  • En el mejor de los casos, solo te muestra el efecto, que es un subproceso o una asignación inocente que se estaba ejecutando en el momento en que la app se congeló o se quedó sin memoria insuficiente.

  • Todo esto mientras la causa raíz real ocurrió mucho antes o, tal vez, en otro lugar por completo.

  • Por suerte, cuando se trata de estos problemas, no tienes que adivinar a ciegas, porque Firebase Crashlytics tiene la respuesta para nosotros.

  • Pero antes de ponernos manos a la obra con la solución de problemas, levanten la mano si conocen Crashlytics.

  • ¡Excelente!

  • Me encanta ver todas esas manos.

  • Para quienes no lo conocen, Firebase Crashlytics es una solución de informes de errores en tiempo real que proporciona datos de campo, junto con estadísticas contextuales y basadas en IA, sugerencias para solucionar problemas y señales claras sobre tus nuevas versiones, todo para ayudarte a corregir problemas más rápido.

  • Crashlytics es muy popular entre nuestros desarrolladores, ya que es la solución de informes de fallas más adoptada en dispositivos móviles y potencia más de 370,000 apps nativas, incluido el 57% de las apps para Android más populares.

  • Para ver cómo Crashlytics nos ayuda a detectar errores en acción, veamos un ejemplo concreto.

  • Hoy, quiero contarles la historia de un ChromaClip.

  • Es un extractor de paletas de colores de videos que estuve creando, y quiero contarte cómo abordé los errores de responsividad y memoria mientras lo desarrollaba.

  • Por lo tanto, la idea detrás de ChromaClip es simple: los usuarios eligen un video, tal vez un hermoso clip de un atardecer o una escena con una excelente gradación de color, y la app analiza los fotogramas para generar una paleta de colores personalizada, que luego los usuarios pueden usar para sus diseños, arte digital, paneles de ideas, lo que sea.

  • Nuestra historia comienza justo antes de mi lanzamiento de la versión 1.0.

  • Quería asegurarme de que fuera sólida antes del lanzamiento, así que estaba ejecutando las pruebas finales de forma local en un par de dispositivos, seleccionando el video, presionando el botón para extraer la paleta de colores y, por suerte, el diálogo favorito de todos los desarrolladores de Android, ¿no?

  • ChromaClip no responde.

  • Este es un error de Aplicación no responde, también conocido como ANR.

Cómo depurar y analizar errores de ANR

  • Polina Govorkova

    Entonces, ¿qué hacemos en esta situación, además de entrar en pánico?

  • Siguiendo nuestro primer instinto, como el teléfono de prueba está conectado directamente a la laptop, abrimos Android Studio y revisamos Logcat.

  • Bueno, vemos el seguimiento de pila del subproceso principal.

    14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
        PID: 14205
        Reason: Input dispatching timed out
        Dumped ANR trace to /data/anr/traces.txt
    "main" prio=5 tid=1 Native
      | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
      | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
      | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
      | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
      at sun.misc.Unsafe.park(Native method)
      - waiting on an unknown object
      at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
      at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
      at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
      at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
      at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
      at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
  • ¿Qué nos dice, en realidad?

  • Primero, en la parte superior, obtenemos la confirmación de que se trata de un error de ANR que se ejecuta en MainActivity.

  • ¿Qué más?

  • Vemos el motivo, que es el tiempo de espera agotado para el envío de la entrada.

  • Esto significa que el usuario presionó la pantalla.

  • Sí, lo hicimos.

  • Sin embargo, el subproceso principal estaba tan ocupado que no pudo procesar ese evento dentro del tiempo de espera de cinco segundos del guardián de Android.

  • Luego, también vemos que el sistema volcó el archivo en traces.txt y, justo debajo, logcat imprime el seguimiento de pila para nuestro subproceso principal.

  • Observa que el estado es igual a S.

  • Nuestro subproceso principal ni siquiera está consumiendo CPU de forma activa.

  • La S significa que está durmiendo.

  • ¿Por qué nuestro subproceso principal está inactivo mientras esperamos?

  • Analicemos con más detalle los marcos de pila.

  • Justo aquí, en el medio del registro, vemos que nuestro subproceso principal está estacionado dentro de Unsafe.park esperando un objeto desconocido.

  • ¿Por qué no sabemos qué está esperando?

  • Bueno, en realidad es cierto que, para algunos errores de ANR, como cuando un subproceso está bloqueado por un bloqueo sincronizado de Java clásico, el seguimiento de pila del subproceso principal es lo suficientemente amable como para decirnos qué está esperando, qué está reteniendo el bloqueo.

  • Sin embargo, cuando usas primitivas de simultaneidad de nivel superior de forma interna, todo lo que nos dice logcat es que estamos esperando un objeto desconocido.

  • Por lo tanto, no tenemos idea de qué objeto estamos esperando, qué subproceso de trabajador en segundo plano estamos esperando específicamente o por qué tarda más de 5 segundos.

  • Y, como estamos haciendo pruebas de forma local, podríamos extraer ese traces.txt del dispositivo y desplazarnos manualmente por decenas de subprocesos inactivos, y posiblemente encontrar al culpable o, tal vez, gastar los tokens preguntándole a la IA.

  • En algunos casos, funcionaría y, de hecho, funcionaría en este caso.

  • Pero no solo no quiero leer volcados de subprocesos sin procesar a mano (honestamente, eso es muy de 2019), sino que sé que este enfoque ni siquiera es viable una vez que estemos en producción.

  • No tenemos acceso directo al archivo traces.txt en los teléfonos de los usuarios, ¿verdad?

  • Por lo tanto, es bueno que hayamos integrado Firebase Crashlytics.

  • Porque elimina la necesidad de buscar y llega al fondo del error de ANR, incluso en producción.

  • ¿Cómo?

  • Aquí es donde entra en juego la API de ApplicationExitInfo de Android.

  • A partir de Android 11, cada vez que el sistema marca un error de ANR o finaliza el proceso de tu app, el SO registra metadatos de salida enriquecidos, incluido el motivo exacto de la falla, el estado de la memoria y un registro del sistema completo para cada subproceso que se ejecuta en tu proceso en ese momento.

  • Y lo mejor de todo es que no necesitas instalar ningún complemento adicional ni escribir una sola línea de código.

  • Siempre que uses el SDK de Crashlytics 18.3.5 o una versión posterior, funcionará de inmediato.

  • Por lo tanto, en cuanto se reinicie tu app, el SDK de Crashlytics consultará automáticamente ApplicationExitInfo, capturará el volcado completo de varios subprocesos junto con la telemetría del dispositivo y lo enviará al backend de Crashlytics.

  • Abramos la consola de Crashlytics y veamos qué nos dice.

  • Esta es la vista de detalles del problema de la consola de Firebase Crashlytics para el error de ANR, y vemos de inmediato en la pestaña de seguimiento de pila el trabajador DefaultDispatcher.

    DefaultDispatcher-worker-1 (Root blocking)
    
    dalvik.system.VMRuntime.allocateNode (Native method)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
    android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
    com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
    com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
    kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
    kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
    java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
    java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
    java.lang.Thread.run (Thread.java:923)
  • De hecho, este es el subproceso que Crashlytics mostró en la parte superior y que es el cuello de botella de nuestro subproceso principal. Crashlytics lo marca con una insignia llamada Bloqueo de raíz.

  • Como se explica en la información sobre la insignia, este subproceso de trabajo es el que bloquea el subproceso que activó el error de ANR.

  • Por cierto, Crashlytics también tiene insignias inteligentes para otros culpables.

  • Por ejemplo, si el subproceso se detuvo mientras leía un archivo gigante o realizaba una consulta de base de datos muy lenta, y si activó un ANR, el subproceso se etiquetaría como bloqueo de raíz de E/S.

  • Y si dos subprocesos se quedaran bloqueados esperando el uno al otro, Crashlytics los etiquetaría como interbloqueados.

  • Ahora, veamos los marcos de pila en sí dentro del subproceso de bloqueo raíz.

  • Entonces, tenemos nuestro PaletteExtractor processVideo que llama a FrameDecoder getUncompressedFrame destacado en azul, y eso está completamente bloqueado dentro de createBitmap, que solo intenta asignar memoria para un solo fotograma.

  • Como sabemos que no está esperando otro bloqueo y que no está en una llamada de red, nuestro principal sospechoso tiene que ser la memoria, y podemos confirmarlo de inmediato si revisamos la memoria libre del dispositivo aquí mismo en la consola de Crashlytics.

  • Observa los dos lugares en los que Crashlytics muestra esta información.

  • En la parte inferior de la pestaña de datos del evento específico, justo al lado de la pestaña de seguimiento de pila que acabamos de explorar, y también en la parte superior derecha, donde se muestra el promedio de todos los eventos.

  • En ambos casos, parece que casi no hay nada, ni siquiera 100 KB.

  • Así que pensemos.

  • Lo que sucedió aquí es que, debido a que el proceso se quedó sin memoria, el Android Runtime pasó todo el tiempo ejecutando frenéticamente la recolección de elementos no utilizados, tratando de encontrar suficiente espacio para el siguiente mapa de bits y sin lograrlo.

  • Se congela el subproceso de trabajo y se bloquea la IU durante más de cinco segundos.

  • Por lo tanto, aquí tienes un consejo rápido.

  • Antes de que obtengas, cada vez que obtengas un error de ANR vinculado con este tipo de RAM libre baja, es tu prueba irrefutable de que el error de ANR es, en realidad, una fuga de memoria o un problema de exceso de datos.

  • Ahora, la pregunta es si nuestra app es la víctima o si también es la que está acaparando la memoria.

  • Así que volvamos a la lista de problemas de nuestro proyecto y veamos si hay algo más que parezca relevante.

Investiga las excepciones de memoria insuficiente

  • Polina Govorkova

    Y mira justo ahí.

  • En la parte inferior, vemos la lista de problemas y, justo encima de nuestro error de ANR con los ocho eventos, tenemos una excepción OutOfMemory.

  • Por lo tanto, es posible que nosotros seamos los culpables después de todo.

  • Y, de hecho, esa es una gran pista.

  • Porque, en Android, un OutOfMemoryError de Java, o OOM, no solo significa que el teléfono tenía poca RAM.

  • Significa que el proceso de nuestra app superó el límite de montón de Java de Android Runtime.

  • Por lo tanto, en este punto, mi teoría de trabajo es que nuestros ANR y OOM son, en realidad, las dos caras de la misma moneda.

  • Según el momento de la ejecución de la prueba, ChromaClip se quedaba atascado en la recolección de basura el tiempo suficiente para activar un error de ANR o agotaba por completo el montón, lo que generaba un error de OOM.

  • Investiguemos la excepción OutOfMemory para saberlo con certeza.

  • Comencemos con el seguimiento de pila.

    java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
     at android.graphics.Bitmap.createBitmap
     at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
     at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
  • Espera un segundo.

  • Bitmap.createBitmap, eso me suena familiar.

  • ¿No habíamos visto ese antes?

  • Bueno, excepto que mires desde dónde se llama esta vez.

  • No se pudieron asignar 256 bytes cuando se abrió la actividad de configuración.

  • Estamos cayendo en la misma trampa otra vez.

  • Si tu app falla porque no puede aumentar un ícono de configuración pequeño, la actividad de configuración no será la culpable.

  • Es solo el llamador desafortunado que pidió una gota de memoria cuando no había ninguna.

  • Por lo tanto, para encontrar al verdadero culpable, debemos alejar la imagen y ver todo lo que estaba en la memoria cuando se produjo la falla.

  • En otras palabras, necesitamos un volcado de montón de Java.

  • En tu escritorio, si sabes cómo activar un OOM, puedes obtener manualmente un volcado de montón de Java con el Generador de perfiles de memoria de Android Studio.

  • Sin embargo, cuando se produce un OOM de forma impredecible, durante las ejecuciones de prueba o en el entorno de producción, este método no funcionará.

Captura y analiza volcados de montón

  • Polina Govorkova

    Este es otro momento brillante para Firebase Crashlytics.

  • Además de la información de salida de la aplicación, Crashlytics ahora se integra con la API de ProfilingManager de Android.

  • La API de ProfilingManager en Android 17 se potencia con la generación de perfiles de memoria activada por el sistema, y eso es exactamente lo que necesitamos aquí.

  • Esto significa que, justo cuando tu app se encuentre con un error OutOfMemory o incumpla los límites de memoria del sistema, el SO capturará una instantánea del montón en segundo plano y, luego, en Crashlytics, la recopilación activada se realizará de forma gratuita y lista para usar, siempre y cuando uses el SDK de Crashlytics 20.1.0 o una versión posterior.

  • Luego, Crashlytics agrupará automáticamente las finalizaciones por OOM y Memory Limiter, de las que hablaremos más adelante, en el panel.

  • Y cuando habilites la recopilación de volcados de montón a través de Firebase console o la CLI, Crashlytics comenzará a recopilar automáticamente esas instantáneas del montón capturadas y las desofuscará por ti. Básicamente, obtendrás una radiografía completa de todo lo que se encuentra en el montón de Java de tu app en el momento de la falla.

  • Así es como se ve el OOM de ChromaClip en la consola de Crashlytics una vez que se captura el montón.

  • En la parte superior, verás una explicación de por qué Java agotó el montón y por qué sucedió.

  • Luego, también verás sugerencias útiles para solucionar problemas y punteros a los recursos externos.

  • En la pestaña de seguimiento de pila, justo encima de esa actividad de configuración engañosa, Crashlytics indica que los seguimientos de pila de OOM no son deterministas, para que las personas no tengan que perder tiempo mirando el seguimiento de pila como acabamos de hacer.

  • Luego, Crashlytics adjunta el archivo de Perfetto desofuscado, el volcado de montón, para la sesión.

  • Y, desde aquí, podemos descargar el archivo.

  • Podemos explorar todos los volcados en Google Cloud Storage o abrirlos en la IU de Perfetto.

  • Por lo tanto, si haces clic en Analyze en la IU de Perfetto, se abrirá el explorador de volcados de montón de Perfetto.

  • Ahora, levanten la mano si ya vieron este.

  • Muy bien, veo un par de manos levantadas.

  • Para mí, esta vista es muy intimidante, pero, en este caso, el problema es bastante simple.

  • Observa el conjunto de barras enormes que se extienden casi por todo el montón de Java.

  • Por lo tanto, en su mayor parte, es un array de objetos de par, cada uno de los cuales une un color y alguna instancia de cadena.

  • ¡Oh!

  • Creo que sé lo que está pasando.

  • Nuestro código asigna un color y algunos datos de respaldo para cada píxel de cada fotograma, y conserva millones de ellos en una lista.

  • Por lo tanto, no es de extrañar que la actividad de Configuración no haya podido asignar esos 256 bytes.

  • Bueno, al menos se resolvió un misterio.

  • Vamos a corregirlo.

  • En este punto, tenemos un par de opciones.

  • Como Perfetto ya nos indicó PaletteExtractor, podríamos abrir nuestro IDE y reescribir el bucle de forma manual o usar el agente de programación de IA.

  • O bien, podemos omitir la inspección manual del montón por completo y aprovechar la CLI de Android.

    # 1. Install Android CLI & Perfetto skills
    $ android skills add android-cli perfetto
    
    # 2. List recent heap dumps from Crashlytics
    $ android signals heaps list --kind oom --timeframe=90d
    
    # 3. Download a heap dump as a Perfetto trace
    $ android signals heaps download <heap-dump-id> \
        --output ./heaps
    
    # 4. Query top retained objects in the heap dump
    $ trace_processor query ./heaps/oom.perfetto-trace ...
    
    # 5. Fetch matching Crashlytics events
    $ android signals heaps describe <heap-dump-id>
  • Por lo tanto, instalar las habilidades de Android CLI y Perfetto te permitirá enumerar y descargar volcados de montón de Crashlytics desofuscados, consultar los objetos retenidos principales de estos volcados de montón a través de la trace_processor y recuperar datos de problemas de Crashlytics coincidentes, como claves personalizadas, registros, rutas de navegación, lanzamientos asociados y mucho más, todo desde tu terminal.

  • Mejor aún, mira la parte inferior de las diapositivas.

  • No ejecutes estos comandos por tu cuenta.

  • Una vez más, pídele a tu agente de programación de IA favorito en lenguaje natural que inspeccione el volcado de montón de OOM más reciente con el comando de Android y que envíe la corrección.

    # Or ask your favorite LLM in natural language
    > Using `android` command, inspect the latest heap dump
      for my OOM issue and propose a fix
    ● Ran android signals heaps list · download · describe
    ● Analyzed ./heaps/oom.perfetto-trace (perfetto)
    ● Updated PaletteExtractor.kt (+6 -4 lines)
  • Y tendrá suficiente contexto para realizar todo el flujo por ti.

  • Es decir, enumerar los OOM, descargar el seguimiento, ejecutar el análisis y generar el parche de código directamente en tu proyecto.

  • Y mira este parche en el archivo PaletteExtractor.

    // PaletteExtractor.kt
    fun extractPalette(framePixels: IntArray): SparseIntArray {
    -   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
    -   for (px in framePixels) {
    -       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
    -   }
    
    +   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
    +   for (i in framePixels.indices step 16) {
    +       val argb = framePixels[i]
    +       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
    +   }
    +   return colorHistogram
    }
  • En lugar de millones de asignaciones de objetos, ahora solo reducimos el muestreo del fotograma y contamos los números enteros sin procesar directamente.

  • Por lo tanto, apliquemos la corrección, cerremos los problemas, actualicemos la versión y sigamos probando.

  • Con un poco de suerte, no solo debería desaparecer el error de memoria insuficiente, sino que, como ya no tendremos problemas con la recolección de elementos no utilizados, es de esperar que también desaparezca ese molesto error de ANR.

  • Por lo tanto, implementamos la compilación de parches en nuestros dispositivos de prueba, elegimos un clip nuevo y comenzamos a extraer paletas.

  • Y la IU se vuelve a inmovilizar.

  • Otro error de ANR.

  • Curiosamente, al realizar más pruebas, descubrimos que, en algunos dispositivos, aún vemos ese error de ANR, pero, en otros, la app ahora falla de forma aleatoria y con bastante frecuencia, incluso más que con esos errores de OOM.

  • Pero corregimos el OOM, entonces, ¿qué está pasando?

Investiga problemas de memoria nativa

  • Polina Govorkova

    Bueno, volvamos a Crashlytics.

  • Primero, las buenas noticias.

  • Consulta nuestra lista de problemas.

  • La excepción de memoria insuficiente desapareció por completo, como esperábamos.

  • ¿La mala noticia?

  • Ahora que el ChromaClip sobrevive un poco más, tiene tiempo para descubrir qué más se ocultaba debajo.

  • Por lo tanto, nuestro error de ANR no desapareció.

  • Y ahora tenemos dos fallas por error en código nativo en la parte inferior, afortunadamente con solo un par de eventos.

  • Y justo en la parte superior, con 11 eventos, tenemos la finalización del limitador de memoria, que, por suerte, ya tiene la insignia de tener el volcado de montón.

  • Espera.

  • ¿Qué es una finalización del limitador de memoria?

  • ¿Pueden levantar la mano quienes hayan oído hablar de ella?

  • Bien.

  • Algunas manos raras.

  • Aprendamos juntos.

  • Si no viste antes la función Memory Limiter kill, es porque es nueva en Android 17.

  • Y un OOM de Java solo se activa cuando superas el límite del montón de Java administrado, pero cuando el espacio en memoria total de tu app también incluye RAM nativa, como búferes gráficos y asignaciones de C++, antes no había un límite por app.

  • Por lo tanto, tendríamos el optimizador de poca memoria, que era global.

  • Intervenía cuando alguna app consumía la RAM del sistema hasta que todo el teléfono se bloqueaba.

  • Ahora, a partir de Android 17, el Limitador de memoria limita el espacio en memoria individual de cada app.

  • Por lo tanto, cuando el proceso consume una cantidad desproporcionada de RAM, el SO finaliza esa app específica.

  • Crashlytics captura todos esos detalles y agrupa las finalizaciones en el panel.

  • Por lo tanto, si haces clic en la finalización por Memory Limiter en Crashlytics, al igual que con el OOM, obtendrás una explicación de por qué intervino el SO, por lo que no necesitas memorizar lo que acabo de decir.

  • También obtendrás sugerencias para abordar el problema y vínculos directos a los documentos de aplicación de medidas por incumplimiento de la memoria de Android.

  • Y, al igual que con OOM, el seguimiento de pila culpa a un fotograma inocente cuando el SO desconectó la app, y el panel de Crashlytics lo destaca.

  • Ahora, los cierres por Memory Limiter ocurren principalmente en el código nativo porque los entornos nativos no tienen esa capa que controla la seguridad y las asignaciones de memoria.

  • De lo contrario, se habría producido un error de OOM.

  • Por lo tanto, nuestra primera pregunta es: ¿nuestros objetos de Kotlin retienen asignaciones enormes respaldadas por código nativo?

  • ¿O es un código de C++ sin procesar que tiene una fuga por sí solo?

  • Hagamos clic en Analyze in Perfetto UI para averiguarlo.

  • Consulta la pestaña Resumen en el explorador de volcados de montón de Perfetto.

  • En la información general, vemos que el RSS anónimo más el intercambio muestran que la memoria total del proceso alcanzó los 4 GB.

  • Bien.

  • Sin embargo, más abajo, vemos que en Bytes Retained by Heap, todo nuestro montón administrado, incluida la memoria nativa vinculada a objetos de Kotlin, como mapas de bits, es de solo 42 MB.

  • Bien.

  • Por lo tanto, en 10 segundos, descartamos todo el código Kotlin y Java.

  • Por lo tanto, el culpable debe ser una fuga de una capa nativa de C++.

  • Y, como ChromaClip tiene una capa delgada de C++ para el procesamiento de fotogramas, no tenemos que buscar mucho.

  • Si apuntamos nuestro agente de programación o el generador de perfiles nativo de Android Studio a NativePaletteEngine.cpp processFrame, el error está ahí.

    // NativePaletteEngine.cpp
    void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
        uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
        transformColorSpace(rgba, scratch, size);
    
        // BUG: Allocated every frame, but never freed!
    +   delete[] scratch; // Free native buffer when done
    }
  • Por lo tanto, en cada fotograma, nuestro código de C++ asigna un búfer temporal, que no es tan temporal si no lo liberas, un error de principiante.

  • Es hora de atar cabos.

  • ¿Qué pasó?

  • Una vez que hayamos corregido la fuga de Java, la fuga de C++ tendrá tiempo para acumularse y descontrolarse.

  • En Android 17, el limitador de memoria lo detectó y cerró la app.

  • Sin embargo, en los dispositivos más antiguos en los que aún no estaba disponible el limitador de memoria, se manifestaba como un error de ANR.

  • Así que, finalmente, todo tiene sentido.

  • Agreguemos delete[] scratch aquí, aumentemos la versión y enviemos la actualización.

  • Con la eliminación de las finalizaciones de Memory Limiter y los ANR, ya podemos lanzar la versión 1.0.

  • Emocionante.

  • Y gracias a lo rápido y estable que es ChromaClip ahora, nuestra app despega.

  • Miles de personas suben y crean remixes de los videos todos los días.

  • Pero con todo el tráfico, ¿recuerdas esas misteriosas fallas por error en código nativo que vimos antes en la parte inferior del panel?

  • Bueno, también se volvieron locos.

  • De repente, hay muchos más.

  • Y nuestro trabajo aún no termina.

Investiga fallas por corrupción de la memoria nativa

  • Polina Govorkova

    Por lo tanto, si abres esa falla por error en código nativo en Crashlytics, verás el seguimiento de pila.

  • No hay ni una sola línea de código de nuestra propia app.

  • RenderThread falla en lo profundo de libc.so mientras libera la memoria.

  • Y otra sugerencia para ti: cuando un subproceso del sistema falla dentro de la asignación de memoria sin que haya código de tu app, es un signo clásico de daño en la memoria nativa.

  • Por lo tanto, nuestro código de C++ dañó o liberó el búfer antes, y el subproceso de renderización se topó con él más tarde.

  • Entonces, ¿cómo atrapamos al verdadero culpable en el momento exacto en que se produjo el daño?

  • Android nos brinda dos herramientas potentes, y Crashlytics admite ambas de forma predeterminada.

  • Primero, GWP-ASan, que es una red de seguridad ligera creada específicamente para la producción.

  • Muestrea una pequeña fracción de las asignaciones de montón nativas y las rodea con páginas de protección para que la sobrecarga de CPU y memoria sea casi nula.

  • En miles de sesiones de usuario en el entorno real, cuando un error toca la memoria liberada o fuera de los límites en una asignación muestreada, GWP-ASan lo detecta de inmediato.

  • Sin embargo, debido a que GWP-ASan es, por naturaleza, probabilístico, cuando pruebes cosas de forma local, no querrás ejecutar el teléfono de prueba durante 16 horas o más para obtener la muestra aleatoria.

  • Entonces, ¿qué obtienes?

  • Para la depuración local determinista, tienes la MTE de ARM, o extensión de etiquetado de memoria.

  • La MTE usa un puntero de hardware y etiquetas de memoria para verificar cada asignación, y está disponible en dispositivos ARM v9, como el Pixel 8 y modelos posteriores.

  • Por lo tanto, vincúlalo con GWP-ASan en producción y obtendrás lo mejor de ambos mundos.

  • Y, como era de esperar, cuando cualquiera de las herramientas detecta el error, Crashlytics asigna automáticamente una insignia al problema, te permite filtrar por GWP-ASan o MTE, y fija una tarjeta de información con la causa exacta de la corrupción en el panel.

  • Veamos qué sucede en la consola de Crashlytics cuando GWP-ASan detecta la falla.

  • Junto a la pestaña de seguimiento de pila normal, tenemos el seguimiento de pila de la memoria.

  • En la parte superior, vemos la causa exacta del daño: Use After Free.

  • En lugar de mostrar que el subproceso de renderización falla en libc.so, GWP-ASan ahora nos brinda la biografía completa de ese búfer dentro de nuestro propio effect_pipeline.cc.

  • En Allocated By, vemos dónde se creó el búfer.

  • Y justo debajo de Deallocated By, vemos el culpable exacto que lo liberó del subproceso de renderización.

  • Y eso se oculta en la línea 208 de nuestra canalización de efectos.

  • Por lo tanto, abrimos nuestro código nativo en el EffectPipeline y el error nos sonríe.

    // effect_pipeline.cc
    void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
        // BUG: Freed on worker thread while RenderThread is still reading it!
    +   std::lock_guard<std::mutex> lock(lut_mutex_);
    
        releaseLutBuffer();       // Frees old color-grading LUT
        loadFilterPreset(preset); // Allocates new color-grading LUT
    }
  • Cada vez que un usuario cambia el filtro de color en el subproceso en segundo plano, liberamos el búfer anterior mientras el RenderThread aún lo está leyendo.

  • Bien, agregamos el bloqueo de exclusión mutua alrededor del intercambio de búfer, enviamos la actualización y, finalmente, vemos que los informes de GWP-ASan y todas esas fallas misteriosas desaparecen casi de la noche a la mañana.

  • Y nuestra métrica de usuarios que no experimentaron fallas se disparó al 100%.

  • ¡Muy bien!

  • Recuerda que, al principio de la charla, nos preguntamos: "¿Terminé?".

  • Justo antes de que lo golpeen prácticamente todos los jefes de capacidad de respuesta y memoria del libro.

  • Bueno, los errores de ANR, de memoria insuficiente, de eliminación por limitador de memoria y de corrupción de memoria nativa ya se resolvieron.

  • Incluso si el seguimiento de pila miente y el culpable se oculta en algún lugar de los subprocesos en C++, Android y Firebase Crashlytics te brindan la herramienta para rastrear y enviar con confianza.

Resumen y registro para el acceso anticipado

  • Polina Govorkova

    Recapitulemos lo que puedes usar cuando vuelvas a tu laptop: estadísticas de subprocesos de ANR y otros metadatos relevantes a través de ApplicationExitInfo, recopilación automática de activadores de cierre de OOM y limitador de memoria a través de ProfilingManager, y compatibilidad completa con GWP-ASan y Arm MTE para daños en la memoria nativa.

  • Además, se lanzarán en versión preliminar privada los volcados de montón de campo en Android 17 o versiones posteriores.

  • Si quieres ser de las primeras personas en probar la recopilación automática de volcados de montón en Firebase Crashlytics y analizarlos con Perfetto y la CLI de Android, toma una foto rápida de la diapositiva y regístrate en el vínculo.

  • Ahora que ya pasamos por la mayor parte de la charla, quiero agradecerte por acompañarme hoy y por tu tiempo.

  • Que tu subproceso principal permanezca desbloqueado, tu montón se mantenga limpio y tu próxima versión sea la más estable hasta el momento.

  • Así que, ¡feliz depuración!

  • Muchas gracias de nuevo.

Código

Fragmentos de código

Fragmento de texto en el minuto 1:45 de "Introducción"
java.lang.NullPointerException
  at MainActivity.onCreate(MainActivity.kt:42)
  at android.app.Activity.performCreate
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
Fragmento de texto en el minuto 01:58 de "Introducción"
"main" prio=5 tid=1 Blocked
  | group="main" sCount=1 dsCount=0 flags=1
  | state=S schedstat=( 1450239 48192 145 )
  at DataManager.getData(DataManager.kt:25)
  - waiting to lock <0x0a1b> held by thread 12
  at MainActivity.onResume(MainActivity.kt:50)
  at android.app.Activity.performResume
  at android.os.Handler.dispatchMessage
  at android.os.Looper.loopOnce
  at android.app.ActivityThread.main
Fragmento de texto en el minuto 04:45 de "Depura y analiza errores de ANR"
14:32:15.105  1205-1250/system_process E/ActivityManager: ANR in com.chromaclip.app (com.chromaclip.app/.MainActivity)
    PID: 14205
    Reason: Input dispatching timed out
    Dumped ANR trace to /data/anr/traces.txt
"main" prio=5 tid=1 Native
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x72a8c3d0 self=0x759fb92000
  | sysTid=14205 nice=-10 cgrp=default sched=0/0 handle=0x7623910548
  | state=S schedstat=( 1450239120 4819203 1453 ) utm=132 stm=13 core=4 HZ=100
  | stack=0x7fed2a0000-0x7fed2a2000 stackSize=8MB
  at sun.misc.Unsafe.park(Native method)
  - waiting on an unknown object
  at java.util.concurrent.locks.LockSupport.park(LockSupport.java:190)
  at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:279)
  at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
  at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
  at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source:1)
  at com.chromaclip.app.extractor.PaletteExtractor.extractColors(PaletteExtractor.kt:42)
Fragmento de texto en el minuto 08:08 de "Depuración y análisis de errores de ANR"
DefaultDispatcher-worker-1 (Root blocking)

dalvik.system.VMRuntime.allocateNode (Native method)
android.graphics.Bitmap.createBitmap (Bitmap.java:1124)
android.graphics.Bitmap.createBitmap (Bitmap.java:1091)
com.chromaclip.app.extractor.FrameDecoder.getUncompressedFrame (FrameDecoder.kt:112)
com.chromaclip.app.extractor.PaletteExtractor$processVideo$2.invokeSuspend (PaletteExtractor.kt:65)
kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith (ContinuationImpl.kt:33)
kotlinx.coroutines.DispatchedTask.run (DispatchedTask.kt:106)
java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:641)
java.lang.Thread.run (Thread.java:923)
Fragmento de texto en el minuto 11:54 de "Investigating out of memory exceptions"
java.lang.OutOfMemoryError: Failed to allocate a 256 byte allocation
 at android.graphics.Bitmap.createBitmap
 at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
 at com.chromaclip.app.SettingsActivity.onCreate(SettingsActivity.kt:21)
Fragmento de shell en el minuto 15:54 de "Captura y análisis de volcados de montón"
# 1. Install Android CLI & Perfetto skills
$ android skills add android-cli perfetto

# 2. List recent heap dumps from Crashlytics
$ android signals heaps list --kind oom --timeframe=90d

# 3. Download a heap dump as a Perfetto trace
$ android signals heaps download <heap-dump-id> \
    --output ./heaps

# 4. Query top retained objects in the heap dump
$ trace_processor query ./heaps/oom.perfetto-trace ...

# 5. Fetch matching Crashlytics events
$ android signals heaps describe <heap-dump-id>
Fragmento de texto en el minuto 16:27 de "Capturing and analyzing volcado de montón"
# Or ask your favorite LLM in natural language
> Using `android` command, inspect the latest heap dump
  for my OOM issue and propose a fix
● Ran android signals heaps list · download · describe
● Analyzed ./heaps/oom.perfetto-trace (perfetto)
● Updated PaletteExtractor.kt (+6 -4 lines)
Fragmento de Kotlin en el minuto 16:47 de "Capturing and analyzing volcado de montón"
// PaletteExtractor.kt
fun extractPalette(framePixels: IntArray): SparseIntArray {
-   val pixelColors = mutableListOf<Pair<Color, String>>() // 8.3M objects per 4K frame!
-   for (px in framePixels) {
-       pixelColors.add(Color.valueOf(px) to "#%06X".format(px))
-   }

+   val colorHistogram = SparseIntArray() // Downsampled & packed ARGB ints!
+   for (i in framePixels.indices step 16) {
+       val argb = framePixels[i]
+       colorHistogram.put(argb, colorHistogram.get(argb) + 1)
+   }
+   return colorHistogram
}
Fragmento de C++ en el minuto 21:06 de "Investigating native memory issues"
// NativePaletteEngine.cpp
void NativePaletteEngine::processFrame(const uint8_t* rgba, size_t size) {
    uint8_t* scratch = new uint8_t[size]; // Temporary frame buffer
    transformColorSpace(rgba, scratch, size);

    // BUG: Allocated every frame, but never freed!
+   delete[] scratch; // Free native buffer when done
}
Fragmento de C++ en el minuto 25:14 de "Investigating native memory corruption crashes"
// effect_pipeline.cc
void EffectPipeline::onFilterSwitched(FilterPresetId preset) {
    // BUG: Freed on worker thread while RenderThread is still reading it!
+   std::lock_guard<std::mutex> lock(lut_mutex_);

    releaseLutBuffer();       // Frees old color-grading LUT
    loadFilterPreset(preset); // Allocates new color-grading LUT
}