Saltar al contenido
K
Android: la plataformaPaso a paso38 min de lectura · 6 min de máquina Intermedio

El hilo principal y el ANR

Al terminar: Entender el hilo principal y el ANR, e instalar las corrutinas en :app para sacar los cálculos pesados del hilo principal.

Al terminar esta lección, la pantalla de Marcador tendrá un botón que congela la app y hace que Android muestre el diálogo «Marcador isn't responding», y otro botón que hace el mismo cálculo sin congelar nada. Habrás provocado un ANR con tus manos, habrás leído su mensaje literal en Logcat y sabrás arreglarlo con lifecycleScope y withContext.

Importa porque el hilo principal es el único que dibuja y atiende los toques: cualquier trabajo largo ahí se nota al instante. Es además la primera lección que instala una librería en :app, con la receta de Los ficheros de Gradle de tu proyecto. Temas: hilo principal y ANR; lifecycleScope y withContext; instalar una librería en el módulo de la app; el Profiler (recuadro). En la lección siguiente, Lifecycle y repeatOnLifecycle, el marcador en vivo aprende a pararse cuando la pantalla no se ve.

El hilo principal y el ANR

Un hilo es el trabajador que ejecuta tu código línea a línea, como viste en Corrutinas y suspend. En una app hay varios, pero uno es especial: el hilo principal (main thread), también llamado hilo de la interfaz. Solo él dibuja la pantalla y atiende los toques. Mientras ejecuta algo, no hace nada más.

ANR («la aplicación no responde») es ese error: el sistema decide que la app está colgada y ofrece cerrarla.

Cómo se arregla: lifecycleScope y withContext

Las corrutinas que ya conoces necesitan un alcance (Alcance, Job y cancelación). En una Activity, el alcance natural está atado a su vida: la Activity nace, se oculta y se destruye (lo viste en El ciclo de vida de la Activity), y ese alcance se cancela solo cuando ella se destruye.

Un Lifecycle es el objeto que sabe en qué estado está una pantalla, y un LifecycleOwner es quien tiene uno (la Activity lo es); en la lección siguiente los verás por dentro. Hoy basta con esto: lifecycleScope es un alcance que ya trae la Activity y que se cancela en onDestroy.

HerramientaQué hace
lifecycleScope.launch { … }Corrutina hija de la Activity; se cancela en onDestroy
withContext(Dispatchers.Default) { … }Mueve solo el cálculo a un hilo de fondo y vuelve al principal con el resultado
ejemplo mínimo: calcular sin congelar (con comentarios)
// Un botón que calcula la media de goles de mil partidos sin congelar la pantalla:
lifecycleScope.launch {                                  // corrutina atada a la Activity; muere en onDestroy
    val media = withContext(Dispatchers.Default) {       // solo este bloque va a un hilo de fondo
        mediaDeGoles(1_000)                              // el cálculo pesado, lejos de la pantalla
    }
    textoMedia = "media: $media"                         // aquí ya estamos otra vez en el hilo principal
}

Error típico: runBlocking en el hilo principal

runBlocking arranca una corrutina y bloquea el hilo actual hasta que termina. En una app de consola no importa; en el hilo principal es el ANR. Y como suspend no cambia de hilo, llamar a una función suspendida dentro no lo evita.

con el bug: bloquea el hilo principal
Button(onClick = {
    // onClick corre en el hilo principal: nada puede tocar la pantalla hasta que termine.
    val p = runBlocking { probabilidadDeVictoria(Ejemplo.rayoContraToros, 100_000_000) }
    resultado = "victoria local: $p"
}) {
    Text("Calcular")
}
corregido: fuera del hilo principal
Button(onClick = {
    lifecycleScope.launch {
        val p = withContext(Dispatchers.Default) {
            probabilidadDeVictoria(Ejemplo.rayoContraToros, 100_000_000)
        }
        resultado = "victoria local: $p"
    }
}) {
    Text("Calcular")
}

Reto · Ejercicio: un reloj que no se congela

Esto es Kotlin puro, así que lo haces fuera de tu proyecto, en el Kotlin Playground (admite corrutinas) y sin emulador: bloquear un hilo con una pausa se comporta igual en la consola que en el móvil.

Escribe un programa que lance con launch un reloj que, cinco veces, espera una décima de segundo con delay(100) (que no bloquea el hilo) y escribe «tick». Justo después, en el mismo hilo, simula un cálculo largo de un segundo con Thread.sleep(1000) (una pausa que sí bloquea el hilo) y escribe «fin del cálculo». Escribe «Versión 1» antes de todo ello. Después repite lo mismo, precedido de «Versión 2», pero con el Thread.sleep(1000) dentro de withContext(Dispatchers.Default), el grupo de hilos pensado para calcular. Mete cada versión en un coroutineScope { … }, que espera a que acabe todo lo lanzado dentro (el reloj incluido) antes de pasar a la siguiente.

Antes de ejecutar, escribe en un papel en qué orden saldrá cada versión y por qué. La consola debe mostrar:

Versión 1
fin del cálculo
tick
tick
tick
tick
tick
Versión 2
tick
tick
tick
tick
tick
fin del cálculo

Para pensar: en la versión 1, ¿cuándo empezó el reloj a contar su primera décima de segundo?

Ver solución
solución: bloquear frente a mover de hilo (con comentarios)
import kotlinx.coroutines.*
 
fun main() = runBlocking {                        // runBlocking: el hilo de main es el único hilo
    println("Versión 1")
    coroutineScope {                              // coroutineScope espera a que acabe todo lo lanzado dentro
        launch {                                  // el reloj: corre en ese mismo hilo, cuando esté libre
            repeat(5) {
                delay(100)                        // delay SÍ libera el hilo mientras espera
                println("tick")
            }
        }
        Thread.sleep(1000)                        // la pausa de Java: tiene el hilo ocupado durante un segundo
        println("fin del cálculo")
    }
    println("Versión 2")
    coroutineScope {
        launch {
            repeat(5) {
                delay(100)
                println("tick")
            }
        }
        withContext(Dispatchers.Default) { Thread.sleep(1000) }  // el segundo se gasta en otro hilo
        println("fin del cálculo")
    }
}

Con Thread.sleep(1000) directo, el orden es fin del cálculo y después los cinco tick: el único hilo estaba ocupado y el reloj no pudo avanzar. Con withContext(Dispatchers.Default), los cinco tick salen mientras el cálculo corre, antes de fin del cálculo: el hilo de runBlocking queda libre para atenderlos. Es la misma diferencia que ves en la app entre el botón que congela y el que no.

Respuesta a «para pensar»: solo cuando la pausa terminó, un segundo después de lanzarlo. Antes, el hilo estaba ocupado y el reloj ni había empezado a contar. En el móvil, ese segundo sería un segundo sin atender un solo toque.

Paso · Instala las corrutinas en :app

Objetivo: que :app declare kotlinx-coroutines-core, la misma librería que instalaste en :torneo, y que confirmes que lifecycleScope ya está disponible.

Es la misma receta, en corto. La explicación completa de la instalación de una librería está en Los ficheros de Gradle de tu proyecto, y la de las corrutinas en concreto, en Corrutinas y suspend. Lo nuevo hoy es que la dependencia va en otro módulo.

Gradle Scripts
build.gradle.kts (Module :app)se modifica: dependencies
El fichero que se modifica en la instalación, tal como lo ves en la vista Android. El catálogo no cambia.

1 · Qué instalas y por qué ahora. La pantalla de hoy usa runBlocking, Dispatchers, withContext y launch: todo eso es de kotlinx-coroutines-core. Hasta ahora solo :torneo la tenía declarada.

2 · Por qué hace falta declararla en :app si :torneo ya la tiene. Porque :torneo la instaló con implementation, y implementation no se transmite: la usa :torneo por dentro y :app no puede apoyarse en ella como suya (lo viste en La Activity). Declarándola, :app usa la misma versión que :torneo (la 1.11.0) y deja escrito en su build.gradle.kts que usa corrutinas directamente. (No es que sin la línea no compile: otra librería que :app ya usa trae las corrutinas por su cuenta, pero con la versión que ella eligió, la 1.9.0, y no la tuya. Esa otra librería es parte de AndroidX, el nombre de las librerías de Google para Android, también llamadas Jetpack; sus import empiezan por androidx, como viste en La Activity.)

3 · La versión y la librería: ya las tienes. Abre Gradle Scripts → libs.versions.toml (Version Catalog "libs"): tienes kotlinxCoroutinesCore en [versions] y kotlinx-coroutines-core en [libraries], porque las escribiste tú en Corrutinas y suspend. Un catálogo es compartido por todos los módulos: no hay que repetir nada. No hay plugin, así que [plugins] y build.gradle.kts (Project: Marcador) tampoco se tocan.

4 · La dependencia, en el módulo que la usa. Abre Gradle Scripts → build.gradle.kts (Module :app) y añade una línea dentro de dependencies { … }, junto a las demás implementation, por ejemplo justo debajo de implementation(project(":torneo")):

build.gradle.kts (Module :app): la línea nueva
implementation(libs.kotlinx.coroutines.core)

implementation, porque la usa el código de la app y no solo los tests. libs.kotlinx.coroutines.core es el alias del catálogo con los guiones convertidos en puntos.

5 · lifecycleScope ya está instalado. Hoy también usas lifecycleScope, que viene de la librería lifecycle-runtime-ktx. No tienes que instalarla: la plantilla de Android Studio la instaló al crear el proyecto. Compruébalo en el mismo build.gradle.kts (Module :app): hay una línea implementation(libs.androidx.lifecycle.runtime.ktx).

6 · Sync Now, y comprueba. Pulsa Sync Now: debe terminar sin errores rojos (la primera vez puede descargar algo). Gradle puede listar lo que usa :app de dos maneras: debugCompileClasspath es lo que ve el compilador al compilar la versión debug, y debugRuntimeClasspath es lo que lleva la app al ejecutarse. Comprueba primero qué versión usa ahora :app al compilar, en la Terminal (View → Tool Windows → Terminal):

./gradlew :app:dependencies --configuration debugCompileClasspath

En la salida, las líneas de primer nivel (las que empiezan por +--- o \---) deben incluir org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0. Si la salida es larga, puedes filtrarla: añade | grep "^+--- org.jetbrains.kotlinx" al final. La barra | pasa la salida de un comando a grep, que deja solo las líneas con ese texto; el ^ del principio significa «que la línea empiece por».

7 · La otra librería de corrutinas, kotlinx-coroutines-android. En Dispatchers y withContext viste el error Module with the Main dispatcher is missing y que Dispatchers.Main, el hilo de la interfaz, lo aporta esa librería aparte. Aquí no hay que instalarla: las librerías de AndroidX que ya usa :app la traen, y por eso Dispatchers.Main y lifecycleScope funcionan sin que escribas nada más.

Paso · Un cálculo que congela y otro que no

Objetivo. Añadir a MainActivity un botón que calcula bloqueando (ANR) y otro que calcula bien. Al terminar, el primer botón provoca el diálogo del ANR y el segundo deja la pantalla viva.

app → kotlin+java → com.sebastiannarvaez.marcador
MainActivity.ktse modifica
Un solo fichero cambia en este paso, tal como lo ves en la vista Android.

Abre MainActivity.kt con doble clic. Dónde va cada trozo (obligatorio; el código está debajo):

  • Los imports nuevos van con los demás (mutableStateOf junto a mutableIntStateOf).
  • La propiedad y las dos funciones nuevas, antes de onCreate.
  • Los tres elementos nuevos (Button, Button, Text), dentro de la Column, tras Text("Marcador") y antes de los dos botones de gol de la lección anterior.
  • La constante SIMULACIONES, en el companion object que ya tienes junto a CLAVE_GOLES. El resto no cambia.

Piezas nuevas de este paso:

  • runBlocking { … } y measureTimeMillis { … }: ya las conoces de Corrutinas y suspend. La primera espera un resultado bloqueando el hilo actual; la segunda mide cuánto tarda un bloque, en milisegundos.
  • "%.1f".format(p * 100): convierte un número en texto con una sola cifra decimal (%.1f quiere decir «número con decimales, con una cifra tras la coma»). Aquí, el porcentaje. Ojo: el formato usa la coma o el punto decimal del idioma del dispositivo, así que con el emulador en español verás 60,0% y en inglés 60.0%. Es el mismo número.
  • mutableStateOf("…"): como mutableIntStateOf, pero para cualquier tipo (aquí, un String); al cambiar, la pantalla se repinta.
  • lifecycleScope: ver arriba.

Esas piezas, con otros datos que los de Marcador:

ejemplo mínimo: guardar un texto, medir y formatear (con comentarios)
private var media by mutableStateOf("sin calcular")     // mutableStateOf: un valor observable de cualquier tipo (String)
 
private fun calcularMedia() {
    val ms = measureTimeMillis {                         // mide lo que tarda el bloque; devuelve un Long en ms
        val m = runBlocking { mediaDeGoles(1_000) }      // espera al resultado BLOQUEANDO el hilo actual
        media = "media: ${"%.2f".format(m)}"             // "%.2f": dos decimales (1.5 se escribe "1.50")
    }
    Log.d("Prueba", "tardó $ms ms")                      // una línea en Logcat con lo que tardó
}

Intenta escribirlo tú, en este orden:

  1. Dos funciones que calculan la probabilidad de victoria del local en el partido de ejemplo, con cien millones de simulaciones, y dejan el resultado en un texto de la pantalla como porcentaje con un decimal: calcularBloqueando, que espera al resultado en el hilo principal y anota en Logcat cuántos milisegundos tardó, y calcularEnSegundoPlano, que primero pone «calculando...» y hace el cálculo en un hilo de fondo.
  2. En la pantalla, debajo del título: un botón para cada cálculo, «Calcular (BLOQUEA: ANR)» y «Calcular (en segundo plano)», y el texto del resultado (empieza en «sin calcular»).

Debes ver «victoria local: 60.0%» (o 60,0%) al final de cualquiera de los dos cálculos que llegue a terminar. Cuando termines, abre los bloques y compara.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código

Los imports nuevos de MainActivity.kt (dos bloques, porque entre ellos hay imports que ya tenías):

app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
import androidx.compose.runtime.mutableStateOf
app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
import androidx.lifecycle.lifecycleScope
import com.sebastiannarvaez.marcador.torneo.probabilidadDeVictoria
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlin.system.measureTimeMillis

La propiedad y las dos funciones:

app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
    private var resultadoDelCalculo by mutableStateOf("sin calcular")
 
    // EL BUG (ANR): el hilo principal es el UNICO que atiende los toques y dibuja
    // la pantalla. Si tarda mas de ~5 s en volver de un trabajo, Android muestra
    // «La aplicacion no responde». `runBlocking` NO cambia de hilo: bloquea el
    // actual hasta que termina lo de dentro (y `probabilidadDeVictoria` es suspend,
    // pero suspend no significa «en otro hilo»: corre donde la llamen).
    //
    // Para reproducirlo: pulsa el boton y, MIENTRAS esta bloqueado, toca la pantalla
    // varias veces. A los ~5 s aparece el dialogo del ANR.
    private fun calcularBloqueando() {
        val ms = measureTimeMillis {
            val p = runBlocking { probabilidadDeVictoria(Ejemplo.rayoContraToros, SIMULACIONES) }
            resultadoDelCalculo = "victoria local: ${"%.1f".format(p * 100)}%"
        }
        anotar("cálculo bloqueante: $ms ms")
    }
 
    // EL ARREGLO: `lifecycleScope` es un alcance atado a la Activity: se cancela
    // solo en onDestroy. `launch` arranca en el hilo principal, pero `withContext(
    // Dispatchers.Default)` mueve SOLO el calculo a un hilo de fondo y vuelve al
    // principal con el resultado. Mientras tanto la pantalla sigue viva.
    private fun calcularEnSegundoPlano() {
        resultadoDelCalculo = "calculando..."
        lifecycleScope.launch {
            val p = withContext(Dispatchers.Default) {
                probabilidadDeVictoria(Ejemplo.rayoContraToros, SIMULACIONES)
            }
            resultadoDelCalculo = "victoria local: ${"%.1f".format(p * 100)}%"
        }
    }

Los elementos nuevos de la pantalla y la constante:

app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
                    Text("Marcador")
                    Button(onClick = { calcularBloqueando() }) {
                        Text("Calcular (BLOQUEA: ANR)")
                    }
                    Button(onClick = { calcularEnSegundoPlano() }) {
                        Text("Calcular (en segundo plano)")
                    }
                    Text(resultadoDelCalculo)
app/src/main/java/com/sebastiannarvaez/marcador/MainActivity.kt
    private companion object {
        const val CLAVE_GOLES = "golesQueSobreviven"
        // Con 100 millones de vueltas el calculo tarda decenas de segundos: de sobra para un ANR.
        const val SIMULACIONES = 100_000_000
    }

Provoca el ANR

Con el emulador arrancado y el selector de configuración en app, pulsa Run: Android Studio compila, reinstala la app encima de la anterior y la reabre. Antes de empezar, vacía Logcat desde la Terminal de Android Studio (o cualquier terminal): adb logcat -c. Las órdenes de adb de esta lección (logcat e input tap, entre otras) están en la Chuleta de adb.

  1. Toca Calcular (BLOQUEA: ANR).
  2. Mientras está bloqueada, toca varias veces la pantalla, con el ratón sobre el emulador o con adb shell input tap 500 700 (toca el punto de la pantalla que está 500 píxeles a la derecha y 700 hacia abajo), cuatro veces, con 2 segundos entre cada una. Sin toques no hay ANR: Android lo declara cuando un evento de entrada lleva 5 segundos sin atenderse.
  3. A los 5 segundos aparece el diálogo. En un emulador en inglés: «Marcador isn't responding», con dos opciones, Close app y Wait (en un sistema en español los textos serán otros). Cierra con «Close app» y vuelve a abrir la app desde su icono en la lista de aplicaciones del emulador. Si el cálculo termina antes de que cierres el diálogo (en un ordenador muy rápido), puede retirarse solo; el aviso queda igualmente en Logcat, como verás ahora.

Busca los mensajes del ANR con adb logcat -d -s ActivityManager (ActivityManager es la etiqueta con la que el propio Android escribe sus avisos sobre las apps; o, en la ventana Logcat de Android Studio, con el filtro tag:ActivityManager). Verás algo como esto (sin fecha ni pids; los códigos como 14dc12e cambian en cada ejecución):

E ActivityManager: ANR in com.sebastiannarvaez.marcador (com.sebastiannarvaez.marcador/.MainActivity)
E ActivityManager: Reason: Input dispatching timed out (14dc12e com.sebastiannarvaez.marcador/com.sebastiannarvaez.marcador.MainActivity (server) is not responding. Waited 5005ms for MotionEvent).

Fíjate en Waited 5005ms for MotionEvent: es el toque que nadie atendió. Con 100 millones de vueltas el cálculo es lo bastante largo para que el sistema avise de ANR. Si en tu máquina acaba antes de 5 segundos, no habrá diálogo: para la prueba, añade un cero a SIMULACIONES y quítalo después.

Ahora sin congelar

Toca Calcular (en segundo plano): la línea dice calculando... y, pasado un rato que depende de tu máquina, muestra victoria local: 60.0% (o 60,0% con el emulador en español). Mientras tanto, toca Gol (sobrevive), el botón de la lección anterior: el contador sube con cada toque. La pantalla está viva porque el cálculo corre en otro hilo.

Punto de control

  • build.gradle.kts (Module :app) tiene implementation(libs.kotlinx.coroutines.core), Sync Now termina sin errores rojos y la comprobación de debugCompileClasspath muestra la versión 1.11.0 de corrutinas.
  • Run reinstala la app sin errores y la pantalla tiene los dos botones de cálculo y la línea «sin calcular».
  • «Calcular (BLOQUEA: ANR)» seguido de toques provoca el diálogo, y Logcat (tag:ActivityManager) tiene Waited 5005ms for MotionEvent (el número exacto varía un poco).
  • «Calcular (en segundo plano)» deja la pantalla viva y acaba mostrando victoria local: 60.0% (60,0% en español).

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Qué hace el hilo principal de una app Android?

  2. 2.Llamas a una función suspend pesada dentro de runBlocking en un onClick. ¿Qué ocurre?

  3. 3.Quieres calcular algo pesado y mostrar el resultado sin congelar la pantalla. ¿Qué encaja?

  4. 4.¿Cuándo declara Android un ANR por un toque sin atender, según lo que has visto y leído en esta lección?

  5. 5.:torneo instala las corrutinas con implementation. ¿Qué pasa con :app, que depende de :torneo?

Faltan 5 de 5