wandres.dev
ANDROID STUDIO · SDK, emulador y adb

Depurar de verdad

Puntos de ruptura condicionales y de registro, inspección y manipulación del proceso vivo, el depurador de layouts con Compose, y cómo depurar una compilación de publicación ofuscada por R8.

⏱ 18 min

Depurar no es avanzar línea a línea esperando ver algo raro. Es formular una hipótesis falsable sobre el estado del proceso y elegir el instrumento mínimo que la refutaría. El depurador de IntelliJ ofrece un arsenal para eso —condiciones, recuentos de impactos, puntos de ruptura que registran sin detener, rebobinado de marcos, evaluación de expresiones arbitrarias— que la mayoría de la gente no usa porque nadie se lo enseñó. Y al final del camino está el caso difícil: depurar el artefacto que realmente publicas, minificado y con los nombres borrados.

🎯 Al terminar esta lección sabrás
  • Usar puntos de ruptura condicionales, de excepción y de registro sin detener el proceso.
  • Inspeccionar y modificar el estado vivo: evaluar, asignar, rebobinar marcos y etiquetar objetos.
  • Diagnosticar la interfaz con el inspector de layouts y los recuentos de recomposición.
  • Depurar y descifrar una compilación de publicación ofuscada usando el mapa de R8.

Puntos de ruptura que piensan por ti

Un punto de ruptura sin condición en un bucle de mil iteraciones no es una herramienta de diagnóstico: es una forma de perder diez minutos pulsando continuar. El diálogo que aparece al pulsar con el botón derecho sobre el punto rojo contiene todo lo que hace falta para evitarlo.

🎯

Condición

Una expresión booleana en el ámbito del punto, por ejemplo pedido.id == 4217. Solo detiene cuando es cierta. Es la diferencia entre buscar y encontrar.

🪵

Evaluar y registrar

Desmarca la suspensión y activa el registro de una expresión. Obtienes trazas sin recompilar, sin Log sembrado por el código y sin alterar el ritmo del programa.

💥

Punto de excepción

Se dispara cuando se lanza un tipo concreto, capturado o no, sin que sepas dónde ocurrirá. Es la única forma sensata de cazar una excepción que alguien traga en silencio.

🔗

Dependencia y recuento

Un punto que solo se activa después de que otro se haya disparado, o a partir de la iteración número doscientos. Acota el escenario sin tocar el código.

Hay dos opciones más que salvan sesiones enteras. La primera es elegir si el punto suspende todos los hilos o solo el que lo alcanza: suspender todo congela la imagen y es lo que quieres casi siempre, pero suspender solo un hilo permite observar una carrera entre dos sin detener al competidor. La segunda es el punto de observación sobre un campo, que no detiene en una línea sino cuando un valor cambia, sin que necesites saber quién lo cambia. Para código con corrutinas, además, el panel de corrutinas del depurador muestra las que están suspendidas y su estado, algo que la pila de hilos por sí sola no puede revelar.

💡
Depurar el arranque

Un punto de ruptura en el método de creación de la aplicación nunca se alcanza si conectas el depurador después. La solución es indicarle al sistema que espere: adb shell am set-debug-app -w --persistent com.ejemplo.app deja el proceso congelado en el arranque hasta que te conectas. Se limpia con am clear-debug-app.

Inspeccionar y manipular el proceso vivo

Detener el proceso es solo la mitad. La otra mitad es interrogarlo, y el depurador permite bastante más que mirar la lista de variables.

Acción Para qué sirve
Evaluar expresión Ejecutar cualquier código en el ámbito actual, incluidas llamadas a métodos
Asignar valor Cambiar una variable en caliente para probar la rama contraria sin recompilar
Descartar marco Rebobinar la pila y volver a entrar en la función que acabas de ejecutar
Forzar retorno Salir de un método con el valor que tú decidas, saltándote su cuerpo
Etiquetar objeto Ponerle nombre a una instancia y reconocerla más tarde en cualquier panel
Ejecutar hasta el cursor Continuar sin poner un punto de ruptura desechable

La combinación de evaluar y asignar convierte el depurador en un banco de pruebas interactivo: puedes forzar un error de red asignando null a una respuesta, comprobar la rama de fallo, descartar el marco y volver a ejecutar la misma llamada con el valor correcto. Todo eso sin una sola recompilación, que en un proyecto grande cuesta minutos.

⚠️
El depurador altera lo que observa

Detener un hilo cambia el orden de ejecución y hace desaparecer condiciones de carrera. Cuando un error solo ocurre sin el depurador, la herramienta correcta no es un punto de ruptura sino un punto de registro sin suspensión, o directamente logcat con marcas temporales.

Depurar la interfaz

El inspector de layouts muestra la jerarquía que el sistema está pintando en este instante, con las propiedades resueltas de cada nodo y, en Compose, los parámetros de cada composable junto a su semántica de accesibilidad. Resuelve de un vistazo la clase de preguntas que de otro modo se responde a base de cambiar un valor y recompilar: por qué un elemento no aparece, qué restricción lo está aplastando, qué modificador introduce ese margen que nadie escribió.

Su función más valiosa, sin embargo, no es estructural sino de rendimiento. El recuento de recomposiciones asigna a cada composable dos números: cuántas veces se ha recompuesto y cuántas veces se ha saltado la recomposición. Un composable que se recompone en cada fotograma sin que sus datos hayan cambiado es la causa más común de una interfaz que se atasca, y ese contador la convierte de sospecha en hecho. Para que funcione, la variante de depuración debe incluir las herramientas de inspección:

dependencies {
    debugImplementation(libs.androidx.compose.ui.tooling)
    implementation(libs.androidx.compose.ui.tooling.preview)
}

Depurar un release ofuscado

Aquí está el caso que separa a quien sabe depurar de quien sabe usar el depurador. La aplicación que publicas no es la que compilas en depuración: R8 la ha minificado, ha eliminado código muerto, ha incorporado métodos dentro de otros y ha renombrado clases y campos a una y dos letras. Una traza de pila procedente de producción es, literalmente, ilegible.

flowchart TD
A[Fallo en produccion] --> B[Traza ofuscada con nombres de una letra]
B --> C[Recuperar el mapping.txt de esa version exacta]
C --> D[retrace sobre la traza]
D --> E[Nombres y lineas originales]
E --> F[Reproducir en un release depurable]
F --> G[Punto de ruptura sobre el codigo real minificado]
H[Sin mapping guardado] --> I[La traza es irrecuperable para siempre]
style I fill:#f38ba8,color:#11111b
style E fill:#a6e3a1,color:#11111b

El mapa que deshace el renombrado se genera en cada compilación minificada y se escribe en app/build/outputs/mapping/release/mapping.txt. Es un artefacto irrepetible: una compilación posterior, aunque parta del mismo código, puede producir nombres distintos. Guardarlo junto a cada versión publicada no es una buena práctica, es un requisito.

# descifrar una traza de pila con el mapa de esa version
retrace mapping.txt traza_ofuscada.txt

# codigo nativo: simbolizar con los simbolos sin desnudar
ndk-stack -sym app/build/intermediates/merged_native_libs/release/out/lib/arm64-v8a \
          -dump traza_nativa.txt

Para que la traza conserve números de línea hay que preservar los atributos correspondientes en las reglas de R8, y para depurar de verdad sobre el artefacto minificado se crea un tipo de compilación intermedio que hereda del de publicación pero es depurable y está firmado con una clave de desarrollo:

android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"),
                          "proguard-rules.pro")
        }
        create("releaseDebuggable") {
            initWith(getByName("release"))
            isDebuggable = true
            signingConfig = signingConfigs.getByName("debug")
            matchingFallbacks += listOf("release")
        }
    }
}
# proguard-rules.pro: sin esto, no hay numeros de linea que descifrar
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
La compilación que depuras no es la que tus usuarios ejecutan

Existe una asimetría incómoda en el corazón de la práctica profesional: todo tu ciclo de trabajo ocurre sobre un binario que nadie usará jamás. La compilación de depuración lleva activada la verificación de aserciones, no minifica, conserva todos los nombres, desactiva optimizaciones y arrastra bibliotecas de instrumentación que en publicación no existen. La de publicación es otro programa: R8 ha decidido que un método privado llamado una sola vez puede incorporarse dentro de su llamador, que una clase con un único implementador puede colapsarse, que una rama gobernada por una constante puede desaparecer. Las consecuencias son concretas y a veces brutales. Un error de reflexión sobre un nombre de clase que ya no existe solo aparece en publicación. Una serialización que dependía de nombres de campo se rompe únicamente ahí. Una condición de carrera latente se manifiesta cuando el optimizador reordena y acelera el código lo bastante como para estrechar la ventana. Y la pila que ves en la traza puede no corresponder a ninguna secuencia de llamadas del código fuente, porque las funciones incorporadas no dejan marco propio. De ahí se siguen tres disciplinas no negociables. La primera: archivar el mapping.txt de cada versión publicada como si fuera el propio binario, porque sin él las trazas de tus usuarios son ruido permanente e irrecuperable. La segunda: mantener un tipo de compilación que sea idéntico al de publicación salvo por la depurabilidad, y probar ahí antes de cada entrega, porque es la única forma de conectar un depurador al programa real. La tercera, la más importante: entender que las reglas de conservación no son magia defensiva que se copia de un foro, sino la declaración explícita de qué partes de tu código son alcanzadas por caminos que el análisis estático no puede ver. Cada regla que escribes sin saber por qué es optimización que renuncias a recibir; cada regla que falta es un fallo que solo descubrirán tus usuarios.

⚔️ Depura lo que publicas
  1. Pon un punto de ruptura condicional dentro de un bucle largo y detén la ejecución exactamente en la iteración que te interesa, sin pulsar continuar ni una vez.
  2. Convierte tres llamadas de registro de tu código en puntos de ruptura de tipo evaluar y registrar. Bórralas del código fuente.
  3. Usa evaluar expresión y asignar valor para forzar la rama de error de una llamada de red, y luego descarta el marco para volver a intentarlo con el valor bueno.
  4. Abre el inspector de layouts sobre una lista y localiza el composable con más recomposiciones. Explica por qué se recompone.
  5. Compila una variante minificada, provoca un fallo, copia la traza ofuscada y descífrala con retrace y el mapping.txt correspondiente.
  6. Crea el tipo de compilación depurable derivado del de publicación, conecta el depurador y comprueba con tus ojos qué nombres se han perdido y qué métodos han desaparecido de la pila.