wandres.dev
RENDIMIENTO · arranque, R8 y baseline

El arranque: frío, templado y caliente

El intervalo que va desde que el dedo se levanta del icono hasta que aparece contenido real es la única parte de una aplicación que todos los usuarios atraviesan sin excepción, y también la que menos equipos miden con seriedad. Esta lección desmonta los tres regímenes de arranque que el sistema contabiliza por separado, recorre en orden todo lo que ocurre antes de que se ejecute la primera línea de código propio, cataloga el trabajo que jamás debe aparecer en `Application.onCreate` y explica por qué, y establece un protocolo de medición que distingue entre el tiempo hasta el primer dibujo y el tiempo hasta que la pantalla es realmente útil.

⏱ 22 min

Hay una asimetría curiosa en cómo los equipos reparten su atención sobre el rendimiento. Se invierten semanas en optimizar una consulta que se ejecuta cuando el usuario entra en la tercera pestaña de la sección de ajustes, y se ignora por completo el trayecto que absolutamente todos los usuarios recorren absolutamente todas las veces: el arranque. Nadie usa tu aplicación sin arrancarla. Ninguna optimización tiene una cobertura del cien por cien salvo esa. Y sin embargo el arranque es el lugar donde los equipos acumulan deuda con más alegría, porque el coste no se paga en la máquina del desarrollador, que tiene el proceso caliente y el disco en caché, sino en un teléfono de gama media con la memoria llena y la aplicación expulsada hace horas. La distancia entre esas dos experiencias es de un orden de magnitud, y esa distancia es exactamente el tamaño del problema que casi nadie mira.

🎯 Al terminar esta lección sabrás
  • Distinguir el arranque cold start, warm start y hot start según el estado del proceso y de la pila de actividades.
  • Enumerar en orden las fases que el sistema ejecuta antes de que corra la primera línea de código propio.
  • Catalogar el trabajo que nunca debe aparecer en Application.onCreate y justificar cada exclusión.
  • Medir el tiempo hasta el primer dibujo y el tiempo hasta el contenido útil sin engañarse con el resultado.

Tres arranques que el sistema contabiliza por separado

La plataforma no tiene un concepto único de arranque, sino tres regímenes que se distinguen por lo que ya existe en memoria cuando llega la intención de lanzamiento. En el cold start no existe nada: el proceso de tu aplicación no está vivo, el sistema debe crearlo desde cero, cargar tus clases, construir el objeto de aplicación, instanciar la actividad y producir el primer fotograma. Es el caso más caro y el que se lleva casi toda la atención, porque es el que sufre el usuario que abre la aplicación por la mañana después de que el sistema la haya expulsado durante la noche.

El warm start describe la situación intermedia y admite varias formas. La más común es que el proceso siga vivo pero la actividad haya sido destruida, de modo que el sistema se ahorra la creación del proceso y la carga de clases pero debe rehacer la actividad completa y restaurar el estado guardado. Otra variante es que el proceso viva y la actividad también, pero el sistema haya reciclado su jerarquía de vistas. Lo que unifica estos casos es que el trabajo caro de infraestructura ya está hecho y solo queda reconstruir la superficie visible.

El hot start es el escenario del usuario que alterna entre aplicaciones. El proceso está vivo, la actividad está viva, el estado está intacto y lo único que hace el sistema es traer la ventana al primer plano y pedir un redibujado. Aquí el tiempo se mide en decenas de milisegundos y el margen de error del desarrollador es pequeño, salvo que alguien haya tenido la ocurrencia de recargar datos desde el disco en el método de reanudación.

🧊

Cold start

Sin proceso. Creación desde cero, carga y verificación de clases, objeto de aplicación, actividad y primer fotograma. El caso caro.

🌡️

Warm start

Proceso vivo, actividad destruida. Se ahorra la infraestructura pero se rehace la superficie y se restaura el estado.

🔥

Hot start

Todo vivo. Solo se trae la ventana al frente y se redibuja. Cualquier retraso aquí es culpa exclusiva de tu código.

📊

Percentiles, no medias

La media oculta el problema. Lo que degrada la reputación de una aplicación vive en el percentil noventa y cinco del campo.

Conviene fijar el marco de referencia. La consola de publicación considera excesivo un arranque en frío por encima de los cinco segundos, uno templado por encima de los dos y uno caliente por encima de segundo y medio, y esos umbrales no son objetivos de diseño sino líneas rojas administrativas: una aplicación que los roza está muy lejos de ser buena. Más importante que el umbral es la costumbre de mirar percentiles y no medias, porque el arranque tiene una distribución con cola larga y la media la aplana justamente donde duele.

Lo que ocurre antes de tu primera línea

El primer error conceptual consiste en creer que el arranque empieza en tu código. No empieza ahí. Cuando llega la intención de lanzamiento, el sistema bifurca un proceso a partir de una plantilla que ya tiene cargadas las clases del entorno de ejecución y las bibliotecas comunes, compartidas mediante copia al escribir. Ese mecanismo ahorra una cantidad enorme de trabajo, pero solo cubre lo que es común a todas las aplicaciones: todo lo tuyo se paga aparte.

A continuación el entorno de ejecución debe localizar, cargar y verificar tus clases. Aquí aparece el primer coste que escala con el tamaño del proyecto y que resulta invisible en el código: cada clase que se toca durante el arranque debe ser resuelta y verificada, y si no está compilada de antemano, interpretada primero y compilada después. Una jerarquía de inyección de dependencias que instancia con avidez doscientos objetos en el arranque no cuesta lo que cuestan esos constructores, sino lo que cuesta cargar y verificar las doscientas clases correspondientes más sus dependencias transitivas.

Después, y este es el detalle de orden que sorprende a casi todo el mundo, el sistema ejecuta el método de creación de todos los proveedores de contenido declarados en el manifiesto antes de invocar Application.onCreate. Numerosos kits de terceros abusan de esa ventana para inicializarse solos sin pedir permiso ni aparecer en tu código, lo que produce el fenómeno desconcertante de un arranque lento cuyo perfil no muestra ninguna función propia. La biblioteca de arranque de Jetpack existe precisamente para consolidar esa multitud de proveedores en uno solo con un grafo de dependencias explícito.

Solo entonces corre Application.onCreate, luego la creación de la actividad, luego la primera composición o el inflado de la jerarquía de vistas, luego una pasada de medida, colocación y dibujo, y finalmente el sistema entrega el primer fotograma. Ese instante es el que la plataforma registra como mostrado y el que constituye el tiempo hasta el primer dibujo. Desde Android 12 la pantalla de bienvenida la dibuja el sistema siempre, y su condición de permanencia es un arma de doble filo: retenerla oculta el contenido vacío, pero también retrasa el momento en que el usuario ve algo, de modo que usarla para tapar un arranque lento empeora la métrica que dice mejorar.

💡
La primera medición cuesta un comando

Antes de instrumentar nada, arranca la actividad desde la terminal con la opción que espera y reporta tiempos, y observa la línea que el sistema emite cuando considera mostrada la ventana. Ese par de números ya te dice si tienes un problema de arranque o no. Para que la cifra signifique algo hay que matar el proceso antes de cada intento, usar una compilación de publicación y descartar la primera ejecución tras la instalación, porque el entorno de ejecución todavía no ha terminado su optimización en segundo plano y el número saldrá artificialmente malo.

# Arranque en frio medido y repetible
adb shell am force-stop com.ejemplo.app
adb shell am start-activity -W -S com.ejemplo.app/.MainActivity

# Observar el instante en que el sistema considera mostrada la ventana
adb shell logcat -d | grep -E "Displayed|Fully drawn"

El pecado original: Application.onCreate

Hay una razón estructural por la que este método concreto concentra tanto daño: corre en el hilo principal, corre antes de que exista ninguna ventana, y todo lo que ocurre dentro es tiempo durante el cual el usuario mira una pantalla que el sistema dibujó por él. No hay forma de paralelizarlo con nada visible porque todavía no hay nada visible. Es tiempo puro, sin contrapartida perceptiva.

La lista de lo que no debe aparecer ahí es larga y casi siempre la misma. Cualquier lectura de disco, incluida la aparentemente inocente de preferencias compartidas, que además puede bloquear el hilo mientras el fichero se carga. Cualquier petición de red, aunque sea asíncrona, porque su preparación arrastra la inicialización de la pila cliente entera. La construcción avariciosa del grafo de dependencias. La apertura de la base de datos, que implica abrir el fichero, verificar la versión y quizá ejecutar migraciones. La generación de material criptográfico. La inicialización de los kits de analítica, publicidad y registro de fallos, que se presentan siempre como triviales y nunca lo son. Y cualquier trabajo cuya presencia justifiquemos diciendo que solo tarda unos pocos milisegundos, porque la suma de doce cosas que solo tardan unos pocos milisegundos es medio segundo.

El criterio de decisión es sencillo de enunciar y difícil de aplicar con disciplina: en Application.onCreate solo debe ocurrir aquello sin lo cual la primera pantalla no puede dibujarse correctamente. Todo lo demás es diferible, y hay tres formas de diferirlo según su naturaleza. Lo que se necesita a veces se envuelve en una inicialización perezosa que se paga en el primer uso real. Lo que se necesita seguro pero no ahora se lanza a un hilo secundario después del primer fotograma. Y lo que se necesita solo en una pantalla concreta se inicializa en esa pantalla y no antes.

class MiAplicacion : Application() {

    // Perezoso: el coste se paga en el primer uso real, no en el arranque
    val analitica by lazy { Analitica.crear(this) }

    override fun onCreate() {
        super.onCreate()
        // Solo lo imprescindible para pintar la primera pantalla
        Temas.aplicarModoGuardado(this)

        // El resto viaja fuera del hilo principal y despues del primer fotograma
        ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) {
            Registro.iniciar(this@MiAplicacion)
            Sincronizador.programarPeriodica(this@MiAplicacion)
        }
    }
}

Merece la pena insistir en el coste oculto de las clases. Diferir la ejecución de un inicializador no siempre difiere su coste de carga, porque basta con que una referencia de tipo aparezca en una firma resuelta durante el arranque para que la clase deba cargarse. Por eso una refactorización que mueve trabajo a un hilo secundario a veces mejora menos de lo esperado: el trabajo se movió, pero la carga y verificación de las clases implicadas sigue ocurriendo en el camino crítico.

flowchart TD
A[Intencion de lanzamiento] --> B[Bifurcacion desde la plantilla del sistema]
B --> C[Carga y verificacion de clases propias]
C --> D[Creacion de proveedores de contenido declarados]
D --> E[Application onCreate]
E --> F[Creacion de la actividad]
F --> G[Primera composicion o inflado]
G --> H[Medida, colocacion y dibujo]
H --> I[Primer fotograma: tiempo hasta el primer dibujo]
I --> J[Carga de datos reales]
J --> K[Aviso de dibujo completo: tiempo hasta contenido util]
style E fill:#f38ba8,color:#11111b
style I fill:#f9e2af,color:#11111b
style K fill:#a6e3a1,color:#11111b

Medir sin engañarse: primer dibujo y contenido útil

El tiempo hasta el primer dibujo es una métrica necesaria pero insuficiente, y confiar solo en ella conduce a una patología conocida: se optimiza hasta que la ventana aparece muy rápido, pero lo que aparece es un esqueleto vacío que tarda otro segundo en llenarse. El usuario no percibe que la aplicación arrancó rápido; percibe que arrancó y no servía. Por eso existe el aviso explícito de dibujo completo, que tu código invoca en el momento en que la pantalla muestra contenido real, y que define el tiempo hasta contenido útil.

Ese aviso no es un adorno de instrumentación: el sistema lo usa para afinar sus propias heurísticas y las herramientas de medición lo toman como final del intervalo. Emitirlo en el sitio correcto exige una decisión de producto sobre qué significa que la pantalla está lista, y esa decisión es más valiosa que la propia métrica, porque obliga al equipo a acordar cuál es el contenido mínimo que justifica haber abierto la aplicación.

@Composable
fun PantallaInicio(estado: EstadoInicio, actividad: ComponentActivity) {
    // Se avisa cuando hay contenido real, no cuando hay un esqueleto
    LaunchedEffect(estado) {
        if (estado is EstadoInicio.ConDatos) actividad.reportFullyDrawn()
    }
    when (estado) {
        EstadoInicio.Cargando -> Esqueleto()
        is EstadoInicio.ConDatos -> Contenido(estado.elementos)
    }
}

Queda el problema del entorno de medición, que es donde se pierden más horas por confianza mal puesta. Una compilación de depuración no sirve para medir nada: lleva instrumentación, no aplica optimización y usa un modo de compilación distinto. La primera ejecución tras instalar tampoco sirve, porque el sistema aún no ha optimizado el código en segundo plano. Y un dispositivo de gama alta con la memoria libre representa a una minoría de tus usuarios. La medición honesta usa compilación de publicación, mata el proceso entre iteraciones, repite lo suficiente para tener una distribución y contrasta el resultado del laboratorio con los percentiles que llegan del campo.

El arranque es el único contrato síncrono con el usuario

Todo lo demás en una aplicación móvil admite negociación temporal. Una sincronización puede llegar tarde y nadie se entera; una imagen puede cargarse progresivamente; una escritura puede confirmarse de forma optimista y reconciliarse después. El arranque no admite nada de eso, porque es el único momento en que el usuario está detenido, mirando la pantalla, sin poder hacer otra cosa, esperando a que un sistema que él ha invocado le devuelva el control. Esa asimetría lo convierte en el punto donde la ingeniería y la percepción coinciden con menos mediación que en ningún otro sitio del producto. Y explica por qué el arranque es también el mejor termómetro de la salud arquitectónica de un proyecto: no hay forma de tener un arranque rápido con una arquitectura desordenada. Un arranque lento es casi siempre el síntoma visible de acoplamientos que nadie quiso romper, de un grafo de dependencias que se construye entero porque nadie sabe qué partes hacen falta, de kits de terceros que se colaron por la puerta de atrás de los proveedores de contenido, de una jerarquía de clases que crece sin que nadie mida lo que cuesta cargarla. Optimizar el arranque en serio obliga a responder a la pregunta que el equipo lleva años evitando: qué necesita realmente esta aplicación para mostrar su primera pantalla. Y esa respuesta, cuando por fin se escribe, suele revelar que la mitad de lo que se inicializaba con urgencia no hacía falta en absoluto. Por eso el arranque no se arregla con trucos ni con pantallas de bienvenida más bonitas. Se arregla entendiendo el producto lo bastante bien como para saber qué es lo mínimo que debe existir cuando el usuario levanta el dedo del icono.

⚔️ Reconstruye tu arranque desde el primer fotograma
  1. Mide el arranque en frío de tu compilación de publicación con veinte iteraciones y anota la mediana y el percentil noventa, no la media.
  2. Enumera todos los proveedores de contenido que se crean en tu proceso y averigua cuáles pertenecen a kits de terceros que nunca declaraste.
  3. Recorre Application.onCreate línea por línea y clasifica cada instrucción como imprescindible para la primera pantalla, diferible o eliminable.
  4. Coloca el aviso de dibujo completo donde el contenido sea realmente útil y mide la distancia entre esa marca y el primer dibujo.
  5. Repite la medición en el dispositivo más modesto que tengas y justifica cualquier diferencia mayor del doble respecto a tu máquina de desarrollo.