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

Memoria: el límite real, la huella y quién te mata

Una app iOS no compite por la RAM del dispositivo sino contra un límite por proceso que nadie documenta y que el sistema aplica sin avisar. Esta lección explica la contabilidad que de verdad cuenta —huella, páginas sucias y memoria comprimida—, por qué las imágenes son la causa dominante de terminación, cómo se comporta `jetsam` bajo presión y qué evidencia deja para que puedas diagnosticarlo.

⏱ 19 min

Hay una clase de fallo que desconcierta a los equipos la primera vez que se lo encuentran: la app desaparece de golpe, sin excepción, sin registro de error, sin pila de llamadas que apunte a nada, y en el panel de fallos aparece agrupada bajo un código que no se parece a ningún fallo de programación conocido. No es un fallo de programación en el sentido habitual; es una decisión administrativa del sistema operativo, que ha calculado que tu proceso consume más de lo que le corresponde y lo ha terminado para proteger al resto. Entender ese mecanismo exige abandonar dos ideas intuitivas que casi todo el mundo trae de otros entornos: que la memoria disponible es la del dispositivo, y que lo que ocupa memoria es lo que uno reserva explícitamente. Ninguna de las dos es cierta en iOS, y de esa discrepancia nacen la mayoría de las terminaciones por memoria que se ven en producción.

🎯 Al terminar esta lección sabrás
  • Situar el límite real de memoria de un proceso y cómo varía con el contexto.
  • Distinguir huella, páginas sucias, páginas limpias y memoria comprimida.
  • Explicar por qué las imágenes dominan el consumo y dónde se contabilizan.
  • Diagnosticar una terminación por memoria con la evidencia que deja el sistema.

El límite no es la RAM del dispositivo

iOS no usa fichero de intercambio en el sentido tradicional: no hay disco donde volcar páginas anónimas sucias para recuperarlas después. Esa decisión de diseño, tomada por razones de longevidad del almacenamiento y de latencia, tiene una consecuencia directa y severa: cuando la memoria escasea, el sistema no puede paginar, solo puede recuperar páginas limpias y, si no basta, terminar procesos. El componente que ejecuta esa terminación se conoce como jetsam, y opera con una jerarquía de prioridades donde los procesos en segundo plano caen antes que los de primer plano, y donde cada proceso tiene además un límite individual que no puede superar aunque haya memoria libre en el sistema.

Ese límite individual es el número que importa y no está documentado como una constante. Depende del modelo de dispositivo, de la versión del sistema y del contexto de ejecución, y como orden de magnitud se sitúa entre la mitad y los dos tercios de la memoria física para una app en primer plano. Lo que sí es estable es la jerarquía relativa entre contextos, y esa jerarquía explica la mayoría de los desastres.

📱

App en primer plano

El contexto más generoso. Aun así, muy lejos de la RAM instalada, y el margen se estrecha cuando hay otras apps activas o el dispositivo está bajo presión.

🌙

App en segundo plano

Mismo límite nominal pero prioridad de terminación mucho peor. Reducir la huella al pasar a segundo plano no es cortesía, es supervivencia.

🧩

Widget

Del orden de decenas de megabytes. Decodificar una foto de cámara dentro de un widget lo termina con práctica certeza.

🔔

Extensiones

Notificación y compartición viven en presupuestos estrechos y distintos entre sí. Es el sitio clásico donde el código que funcionaba en la app se cae sin cambiar una línea.

Existe una función del sistema que devuelve cuánta memoria le queda al proceso antes de alcanzar su límite, os_proc_available_memory, y es la única forma programática honesta de conocer el margen. Usarla para decidir el tamaño de una cache o para abortar una operación costosa en una extensión es legítimo y a veces imprescindible; usarla para tomar decisiones de arquitectura en caliente suele indicar que el problema está mal planteado más arriba.

⚠️
El simulador no aplica ninguno de estos límites

Tu Mac tiene decenas de gigabytes, memoria virtual con intercambio y ningún jetsam vigilando. Un código que reserva ochocientos megabytes funciona ahí con total normalidad y termina el proceso en un iPhone de gama media a los tres segundos. Cualquier afirmación sobre memoria hecha desde el simulador carece de valor probatorio: la medición debe hacerse en hardware, y preferiblemente en el dispositivo más modesto que soportes.

Huella, páginas sucias y compresión

La contabilidad que aplica el sistema no es la que uno espera. Lo relevante no es cuánta memoria virtual está mapeada sino la huella del proceso, que agrupa las páginas que el sistema considera atribuibles a tu proceso y que no puede recuperar sin terminarlo. Dentro de esa huella conviene separar tres poblaciones con comportamientos muy distintos.

Las páginas limpias son aquellas cuyo contenido puede reconstruirse desde su origen: código ejecutable mapeado desde el binario, recursos leídos con memoria mapeada, ficheros abiertos con mapeo de solo lectura. Bajo presión el sistema las descarta y las vuelve a leer cuando hacen falta; cuestan latencia, no terminación. Las páginas sucias son las que contienen datos que solo existen en memoria: todo lo que reserva tu programa, los bitmaps decodificados, los búferes. No pueden descartarse y son las que cuentan de verdad. Y entre ambas está la memoria comprimida: cuando la presión sube, el compresor toma páginas sucias poco usadas y las comprime en el propio espacio de memoria, típicamente a la mitad o menos. Eso alivia, pero no es gratis —comprimir y descomprimir consume CPU y energía— y sigue contabilizando en tu huella.

flowchart TD
A[Presion de memoria en el sistema] --> B[Descartar paginas limpias]
B --> C{Suficiente}
C -- No --> D[Comprimir paginas sucias poco usadas]
D --> E{Suficiente}
E -- No --> F[Avisos de memoria a los procesos]
F --> G{Alguien libera}
G -- No --> H[Jetsam termina por prioridad]
H --> I[Segundo plano primero, luego primer plano]

De esa cascada se deducen dos lecciones operativas. La primera es que los avisos de memoria llegan tarde y no siempre llegan: son el penúltimo escalón, no un sistema de alerta temprana, y una arquitectura que depende de reaccionar a ellos ya ha perdido. Vaciar cachés al recibirlos es higiene razonable, pero NSCache ya lo hace por sí sola, que es exactamente la razón por la que conviene usarla en lugar de un diccionario. La segunda es que preferir memoria mapeada sobre memoria reservada cambia la naturaleza del riesgo: leer un fichero grande con mapeo lo convierte en páginas limpias descartables, mientras que cargarlo en un objeto de datos ordinario lo convierte en páginas sucias que te pueden costar el proceso.

Las imágenes, culpable número uno

Si hubiera que atribuir las terminaciones por memoria a una sola causa, sería esta, y la razón es aritmética antes que técnica. Una fotografía de doce megapíxeles ocupa entre uno y tres megabytes comprimida en disco y algo más de cuarenta y seis en memoria una vez decodificada, porque el bitmap de trabajo usa cuatro bytes por píxel. El factor de expansión ronda las veinte veces, y sube a cuarenta cuando la imagen es de gama amplia y se decodifica con dieciséis bits por canal. Una galería que mantenga diez de esas imágenes decodificadas a tamaño completo consume casi medio gigabyte sin que ninguna línea del código parezca sospechosa.

Lo que agrava el diagnóstico es dónde se contabilizan esos bytes. Los bitmaps no viven en el montón de Swift sino en regiones de memoria gráfica gestionadas por el sistema de imagen y de composición, de modo que un perfilado de asignaciones centrado en objetos Swift puede mostrar un montón perfectamente sano mientras la huella real del proceso se acerca al límite. El instrumento adecuado no es el que cuenta objetos sino el que cuenta regiones de memoria virtual, filtrando por las categorías de superficie de imagen.

// La huella real del proceso y el margen que queda antes del limite
import os

func margenDeMemoria() -> (usadaMB: Double, disponibleMB: Double) {
    var info = task_vm_info_data_t()
    var contador = mach_msg_type_number_t(MemoryLayout<task_vm_info>.size / MemoryLayout<natural_t>.size)
    let resultado = withUnsafeMutablePointer(to: &info) {
        $0.withMemoryRebound(to: integer_t.self, capacity: Int(contador)) {
            task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &contador)
        }
    }
    let usada = resultado == KERN_SUCCESS ? Double(info.phys_footprint) : 0
    let disponible = Double(os_proc_available_memory())
    return (usada / 1_048_576, disponible / 1_048_576)
}

El campo relevante es la huella física, no el tamaño residente ni el virtual: es el que jetsam compara contra el límite. Registrar ese valor en los momentos críticos —al entrar en la galería, al abrir el editor, al pasar a segundo plano— y enviarlo con la telemetría convierte un fallo inexplicable en una serie temporal legible.

Junto a las imágenes hay tres fuentes secundarias que conviene descartar antes de acusarlas. Los objetos de datos que cargan un fichero completo cuando bastaría leerlo por tramos o mapearlo; el patrón se reconoce porque el consumo escala con el tamaño del contenido del usuario y no con el de la interfaz. Los conjuntos de resultados de la capa de persistencia que se materializan enteros en lugar de recorrerse por lotes, con el agravante de que los objetos gestionados conservan referencias vivas hasta que el contexto se reinicia. Y los ciclos de retención, que en Swift no se manifiestan como un pico sino como un crecimiento lento y monótono a lo largo de la sesión: cada apertura y cierre de una pantalla deja atrás su modelo, su suscripción y sus cierres capturados. Ese tercer patrón es el único que un perfilado de fugas detecta bien, y es la razón por la que conviene ejecutarlo periódicamente aunque no haya síntomas.

// Recorrer por lotes en lugar de materializar el conjunto completo
var descriptor = FetchDescriptor<Nota>(sortBy: [SortDescriptor(\.fecha)])
descriptor.fetchLimit = 200
var offset = 0
while true {
    descriptor.fetchOffset = offset
    let lote = try contexto.fetch(descriptor)
    if lote.isEmpty { break }
    procesar(lote)
    offset += lote.count
    contexto.rollback()   // suelta los objetos ya procesados
}
La memoria es el único recurso donde el fallo no es tuyo sino de un tercero que decide sobre ti

Conviene detenerse en lo que hace a la memoria estructuralmente distinta de la CPU, del disco o de la red, porque esa diferencia debería reordenar cómo se diseña. Si tu código es lento, el usuario espera; si el disco va cargado, el usuario espera; si la red falla, hay un error que puedes capturar, reintentar y comunicar. En todos esos casos el fallo es local, observable y recuperable: ocurre dentro de tu proceso, tu código se entera y puede decidir qué hacer. La memoria no funciona así. Cuando superas el límite no recibes una excepción, no hay un punto de recuperación, no hay un bloque de manejo de errores que se ejecute: un componente externo, con información que tú no tienes —cuántas apps hay activas, cuánta presión hay ahora mismo, qué prioridad tienes en la jerarquía— toma una decisión unilateral e irreversible sobre tu existencia. Eres el objeto de la decisión, no su sujeto. Esa asimetría tiene una implicación de diseño que va mucho más allá de recortar bitmaps: significa que la memoria no puede gestionarse de forma reactiva, porque no existe el instante en que reaccionar, y por tanto la única estrategia viable es presupuestaria y anticipada. Hay que decidir de antemano cuánto puede ocupar cada subsistema, imponerlo con estructuras que respeten ese techo por construcción —cachés con límite, decodificación a tamaño de destino, lectura por tramos, mapeo en lugar de carga— y verificar la huella en los puntos de máxima presión antes de llegar a producción. Es exactamente el mismo desplazamiento mental que separa la programación con excepciones de la programación con presupuestos en sistemas de tiempo real: cuando el fallo no es capturable, la corrección tiene que demostrarse antes, no gestionarse después.

Qué te mata y cómo enterarte

Las terminaciones por memoria dejan tres rastros distintos y conviene saber leer los tres. El primero es el informe de excepción de recurso, que aparece cuando el proceso supera su límite individual de forma abrupta; su código de excepción identifica el recurso agotado y suele incluir el tamaño alcanzado, lo que da una cota inferior útil. El segundo es el evento de jetsam, que se genera cuando el sistema termina el proceso por presión global; incluye la huella en el momento de la muerte y la prioridad que tenía el proceso, y distingue si estaba en primer o segundo plano. El tercero es agregado y es el más valioso a largo plazo: MetricKit entrega a diario el pico de memoria y la memoria media de la sesión, con la distribución real de dispositivos de tus usuarios.

import MetricKit

func didReceive(_ payloads: [MXMetricPayload]) {
    for carga in payloads {
        guard let memoria = carga.memoryMetrics else { continue }
        // Pico de la sesion y media ponderada, listos para vigilar por version
        registrar(pico: memoria.peakMemoryUsage, media: memoria.averageSuspendedMemory)
    }
}

Hay una asimetría en esa evidencia que conviene conocer para no sacar conclusiones equivocadas. Las terminaciones en primer plano se reportan con bastante fiabilidad y el usuario las percibe como un fallo; las terminaciones en segundo plano son mucho más frecuentes, se reportan peor y el usuario no las percibe como fallo sino como que la app se olvidó de lo que estaba haciendo, perdió el borrador o volvió a empezar desde la primera pantalla. Ese segundo grupo produce quejas que se archivan como defectos de estado o de navegación cuando en realidad son de memoria, y la única forma de identificarlas es registrar la huella en el momento de pasar a segundo plano y correlacionarla con los reinicios de sesión.

Sobre esa base se construye la única disciplina que funciona: fijar un presupuesto explícito de huella máxima por pantalla, medirlo en el dispositivo más modesto que soportes y tratar cualquier aumento entre versiones como una regresión que hay que justificar. Sin ese presupuesto, la memoria crece de forma monótona porque cada función nueva añade su cache, su búfer o su modelo cargado en su totalidad, y nadie es responsable del agregado hasta que el panel de fallos lo hace evidente.

📝
Lo esencial

No hay intercambio: el sistema descarta páginas limpias, comprime sucias y, si no basta, termina procesos. El límite es por proceso, no documentado, del orden de la mitad a dos tercios de la RAM física en primer plano, y mucho más estrecho en widgets y extensiones. Lo que cuenta es la huella física, no el montón de Swift, y las imágenes decodificadas la dominan porque expanden entre veinte y cuarenta veces respecto al fichero. Los avisos de memoria llegan tarde: la gestión tiene que ser presupuestaria y anticipada, nunca reactiva.

⚔️ Medir tu techo real
  1. Registra la huella física en cinco puntos críticos de tu app y anota el máximo en el dispositivo más modesto que soportes.
  2. Abre la pantalla con más imágenes y compara el montón de Swift que reporta el perfilador de asignaciones con la huella total del proceso; explica la diferencia.
  3. Sustituye la carga completa de tu fichero de datos más grande por lectura con memoria mapeada y compara la huella antes y después.
  4. Ejecuta el mismo flujo de imágenes dentro de una extensión de compartición y documenta a partir de qué tamaño se termina el proceso.
  5. Fija un presupuesto de huella por pantalla, añádelo a la telemetría con MetricKit y vigílalo durante dos versiones consecutivas.