Calidad de app Android en Kotlin: pruebas, depuración y rendimiento

Capítulo 12

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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() o load()).
  • 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, crash

Solució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.IO para 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 Modifier complejos o onClick con capturas innecesarias).

Prácticas recomendadas:

  • Reduce el alcance del estado: pasa solo lo que el composable necesita.
  • Usa remember para valores derivados que no deben recalcularse siempre.
  • Usa derivedStateOf para 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íntomaCausa frecuenteAcción
Scroll con tironesAsignaciones excesivas, recomposición, imágenes pesadasKeys estables, simplificar item, cache/remember, mover trabajo a background
Pantalla tarda en abrirCarga síncrona en main, inicializaciones grandesLazy load, coroutines, diferir trabajo no crítico
ANRBloqueo del hilo principalEliminar 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).

Ahora responde el ejercicio sobre el contenido:

Al investigar un crash usando el stacktrace, ¿cuál es el primer paso más efectivo para encontrar la causa raíz en tu app?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

El stacktrace suele incluir muchas capas. Para hallar la causa raíz conviene identificar la primera línea que apunta a tu código (tu paquete) y revisar qué excepción ocurrió y en qué contexto (nulo, índice fuera de rango, estado inválido).

Siguiente capítulo

Publicación y entrega de una app Android con Kotlin

Arrow Right Icon
Portada de libro electrónico gratuitaAndroid desde Cero con Kotlin
86%

Android desde Cero con Kotlin

Nuevo curso

14 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.