wandres.dev
RENDIMIENTO DE APPS · arranque, memoria, batería

Tiempo de arranque: las dos mitades del lanzamiento

El lanzamiento es el único código que todos los usuarios ejecutan siempre. Esta lección disecciona sus dos mitades —lo que ocurre antes de `main`, gobernado por `dyld` y el enlazado dinámico, y lo que ocurre después, gobernado por tu código— explica qué trabajo jamás debe colarse ahí, fija el objetivo realista frente al vigilante del sistema y muestra cómo medirlo con signposts y `MetricKit` en vez de con la impresión de que va rápido.

⏱ 19 min

Hay una asimetría curiosa en el rendimiento de una aplicación: casi ninguna pantalla la visitan todos los usuarios, casi ninguna función se ejecuta en todas las sesiones, pero el lanzamiento ocurre siempre, en todos los dispositivos, varias veces al día y en el peor momento posible, que es cuando la persona ya ha decidido que quiere algo y todavía no lo tiene. Esa asimetría convierte al arranque en la ruta caliente por excelencia y explica por qué el coste de un descuido ahí no se parece al de un descuido en cualquier otro sitio: un trabajo de doscientos milisegundos escondido en una pantalla secundaria es una molestia ocasional; los mismos doscientos milisegundos en el arranque son un impuesto que se cobra en cada sesión de cada usuario durante toda la vida del producto. Entender el lanzamiento es, antes que nada, entender que se compone de dos mitades con dueños distintos, y que la mayoría de los equipos solo sabe operar sobre una de ellas.

🎯 Al terminar esta lección sabrás
  • Separar las fases previas y posteriores a main y saber quién gobierna cada una.
  • Distinguir arranque en frío, templado y reanudado, y cuál define el presupuesto.
  • Identificar el trabajo que nunca debe ejecutarse antes del primer fotograma.
  • Instrumentar el arranque con signposts y MetricKit en lugar de estimarlo.

Antes de main: el territorio de dyld

Cuando el usuario toca el icono, tu código todavía tarda en existir. El sistema crea el proceso y cede el control al enlazador dinámico, dyld, que debe realizar una secuencia de trabajos antes de poder invocar tu punto de entrada. Primero mapea el ejecutable y resuelve el cierre de bibliotecas dinámicas del que depende, cargando cada una y, recursivamente, las suyas. Después aplica el desplazamiento de direcciones que impone la aleatorización del espacio de memoria y resuelve los enlaces simbólicos entre imágenes, un trabajo proporcional al número de símbolos exportados e importados. A continuación inicializa el entorno de ejecución de Objective-C, registrando clases y categorías, y finalmente ejecuta los inicializadores estáticos: los métodos +load, los constructores de C con atributo de inicialización y los objetos globales de C++ con constructor no trivial.

Ninguna de esas etapas aparece en tu código y todas se pagan en tu tiempo. La consecuencia práctica más importante es que el coste previo a main es una función casi lineal del número de bibliotecas dinámicas enlazadas. Un proyecto con seis marcos propios y cuatro dependencias de terceros distribuidas como marcos dinámicos paga diez cargas, con sus resoluciones simbólicas y sus inicializadores; el mismo código enlazado estáticamente paga una sola imagen mucho mayor cuyos símbolos ya están resueltos en tiempo de compilación. La regla heurística que ha aguantado bien el paso de los años es que las bibliotecas dinámicas propias deben contarse con los dedos de una mano, y que la modularización agresiva del código fuente no obliga a modularizar el artefacto binario: se pueden tener veinte paquetes de SwiftPM y un único binario si se enlazan estáticamente.

⚠️
Los inicializadores estáticos son código que corre sin que nadie lo llame

Un método +load de Objective-C o un objeto global de C++ con constructor se ejecutan durante la carga de la imagen, antes de main, en un orden que no controlas y sin posibilidad de diferirlos. Es el mecanismo con el que muchos kits de terceros se auto-registran, y también la razón por la que integrar un analizador de eventos añade a veces decenas de milisegundos que ningún perfilado de tu código explica. La alternativa correcta, cuando el código es tuyo, es la inicialización perezosa: un static let de Swift se materializa en el primer acceso mediante dispatch_once, no durante la carga.

Después de main: el territorio de tu código

La segunda mitad empieza cuando dyld invoca tu punto de entrada y termina cuando el sistema presenta el primer fotograma con contenido real. Aquí sí mandas tú, y aquí es donde se acumulan casi todos los desastres evitables. La secuencia es conocida: se construye la instancia de la aplicación, se entregan las llamadas de ciclo de vida del delegado o el equivalente en el ciclo de vida de SwiftUI, se instancia la escena, se construye la jerarquía de vistas de la primera pantalla, se calcula su distribución, se renderiza y se envía a componer.

flowchart LR
A[Toque en el icono] --> B[dyld carga imagenes]
B --> C[Rebase y enlace de simbolos]
C --> D[Runtime ObjC e inicializadores]
D --> E[main y ciclo de vida]
E --> F[Construccion de la primera pantalla]
F --> G[Primer fotograma con contenido]
G --> H[App utilizable de verdad]

El diagrama esconde una distinción que conviene explicitar porque separa a los equipos que miden bien de los que se engañan. El primer fotograma no es lo mismo que la app utilizable. Una pantalla que aparece instantáneamente llena de esqueletos grises y tarda dos segundos en poblarse ha optimizado la métrica sin optimizar la experiencia; a veces eso es exactamente lo correcto, porque la percepción de progreso vale mucho, y a veces es una forma elegante de mentirse. La disciplina consiste en medir ambos instantes: el primer fotograma, que es lo que el sistema observa, y el momento en que la pantalla contiene datos con los que se puede actuar, que es lo que el usuario observa.

import OSLog

let registro = Logger(subsystem: "com.ejemplo.app", category: "arranque")
let firmante = OSSignposter(logger: registro)

@main
struct MiApp: App {
    let estado = firmante.beginInterval("lanzamiento")

    var body: some Scene {
        WindowGroup {
            PantallaInicial()
                .task {
                    await modelo.cargarDatosCriticos()
                    firmante.endInterval("lanzamiento", estado)
                }
        }
    }
}

Los signposts son la herramienta correcta aquí porque producen intervalos con nombre que Instruments dibuja alineados con el resto de las pistas, de modo que el intervalo de lanzamiento se puede leer sobre la traza de llamadas, la de asignaciones y la de actividad de disco a la vez. Un cronómetro manual da un número; un signpost da un número con contexto causal.

El objetivo realista y el límite duro

Hay dos cifras que conviene tener en la cabeza y que no significan lo mismo. La primera es la orientación clásica de Apple: alrededor de cuatrocientos milisegundos hasta el primer fotograma en un arranque en frío. No es una norma verificable ni un umbral de rechazo, es el punto por debajo del cual la aparición se percibe como inmediata porque coincide aproximadamente con la duración de la animación con la que el sistema expande el icono. La segunda cifra es un límite duro y sí tiene consecuencias: el vigilante del sistema termina el proceso si el lanzamiento se alarga demasiado, con un margen del orden de veinte segundos, y ese cierre se contabiliza como un fallo con un código de excepción propio en los informes de diagnóstico.

❄️

En frío

Nada del proceso está en memoria y las páginas del ejecutable ni siquiera están en la cache del sistema de archivos. Es el caso más lento, el más variable y el único que debe usarse para fijar el presupuesto.

🌤️

Templado

El proceso murió pero buena parte de sus páginas siguen en la cache. Mide más rápido y por eso es el escenario que produce mediciones optimistas cuando se repite el lanzamiento cinco veces seguidas.

♻️

Reanudado

El proceso vive en segundo plano y solo vuelve al primer plano. Aquí no hay lanzamiento en sentido estricto, pero sí hay un coste de restauración que muchas apps ignoran y el usuario percibe igual.

Entre ambas cifras hay un territorio que conviene nombrar porque es donde se juega la reputación del producto. Por debajo del medio segundo el lanzamiento se percibe como inmediato y nadie lo comenta. Entre medio segundo y un segundo se percibe la espera pero se tolera sin fricción. A partir de dos segundos aparece la atribución consciente, que es el momento en que el usuario deja de esperar a la app y empieza a formarse una opinión sobre ella. Y más allá de los cinco segundos la conducta cambia: la gente bloquea el teléfono, cambia de aplicación o abandona la tarea, y ese abandono se mide después como una caída de retención que nadie relaciona con el arranque.

Medir en frío exige método: reiniciar el dispositivo entre tomas o, más práctico, usar la plantilla de lanzamiento de Instruments, que automatiza una serie de arranques limpios y descarta el primero. Y exige hardware representativo, porque el dispositivo de desarrollo suele ser el más rápido del catálogo mientras que la base instalada real está poblada de modelos de cuatro o cinco años. La regla de oro es no fijar objetivos con la mediana de tu equipo sino con el percentil noventa de tu base instalada, que es donde vive el usuario que se plantea desinstalar.

El arranque es el único sitio donde la pereza no es una optimización prematura

Existe un consenso profesional, sano en general, que dice que optimizar antes de medir es un error. En el arranque ese consenso se rompe, y merece la pena entender exactamente por qué, porque el argumento generaliza a otros dominios. La razón no es que el arranque sea especialmente lento, sino que es el único punto del programa donde el trabajo es estructuralmente acumulativo y de propiedad difusa: cada equipo que añade una función tiene un incentivo local para inicializar su parte cuanto antes —así nunca falla, así no hay que pensar en el orden— y ninguno paga individualmente el coste agregado. Es una tragedia de los comunes con forma de milisegundos. Cuando por fin alguien mide, encuentra treinta contribuciones de quince milisegundos cada una, ninguna culpable, todas juntas responsables de un segundo. Ese patrón no se arregla perfilando, porque el perfilado señala síntomas y aquí el problema es el reparto de incentivos. Se arregla con una regla arquitectónica que se aplica antes de medir y sin excepciones: nada se inicializa en el arranque salvo lo que la primera pantalla necesita para dibujar. Todo lo demás —la telemetría, la base de datos secundaria, la sincronización, los kits de terceros, la migración, las cachés— se materializa en su primer uso o en una tarea de baja prioridad posterior al primer fotograma. La regla es deliberadamente más estricta de lo que un análisis coste-beneficio caso por caso justificaría, y esa es precisamente su virtud: convierte una decisión negociable, que treinta personas negociarán treinta veces a favor de sí mismas, en un invariante que se revisa en un pull request sin discutir. La pereza deja de ser una técnica de optimización y pasa a ser la política por defecto del ciclo de arranque.

Qué diferir y cómo verificarlo en producción

Traducida a decisiones concretas, la política anterior desplaza del arranque cinco familias de trabajo. Las lecturas de disco que no alimentan la primera pantalla, incluidas las preferencias voluminosas y los ficheros de configuración. La apertura del contenedor persistente cuando la pantalla inicial no muestra datos persistidos, que en SwiftData o Core Data puede costar decenas de milisegundos y mucho más si hay una migración pendiente. Las peticiones de red síncronas, que además introducen una varianza descomunal porque dependen de una red que no controlas. El registro de kits de terceros, que casi siempre admite una llamada explícita y diferida en lugar del auto-registro. Y las migraciones de formato, que deben ejecutarse fuera del camino crítico y con una pantalla que informe si son largas.

// Fase 1: solo lo que la primera pantalla necesita para dibujar.
// Fase 2: todo lo demas, despues del primer fotograma y sin bloquear.
func iniciarServiciosDiferidos() {
    Task.detached(priority: .background) {
        await Telemetria.compartida.arrancar()
        await Sincronizador.compartido.programarPrimerCiclo()
        await CacheDeImagenes.compartida.podarEntradasCaducadas()
    }
}

La prioridad de esa tarea importa tanto como su existencia: lanzarla con prioridad predeterminada la pone a competir por los mismos núcleos que están construyendo la interfaz, y el resultado es que el primer fotograma sale igual de tarde aunque el código parezca diferido. Con prioridad de fondo, el planificador la relega y el efecto es el buscado.

Hay una variante del mismo error que se detecta peor porque el código parece asíncrono y no lo es en la práctica. Un servicio que se declara como una constante estática y se toca desde la construcción de la primera pantalla se materializa en ese momento, en el hilo principal, con todo su coste; la pereza del lenguaje solo difiere la inicialización hasta el primer acceso, y si el primer acceso está en el camino crítico no ha diferido nada. Lo mismo ocurre con una tarea marcada como asíncrona cuyo cuerpo ejecuta trabajo síncrono sobre el actor principal: el await cede el turno pero el trabajo sigue ocupando el mismo hilo. La comprobación práctica consiste en abrir el perfilado de llamadas filtrado por el hilo principal durante el intervalo de lanzamiento y verificar que no aparece ahí nada que no sea construcción de vistas y distribución.

💡
Una pantalla intermedia bien elegida vale más que cien milisegundos

Cuando el arranque exige trabajo inevitable —una migración, una restauración de sesión, una descarga de configuración— la respuesta correcta no siempre es acelerarlo. Presentar de inmediato la estructura de la pantalla con marcadores de posición y poblarla después reduce el tiempo hasta el primer fotograma a casi nada y, lo que importa más, convierte una espera opaca en una espera con progreso visible. La percepción de duración depende tanto de la retroalimentación como del reloj. La condición para que esto sea honesto y no un truco es que el esqueleto se sustituya por contenido real en un plazo corto y que exista un camino claro si falla.

Una precisión sobre el reparto: no todo lo que se difiere puede diferirse igual. Conviene distinguir tres categorías. Lo imprescindible, que la primera pantalla necesita para dibujarse y por tanto se queda. Lo necesario pronto, como la sesión del usuario o la configuración remota, que se lanza justo después del primer fotograma con prioridad baja y cuya ausencia momentánea la interfaz debe saber representar. Y lo prescindible, que puede esperar minutos o incluso a la siguiente ejecución.

Queda la parte que ningún laboratorio sustituye: el arranque en producción. MetricKit entrega a diario un informe agregado por dispositivo con histogramas de tiempo de lanzamiento, distinguiendo el arranque en frío del retorno desde segundo plano, y lo hace con la distribución real de hardware, de estados térmicos y de presión de memoria de tus usuarios. Es la única fuente que responde a la pregunta que de verdad importa, que no es cuánto tarda tu app en tu mesa sino cuánto tarda en el percentil noventa del mundo.

import MetricKit

final class ReceptorDeMetricas: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXMetricPayload]) {
        for carga in payloads {
            guard let lanzamiento = carga.applicationLaunchMetrics else { continue }
            // Histogramas de arranque en frio y de reanudacion, listos para enviar
            let frio = lanzamiento.histogrammedTimeToFirstDraw
            enviarAlBackend(frio.bucketEnumerator)
        }
    }
}
📝
Lo esencial

El lanzamiento tiene dos mitades: la previa a main, dominada por dyld y proporcional al número de bibliotecas dinámicas e inicializadores estáticos, y la posterior, que es tu código construyendo la primera pantalla. El objetivo razonable ronda los cuatrocientos milisegundos hasta el primer fotograma en frío; el vigilante del sistema mata alrededor de los veinte segundos. Mide en frío, en hardware representativo y por percentiles, no por medias. Y aplica el invariante: en el arranque solo vive lo que la primera pantalla necesita para dibujarse.

⚔️ Auditar tu propio arranque
  1. Cuenta las bibliotecas dinámicas que enlaza tu app y clasifícalas en propias y de terceros; convierte a enlazado estático las propias que puedas y vuelve a medir.
  2. Instrumenta con OSSignposter dos intervalos distintos: hasta el primer fotograma y hasta que la pantalla tiene datos accionables. Compara la diferencia.
  3. Ejecuta la plantilla de lanzamiento de Instruments sobre el dispositivo más antiguo que soportes y anota el percentil noventa de diez arranques en frío.
  4. Localiza todo el trabajo del arranque que no alimenta la primera pantalla y muévelo a una tarea de prioridad de fondo posterior al primer fotograma.
  5. Suscríbete a MetricKit, recoge una semana de histogramas y compara la distribución real con la que medías en tu mesa.