Depuración en Android Studio: ver qué pasa y por qué
La depuración consiste en observar el comportamiento real de tu app (valores, estados, hilos, UI) para encontrar la causa de un fallo o de un resultado inesperado. En Android, las herramientas principales son Logcat, el depurador con breakpoints y los inspectores de UI (Views/Compose).
Logcat: registros útiles (y cómo no perderte)
Logcat muestra los logs del dispositivo/emulador. Es ideal para entender flujos, estados y errores cuando no puedes (o no conviene) pausar la ejecución con breakpoints.
- Buenas prácticas: usa tags consistentes, registra eventos relevantes (inicio/fin de una operación, cambios de estado), y evita loguear datos sensibles.
- Filtrado: filtra por paquete de tu app, por tag o por nivel (Debug/Info/Warn/Error).
Ejemplo de logs con niveles:
private const val TAG = "Login" // o el nombre de la feature/pantalla
fun onLoginClicked(email: String) {
Log.d(TAG, "Click login con email length=${email.length}")
try {
// ... lógica
Log.i(TAG, "Login solicitado")
} catch (t: Throwable) {
Log.e(TAG, "Error en login", t)
}
}Si trabajas con coroutines, loguear el hilo puede ayudarte a detectar trabajo en el hilo principal:
Log.d(TAG, "Thread=${Thread.currentThread().name}")Breakpoints: pausar, inspeccionar y avanzar
Los breakpoints detienen la ejecución en una línea para inspeccionar variables, evaluar expresiones y avanzar paso a paso.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
- Step Over: ejecuta la línea actual sin entrar en funciones.
- Step Into: entra en la función llamada.
- Step Out: sale de la función actual.
- Resume: continúa hasta el siguiente breakpoint.
Guía práctica para depurar un estado incorrecto en un ViewModel:
- Coloca un breakpoint en el método que actualiza el estado (por ejemplo,
onRetry()oload()). - Ejecuta la app en modo Debug.
- Reproduce el problema (toca el botón, navega, etc.).
- Cuando se detenga, inspecciona: valores de entrada, estado actual, y el resultado antes de asignarlo.
- Usa “Evaluate Expression” para probar expresiones (por ejemplo, validar una condición o ver el contenido de una lista).
Analizar errores y crashes: stacktrace y causa raíz
Cuando ocurre un crash, Logcat muestra un stacktrace. Para encontrar la causa raíz:
- Busca la primera línea del stacktrace que apunte a tu paquete (tu código).
- Identifica la excepción (por ejemplo,
NullPointerException,IllegalStateException). - Revisa el contexto: qué valor era nulo, qué índice se salió de rango, qué estado era inválido.
Ejemplo típico: acceso a índice inválido. El stacktrace suele apuntar a la línea exacta:
val first = items[0] // si items está vacío, crashSolución: valida antes o usa alternativas seguras:
val first = items.firstOrNull()Inspección de UI: Layout Inspector (Views) y Compose
Inspeccionar la UI sirve para entender jerarquías, tamaños, constraints, recomposiciones y estados visuales.
Layout Inspector (para Views y jerarquías híbridas)
- Abre la app en un dispositivo/emulador.
- En Android Studio:
Tools > Layout Inspector. - Selecciona el proceso de tu app.
- Explora el árbol de vistas, revisa propiedades (size, padding, constraints) y detecta elementos invisibles o fuera de pantalla.
Inspección en Jetpack Compose
En Compose, además de ver el árbol, te interesa detectar recomposiciones y estados que cambian demasiado.
- Usa el inspector para navegar el árbol de composables y revisar parámetros.
- Si notas “parpadeos” o cambios excesivos, sospecha de recomposiciones innecesarias o de estado mal ubicado.
Pruebas: asegurar comportamiento sin depender de tocar la app
Las pruebas automatizadas te permiten validar que tu lógica y tu UI se comportan como esperas, y te protegen de regresiones al refactorizar. A nivel básico, conviene empezar por dos tipos: pruebas unitarias (lógica) y pruebas de UI (interacción/estado visible).
Pruebas unitarias: lógica del ViewModel (estado y eventos)
Una prueba unitaria valida una función o clase en aislamiento. En apps con ViewModel y UI State, un objetivo común es: dado un evento, el estado emitido debe ser el correcto.
Ejemplo de un estado simple:
data class UiState(
val isLoading: Boolean = false,
val items: List<String> = emptyList(),
val error: String? = null
)Un ViewModel que carga datos (simplificado):
class ItemsViewModel(
private val repo: ItemsRepository
) : ViewModel() {
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
runCatching { repo.getItems() }
.onSuccess { list ->
_state.value = UiState(isLoading = false, items = list, error = null)
}
.onFailure { t ->
_state.value = UiState(isLoading = false, items = emptyList(), error = t.message)
}
}
}
}Test unitario básico verificando estados. Aquí se muestra el patrón general (dependiendo de tu configuración de test, podrías usar kotlinx-coroutines-test para controlar el dispatcher):
@Test
fun `load emite loading y luego items cuando repo responde ok`() = runTest {
val repo = object : ItemsRepository {
override suspend fun getItems(): List<String> = listOf("A", "B")
}
val vm = ItemsViewModel(repo)
val states = mutableListOf<UiState>()
val job = launch { vm.state.toList(states) }
vm.load()
advanceUntilIdle()
// Verificaciones mínimas
assert(states.any { it.isLoading })
assert(states.last().items == listOf("A", "B"))
assert(states.last().error == null)
job.cancel()
}Test unitario para error:
@Test
fun `load emite error cuando repo falla`() = runTest {
val repo = object : ItemsRepository {
override suspend fun getItems(): List<String> = throw IllegalStateException("Boom")
}
val vm = ItemsViewModel(repo)
vm.load()
advanceUntilIdle()
val last = vm.state.value
assert(!last.isLoading)
assert(last.items.isEmpty())
assert(last.error != null)
}Checklist práctico para tests de ViewModel:
- Controla el tiempo/coroutines con herramientas de test para evitar flakiness.
- Evita dependencias reales (red/DB); usa fakes simples.
- Verifica transiciones: loading → success o loading → error.
Pruebas de UI (Compose): verificar estados visibles
Las pruebas de UI validan que, dado un estado, la pantalla muestre lo correcto y que las interacciones disparen eventos. En Compose, se suele testear con reglas de Compose y selectores (por texto o por testTag).
Pantalla ejemplo (simplificada) con tags:
@Composable
fun ItemsScreen(
state: UiState,
onRetry: () -> Unit
) {
when {
state.isLoading -> {
CircularProgressIndicator(modifier = Modifier.testTag("loading"))
}
state.error != null -> {
Column {
Text("Error", modifier = Modifier.testTag("error"))
Button(onClick = onRetry, modifier = Modifier.testTag("retry")) {
Text("Reintentar")
}
}
}
else -> {
LazyColumn(modifier = Modifier.testTag("list")) {
items(state.items) { item ->
Text(item)
}
}
}
}
}Test de UI: cuando está cargando, se ve el indicador:
@get:Rule
val composeRule = createComposeRule()
@Test
fun muestraLoading_cuandoIsLoadingTrue() {
composeRule.setContent {
ItemsScreen(state = UiState(isLoading = true), onRetry = {})
}
composeRule.onNodeWithTag("loading").assertExists()
composeRule.onNodeWithTag("list").assertDoesNotExist()
}Test de UI: cuando hay error, aparece botón y se dispara el callback:
@Test
fun alPulsarReintentar_seEjecutaCallback() {
var retried = false
composeRule.setContent {
ItemsScreen(state = UiState(error = "x"), onRetry = { retried = true })
}
composeRule.onNodeWithTag("retry").performClick()
assert(retried)
}Rendimiento: hacer la app fluida y eficiente
El rendimiento en Android suele degradarse por trabajo pesado en el hilo principal, recomposiciones innecesarias en Compose, listas mal optimizadas y fugas de recursos. La meta práctica: mantener la UI a 60fps/90fps (según dispositivo) evitando jank y ANRs.
Evitar trabajo en el hilo principal
El hilo principal debe dedicarse a dibujar y responder a eventos. Si haces I/O, parsing pesado o cálculos grandes en main, aparecerán congelamientos.
- Mueve trabajo pesado a un dispatcher apropiado (por ejemplo,
Dispatchers.IOpara I/O). - Divide tareas grandes en partes o usa procesamiento incremental.
Ejemplo: parsing pesado fuera del main:
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
val result = withContext(Dispatchers.Default) {
// CPU-bound
heavyCompute()
}
_state.value = _state.value.copy(isLoading = false)
}Minimizar recomposiciones en Compose
Compose recompone cuando cambian estados observados. Recompone “lo necesario”, pero puedes forzar recomposiciones extra si:
- Creas objetos nuevos en cada recomposición (listas, lambdas, formatos) sin necesidad.
- Pasas estados muy amplios a composables pequeños (cualquier cambio dispara recomposición).
- No estabilizas parámetros (por ejemplo, crear
Modifiercomplejos oonClickcon capturas innecesarias).
Prácticas recomendadas:
- Reduce el alcance del estado: pasa solo lo que el composable necesita.
- Usa
rememberpara valores derivados que no deben recalcularse siempre. - Usa
derivedStateOfpara valores derivados de estado que deben recalcularse solo cuando cambie su dependencia.
Ejemplo: derivar un texto filtrado sin recalcular en cada cambio no relacionado:
@Composable
fun SearchHeader(query: String, itemsCount: Int) {
val subtitle by remember(query, itemsCount) {
mutableStateOf("$itemsCount resultados para '$query'")
}
Text(subtitle)
}Ejemplo más típico con derivedStateOf:
@Composable
fun ItemsContent(query: String, items: List<String>) {
val filtered by remember(query, items) {
derivedStateOf { items.filter { it.contains(query, ignoreCase = true) } }
}
LazyColumn {
items(filtered) { Text(it) }
}
}Optimizar listas (LazyColumn/LazyRow)
Las listas son un punto crítico de rendimiento. En Compose, usa componentes “Lazy” para renderizar solo lo visible.
- Provee keys estables para evitar reusos incorrectos y recomposiciones extra cuando cambie el orden.
- Evita medir/dibujar de más: items simples, evita layouts anidados innecesarios.
- Imagen y carga: si hay imágenes, usa carga eficiente y evita decodificar en main.
Ejemplo con key estable:
data class RowItem(val id: String, val title: String)
@Composable
fun ItemsList(items: List<RowItem>) {
LazyColumn {
items(
items = items,
key = { it.id }
) { item ->
Text(item.title)
}
}
}Detectar jank y cuellos de botella con profiling (práctico)
Las herramientas de profiling te ayudan a medir en lugar de adivinar. Un flujo práctico para investigar “la app va lenta”:
- 1) Reproduce el problema: scroll con jank, pantalla que tarda en abrir, animación trabada.
- 2) CPU Profiler: graba mientras reproduces el problema y busca funciones costosas (parsing, mapping, formateos, composición excesiva).
- 3) Memory Profiler: revisa picos y objetos retenidos; busca crecimiento continuo (posible fuga) o muchas asignaciones durante scroll.
- 4) Network Profiler: si hay esperas, verifica latencias, tamaños y llamadas repetidas.
- 5) System Trace: identifica trabajo en main, frames perdidos y bloqueos.
Señales típicas y acciones:
| Síntoma | Causa frecuente | Acción |
|---|---|---|
| Scroll con tirones | Asignaciones excesivas, recomposición, imágenes pesadas | Keys estables, simplificar item, cache/remember, mover trabajo a background |
| Pantalla tarda en abrir | Carga síncrona en main, inicializaciones grandes | Lazy load, coroutines, diferir trabajo no crítico |
| ANR | Bloqueo del hilo principal | Eliminar I/O en main, revisar locks, usar dispatchers correctos |
Checklist rápido de calidad (para aplicar en tu día a día)
- Antes de “arreglar”, reproduce y mide (Logcat + profiler).
- Si hay crash, empieza por la primera línea del stacktrace que apunte a tu código.
- En Compose, revisa recomposiciones: estado bien ubicado, objetos/lambdas estables, listas con keys.
- En listas, evita trabajo por item (formateos, filtros, sorting) dentro del composable; precalcula o deriva con cuidado.
- Nunca hagas I/O o CPU pesado en main: muévelo a
Dispatchers.IO/Default. - Agrega tests unitarios para transiciones de estado y tests de UI para estados visibles críticos (loading/error/empty/content).