Cómo establecer presupuestos de memoria de la app

Los presupuestos de memoria de las apps permiten que estas declaren un presupuesto de memoria para sí mismas, lo que le indica al sistema que recorte su uso de memoria cuando la app use más de su presupuesto establecido. Esto es especialmente útil para las apps del sistema y empaquetadas, o para las apps orientadas a dispositivos con memoria limitada, en las que el desarrollador conoce el conjunto de trabajo de memoria esperado (la memoria que la app necesita de forma activa para lo que el usuario está haciendo en ese momento) y quiere asegurarse de que su app no use demasiados recursos de RAM compartidos del sistema.

El presupuesto se mantiene equilibrado con la expulsión y el intercambio de memoria para quitar las páginas de memoria que no se usaron recientemente, lo que enfoca el espacio en memoria de la app en su conjunto de trabajo actual. Cuando una app supera su presupuesto declarado, el sistema operativo se orienta específicamente a esa app para recuperar recursos:

  1. Las páginas limpias respaldadas por archivos (como el código inactivo y los recursos asignados) se expulsan primero porque se pueden volver a leer desde el almacenamiento si es necesario.
  2. Las páginas respaldadas por archivos modificadas se vuelven a escribir en el almacenamiento y se expulsan.
  3. Las páginas de memoria anónimas (como las asignaciones de montón) se comprimen y se intercambian a zRAM.

Siempre que el conjunto de trabajo no supere el presupuesto, la app funcionará bien sin usar más memoria de la que tiene asignada. El sistema operativo expulsa la memoria sin usar y comprime las páginas de montón inactivas para intercambiarlas, de modo que las asignaciones de memoria permanezcan limitadas sin finalizar el proceso.

Qué sucede cuando una app excede su presupuesto

Exceder un presupuesto de memoria no hace que el sistema finalice o falle tu app. En cambio, cuando una app usa más memoria de la que permite su presupuesto, el SO encuentra memoria que la app no usó en algún momento y la guarda de forma segura hasta que se vuelva a necesitar. Esto deja espacio para nuevas asignaciones de memoria de la app y mantiene el uso general de la memoria de la app dentro del presupuesto.

Mientras haya margen entre lo que la app necesita en un momento determinado (su conjunto de trabajo) y el presupuesto establecido, la app seguirá ejecutándose con normalidad. Si una app establece un presupuesto menor que su conjunto de tareas, su rendimiento durante el tiempo de ejecución puede disminuir.

Para obtener información sobre cómo el SO hace un seguimiento de la memoria y la recupera, consulta la guía de arquitectura de memoria:

Cómo declarar presupuestos en el manifiesto de Android

Declarar tus presupuestos de memoria en tu AndroidManifest.xml es el método principal y recomendado para definir presupuestos. No requiere código de entorno de ejecución, se aplica de inmediato cuando se inicia el proceso y proporciona un contrato claro para el sistema operativo.

Las declaraciones <memory-budget> se aplican en dispositivos que ejecutan Android 17 QPR2 (nivel de API 37.2) y versiones posteriores. En las versiones anteriores de Android, el analizador de manifiestos de la plataforma ignora de forma segura los elementos XML no reconocidos, por lo que puedes adoptar <memory-budget> sin afectar la retrocompatibilidad.

Declarar un presupuesto de referencia

En la mayoría de las apps, solo es necesario definir un único presupuesto para la aplicación. Declara un elemento <memory-budget> directamente dentro de la etiqueta <application>:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.simpleapp">

    <application
        android:label="@string/app_name">

        <!-- Baseline budget for the application -->
        <memory-budget android:maxMb="256" />

    </application>
</manifest>

Esto establece un presupuesto de memoria residente de 256 MB en todos los procesos y estados del paquete. Cuando el espacio en memoria de la app supera los 256 MB, el sistema operativo recorta las páginas de memoria inactivas con la expulsión y el intercambio.

Varía los presupuestos según el estado del proceso

Una app requiere diferentes cantidades de memoria según su visibilidad para el usuario:

  • Primer plano: El proceso aloja una actividad visible que interactúa con el usuario. Por lo general, este estado tiene la mayor huella debido a la IU y los gráficos activos.
  • Perceptible: El proceso es perceptible para el usuario, pero no aloja una ventana visible (por ejemplo, aloja un servicio en primer plano de reproducción de contenido multimedia, una descarga activa en segundo plano, una navegación paso a paso o un método de entrada activo).
  • Segundo plano: El proceso ejecuta trabajos, receptores o sincronizaciones de datos en segundo plano. Se espera que mantenga una huella mínima.

Puedes declarar varias cláusulas <memory-budget> para que coincidan con estos estados:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.simpleapp">

    <application
        android:label="@string/app_name">

        <!-- Default budget for visible foreground UI -->
        <memory-budget android:maxMb="200" />

        <!-- Tighter budget when playing audio in background -->
        <memory-budget
            android:maxMb="120"
            android:state="perceptible" />

        <!-- Minimal budget when fully in background -->
        <memory-budget
            android:maxMb="48"
            android:state="background" />

    </application>
</manifest>

No se requiere una cláusula base sin android:state. Si solo especificas cláusulas específicas del estado (por ejemplo, android:state="background"), otros estados permanecen sin restricciones por un presupuesto de la app. Cuando se incluye, una cláusula sin android:state actúa como el resguardo predeterminado para los estados no especificados (como el primer plano), que las cláusulas más restrictivas posteriores anulan cuando la app pasa a los estados perceptible o background.

Cuando establezcas el tamaño de los presupuestos para cada estado, apunta al conjunto de trabajo activo de ese estado, en lugar de la memoria residente máxima (marca de agua alta de RSS):

  • Asigna un tamaño de foreground a los presupuestos para una renderización de fotogramas fluida: Otorga a la IU en primer plano suficiente espacio para su conjunto de trabajo activo, de modo que la recuperación síncrona no retrase la renderización de fotogramas. Si tu app incluye un flujo de trabajo independiente que consume mucha memoria (como un editor de imágenes o videos de alta resolución), considera mover esa actividad a un proceso dedicado (consulta Apps multiproceso) para que sus asignaciones se presupuesten de forma independiente y se eliminen cuando el usuario salga.
  • Ajusta los presupuestos de tamaño background para el trabajo sin interfaz gráfica: El RSS alto en segundo plano en la telemetría de campo suele reflejar las asignaciones de IU retenidas después de salir del primer plano (consulta Coloca un servicio vinculado con una IU que use mucha memoria y Libera memoria en respuesta a eventos) o las lecturas de archivos únicas durante la indexación y las sincronizaciones en segundo plano. Debido a que el trabajo en segundo plano sin interfaz gráfica no tiene plazos de fotogramas, expulsar las páginas de caché de archivos de un solo uso y cambiar las páginas de montón inactivas a zRAM cuando una sincronización en segundo plano asigna brevemente más de su presupuesto no causa bloqueos visibles para el usuario, a la vez que evita que los picos en segundo plano expulsen la app en primer plano del usuario.

Para ver un flujo de trabajo paso a paso, consulta Mide tu conjunto de trabajo y calibra los presupuestos.

Cómo se asignan los estados del presupuesto a los estados del proceso

La plataforma asigna los estados de presupuesto del manifiesto a los estados del proceso del entorno de ejecución según RunningAppProcessInfo.importance, que Activity Manager calcula a partir de los componentes activos del proceso y de cualquier vinculación de servicio entrante o consulta del proveedor de contenido de otras apps:

  • foreground: Son interacciones activas y visibles del usuario, como alojar una actividad reanudada, mantener el estado de la app principal cuando se apaga la pantalla o alojar un servicio o proveedor de contenido vinculado o consultado de forma activa por la app principal en primer plano.
  • perceptible: Son cargas de trabajo que el usuario puede percibir sin una ventana visible, como la reproducción de contenido multimedia activo, la navegación paso a paso, la captura de la cámara o el micrófono, las descargas activas en segundo plano, los servicios en primer plano de sincronización de datos o los servicios vinculados por un cliente en primer plano que usa marcas como BIND_NOT_FOREGROUND (consulta Cómo controlar la herencia con marcas BIND). Si bien las métricas internas de la plataforma pueden etiquetar algunas de estas cargas de trabajo como PROCESS_STATE_IMPORTANT_FOREGROUND, esa constante interna no significa una ventana visible y se rige por el presupuesto de perceptible.
  • background: Es el trabajo que el usuario no percibe de inmediato, como los trabajos en segundo plano, las alarmas y los receptores de transmisiones.

En la siguiente tabla, se muestra cómo se asignan los niveles de importancia del entorno de ejecución a los estados del manifiesto:

Manifiesto android:state Importancia del entorno de ejecución (RunningAppProcessInfo) Componentes típicos
foreground IMPORTANCE_FOREGROUND
IMPORTANCE_TOP_SLEEPING
Actividad visible reanudada, app principal con el bloqueo de pantalla, servicio o proveedor de contenido vinculado por la app principal
perceptible IMPORTANCE_FOREGROUND_SERVICE
IMPORTANCE_VISIBLE
Servicios en primer plano de reproducción de contenido multimedia, navegación, descargas o sincronización activos; servicio vinculado con marcas de servicio en primer plano o visibles
background IMPORTANCE_PERCEPTIBLE
IMPORTANCE_CANT_SAVE_STATE
IMPORTANCE_SERVICE
Trabajos en segundo plano, receptores y sincronización en segundo plano

Para obtener detalles sobre cómo las vinculaciones de clientes elevan un proceso de host de servicio o proveedor y cómo el proceso vuelve a background o cached después de que el cliente se desvincula o se mueve fuera de la pantalla, consulta Vinculaciones de servicios y estados de procesos.

Cuando un proceso entra en un estado almacenado en caché (IMPORTANCE_CACHED) sin componentes activos, la plataforma libera su presupuesto de memoria activa y administra su memoria a través de la recuperación estándar de procesos almacenados en caché.

Inspecciona el estado del proceso y el presupuesto de tu app

Una forma de inspeccionar el estado y la importancia del proceso activo de tu app durante el desarrollo es consultar Activity Manager con ADB:

adb shell dumpsys activity processes <package-name>

En el siguiente ejemplo abreviado, se muestran el registro del proceso y las entradas de control de OOM para una app que ejecuta un servicio en primer plano:

ACTIVITY MANAGER RUNNING PROCESSES (dumpsys activity processes)
  All known processes:
  *APP* UID 10123 ProcessRecord{edf056c 3919:com.example.app/u0a123}
    pid=3919
    oom adj: max=1001 curRaw=200 setRaw=200 cur=200 set=200
    curProcState=4 mRepProcState=4 setProcState=4 lastStateTime=-42s360ms
    hasStartedServices=true
    mHasForegroundServices=true forcingToImportant=null
...
  Process OOM control (48 total):
    Proc #22: prcp  F/S/FGS  ---NFU-TI  t: 0 3919:com.example.app/u0a123 (fg-service)
        oom: max=1001 curRaw=200 setRaw=200 cur=200 set=200
        state: cur=FGS  set=FGS  lastRss=0.00 lastCachedRss=0.00

En este resultado, se ilustra lo siguiente:

  • curProcState=4 y state: cur=FGS reflejan que el proceso se encuentra en el estado de servicio en primer plano. Si tu app aloja una actividad visible activa, aparecerá como TOP. Si ejecuta un componente importante en primer plano (como una descarga o sincronización), aparece como IMPF.
  • En la tabla de control de OOM de procesos, prcp indica que el proceso se evalúa en el nivel de prioridad perceptible, que corresponde al presupuesto de perceptible.

Para inspeccionar el presupuesto de memoria que se aplica actualmente y el uso de memoria residente, haz lo siguiente:

adb shell dumpsys meminfo <package-name>

A partir de Android 17 QPR2, este resultado incluye una sección Memory Budget que muestra el límite máximo activo, la fuente del límite (como AndroidManifest o MemoryBudgetManager) y la memoria residente actual.

Apps de varios procesos

Si tu aplicación divide su trabajo en varios procesos, configura presupuestos de procesos dedicados con la etiqueta <process> dentro de <processes>.

Por ejemplo, imagina una app de transmisión de música (com.example.radio):

  1. Proceso principal: Aloja la IU visible y el motor de reproducción de audio (MediaSessionService con un servicio en primer plano de mediaPlayback). Cuando está visible, el proceso funciona con el presupuesto de primer plano de 180 MB. Cuando el usuario sale de la app mientras se sigue reproduciendo música, el proceso ingresa al estado perceptible, en el que un presupuesto de 64 MB es suficiente para el motor de reproducción y el búfer de audio.
  2. Proceso de sincronización (:sync): Es un proceso dedicado que ejecuta la sincronización de metadatos en segundo plano y la indexación de descargas. Debido a que este proceso solo está activo en segundo plano, no es necesario que declares de forma explícita state="background"; se aplica un solo presupuesto.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.radio">

    <application
        android:label="@string/app_name">

        <!-- Package baseline: main process with UI and audio playback -->
        <memory-budget android:maxMb="180" />

        <!-- Tighter budget when audio plays in the background -->
        <memory-budget
            android:maxMb="64"
            android:state="perceptible" />

        <processes>
            <!-- Dedicated background sync process -->
            <process android:process=":sync">
                <memory-budget android:maxMb="32" />
            </process>
        </processes>

        <service
            android:name=".playback.AudioPlayerService"
            android:foregroundServiceType="mediaPlayback"
            android:exported="false" />

        <service
            android:name=".sync.PlaylistSyncService"
            android:process=":sync"
            android:exported="false" />

    </application>
</manifest>

Cuando declares un bloque <processes> en tu manifiesto, incluye entradas <process> solo para los procesos que declaren un <memory-budget>. Si quieres asignar un presupuesto solo a subprocesos específicos y dejar el resto de la app en el límite del sistema para el dispositivo, puedes omitir los elementos <memory-budget> directamente en <application>.

El uso de memoria en cualquier subproceso se descuenta tanto de su presupuesto de proceso como del presupuesto del paquete que lo contiene. Un proceso experimenta presión de memoria durante el tiempo de ejecución si incumple su presupuesto de proceso o el presupuesto del paquete, según el umbral que se alcance primero.

Ajusta los presupuestos para pantallas de alta densidad

En el caso de las aplicaciones cuyo espacio en memoria escala de forma significativa según la cantidad de píxeles que se deben dibujar en la pantalla a la vez (como una app de galería de fotos que almacena en caché mapas de bits con el tamaño de la pantalla), Android proporciona dos mecanismos alternativos para escalar los presupuestos de forma dinámica con las especificaciones de la pantalla:

  • Escala por bucket de densidad de pantalla (android:additionalMbPerDensity): Agrega megabytes de manera proporcional al cuadrado de la proporción de densidad de la pantalla en relación con mdpi (1.0x / 160 dpi), ya que los elementos de diseño de trama 2D y los recursos de IU se escalan tanto en el ancho como en el alto:

    <!-- Baseline 180MB + 16MB multiplied by (density ratio)^2 -->
    <memory-budget
        android:maxMb="180"
        android:additionalMbPerDensity="16" />
    

    En una pantalla mdpi (1.0x), el presupuesto es de 180 + 16 × 12 = 196 MB. En una pantalla xxhdpi (3.0x), el presupuesto se escala a 180 + 16 × 32 = 324 MB.

  • Escala por resolución de pantalla física (android:additionalBytesPerDisplayPixel): Agrega bytes directamente por píxel de pantalla física (ancho × alto). Esto es ideal para las aplicaciones que asignan plataformas de gráficos de pantalla completa, búferes de renderización o cachés de fotos de resolución completa en las que el consumo de memoria se escala directamente con el recuento de píxeles de la pantalla sin procesar, en lugar de los buckets de densidad de la IU:

    <!-- Baseline 128MB + 16 bytes per physical display pixel -->
    <!-- For example, a 4-byte RGBA full-screen buffer with double or quadruple buffering -->
    <memory-budget
        android:maxMb="128"
        android:additionalBytesPerDisplayPixel="16" />
    

    En una pantalla de 1080p (1080 × 2400 ≈ 2.59 millones de píxeles), esto agrega ≈ 41.4 MB al presupuesto de referencia. En una pantalla de 1440p (1440 × 3120 ≈ 4.49 millones de píxeles), agrega ≈ 71.8 MB.

Estos dos atributos son alternativos. Elige el atributo que coincida con el factor de escala principal de tu app y evita combinar ambos en la misma cláusula.

Especialízate en factores de forma de dispositivos

Cuando envíes un APK a teléfonos, tablets y Wear OS, usa el atributo android:feature para ajustar los presupuestos de diferentes destinos de hardware.

En los relojes Wear OS, la RAM está limitada, y la IU y el conjunto de funciones de la app son mucho más simples. Puedes declarar un presupuesto más ajustado especializado para la función watch:

<!-- General phone and tablet baseline -->
<memory-budget android:maxMb="180" />

<!-- Wear OS override: simpler UI and constrained hardware -->
<memory-budget
    android:maxMb="48"
    android:feature="watch" />

Regla de resolución: Se aplica la última cláusula aplicable

Cuando se definen varios elementos <memory-budget> para una aplicación o un proceso, el sistema los evalúa en el orden en que se declaran en el manifiesto. Se aplica la última cláusula de presupuesto aplicable.

Debido a que se aplica el último presupuesto aplicable, el orden es importante. Siempre coloca primero el presupuesto de referencia más general, seguido de anulaciones más específicas (como cláusulas específicas del estado o del hardware).

Referencia de atributos XML

Todos los atributos de tamaño de memoria se expresan en megabytes (MB) y se asignan al cargo de memory.current de cgroup de Linux (que excluye la memoria compartida, como Zygote).

Atributo Formato Predeterminado Descripción
android:maxMb Número entero (> 0) Obligatorio Es el límite de presupuesto de memoria residente de referencia en MB.
android:state Enum Cualquiera El estado del proceso al que se aplica este presupuesto: foreground, perceptible o background. Si se omite, la cláusula actúa como un resguardo para cualquier estado no especificado.
android:additionalMbPerDensity Número entero (≥ 0) 0 Megabytes adicionales multiplicados por el cuadrado de la proporción de densidad de la pantalla en relación con mdpi (1.0x).
android:additionalBytesPerDisplayPixel Número entero (≥ 0) 0 Son los bytes adicionales asignados por píxel de pantalla física (ancho × alto), útiles para búferes de superficie y mapas de bits.
android:feature String Cualquiera Restringe la cláusula a los dispositivos que declaran funciones de hardware específicas: watch, automotive o leanback.

Mide tu conjunto de trabajo y calibra los presupuestos

Un buen presupuesto se encuentra justo por encima del conjunto de trabajo de tu app para un estado determinado, no en su punto máximo sin restricciones. Para encontrar ese punto, ejecuta un flujo de usuarios realista, disminuye el presupuesto paso a paso y observa el momento en que tu app comienza a pagar por él.

Información sobre el conjunto de trabajo y la memoria residente

Sin un presupuesto, la huella residente de tu app (memory.current) contiene dos tipos de memoria:

  • El conjunto de trabajo: el montón de Java, Kotlin y nativo, y las páginas respaldadas por archivos activos (código DEX, bibliotecas nativas y recursos) que el flujo de usuarios actual toca una y otra vez
  • Inactividad en frío: cachés de mapas de bits de pantallas anteriores, búferes de inicio únicos, inactividad de montón que queda después de una ráfaga de asignación y páginas de archivos que se leyeron una vez y no se volvieron a tocar

Un presupuesto en tu punto máximo sin restricciones nunca recupera la holgura fría. Un presupuesto inferior a tu conjunto de trabajo hace que el kernel intercambie páginas del montón y expulse páginas de código que tu app vuelve a fallar de inmediato. Estas fallas de reingreso del conjunto de trabajo consumen tiempo de CPU y batería, y, una vez que llegan al subproceso de IU, también consumen fotogramas.

Lo que buscas es el punto de inflexión: el presupuesto más bajo en el que la recuperación solo quita la inactividad fría, por lo que los errores de página, el tiempo de CPU y la latencia de fotogramas se mantienen donde estaban sin un presupuesto.

Prepara tu entorno de pruebas

Configura una prueba repetible que se comporte como la producción:

  • Calibra la misma compilación de lanzamiento optimizada para R8 y no depurable que envías. Las compilaciones de depuración usan más memoria, por lo que un presupuesto ajustado en una compilación de depuración es demasiado alto para el lanzamiento.
  • Realiza pruebas en un dispositivo o emulador que ejecute Android 17 QPR2 (nivel de API 37.2) o una versión posterior. Lo mejor es una compilación userdebug o una imagen de emulador sin Google Play, ya que algunos dispositivos solo te permiten leer los archivos cgroup en las siguientes secciones después de ejecutar adb root.
  • Crea una secuencia de comandos de un flujo de usuarios de 30 a 60 segundos con Jetpack Macrobenchmark, UI Automator o adb shell input, una vez para cada estado que planees presupuestar. Una app inactiva no te dice nada sobre su conjunto de trabajo.
    • foreground: Desplázate por listas densas y cuadrículas de contenido multimedia, muévete entre tus destinos principales, abre menús y diálogos, y ejecuta animaciones.
    • perceptible: Deja la ventana visible mientras continúa el trabajo perceptible (por ejemplo, la reproducción de audio o una descarga), deja que se ejecute durante 10 a 15 segundos y, luego, vuelve a la app.
    • background: Activar una sincronización, un trabajo de WorkManager (por ejemplo, con adb shell cmd jobscheduler run -f <package-name> <job-id>), un servicio iniciado o un receptor de transmisiones mientras la app no tiene una IU visible

Mide un presupuesto de background mientras se ejecuta el trabajo en segundo plano. Una vez que el proceso se almacena en caché, la plataforma libera su presupuesto (consulta Cómo se asignan los estados de presupuesto a los estados de proceso), por lo que un proceso inactivo almacenado en caché no te dice nada sobre tu presupuesto de background.

Mide tu punto de referencia

Comienza con ejecuciones que no tengan ningún presupuesto para que sepas cómo es un comportamiento normal. Usa una compilación sin declaraciones <memory-budget> y, luego, inicia cada ejecución desde un proceso nuevo (adb shell am force-stop <package-name>).

Cada proceso de la app tiene su propio controlador de memoria de cgroup v2. Busca su ruta de acceso en la línea 0:: de /proc/<PID>/cgroup en lugar de compilarla tú, ya que el diseño de cgroup puede variar entre dispositivos. Pasa cada comando del dispositivo a adb shell como una sola cadena entre comillas para que la shell del dispositivo vea las comillas:

PID=$(adb shell pidof -s <package-name>)
CG=/sys/fs/cgroup$(adb shell "sed -n 's/^0:://p' /proc/$PID/cgroup")
adb shell "cat $CG/memory.current $CG/memory.swap.current"
adb shell "grep -E '^(anon|file|workingset_refault_.*) ' $CG/memory.stat"

La lectura de estos archivos es económica y no interrumpe tu app, por lo que puedes muestrearlos cada segundo durante una ejecución:

  • memory.current: Es la memoria residente que se cobra al proceso, en bytes. Este es el número que limita <memory-budget>.
  • memory.swap.current: Es la memoria comprimida en el espacio de intercambio de zRAM, en bytes.
  • anon y file en memory.stat: memoria residente anónima y respaldada por archivos, en bytes
  • workingset_refault_anon y workingset_refault_file en memory.stat: Son recuentos continuos de páginas intercambiadas o expulsadas que tu app volvió a necesitar. Estos contadores son el principal indicador de que un presupuesto es demasiado ajustado.

Si recibes un error de permiso, ejecuta adb root y vuelve a intentarlo. Si no puedes obtener acceso raíz, adb shell dumpsys meminfo <package-name> te brinda una vista más general. En App Summary, Java Heap, Native Heap y Stack son, en su mayoría, memoria anónima, y Code está respaldada por archivos. Los mapas de bits de software se consideran para Native Heap y tu presupuesto, mientras que los mapas de bits de hardware y Graphics no. No ejecutes dumpsys meminfo en un bucle mientras mides la latencia de fotogramas. Fuerza una recolección de elementos no utilizados en tu app y recorre todas sus asignaciones de memoria, lo que cambia lo que intentas medir.

Presupuestos de candidatos de barrido

Puedes probar los presupuestos candidatos sin volver a compilar tu app. Usa am memory-limiter manual para establecer un presupuesto para un proceso en ejecución y none para quitarlo:

adb shell am memory-limiter manual <PID> 120MB
adb shell am memory-limiter manual <PID> none

La anulación manual dura todo el ciclo de vida de esa instancia del proceso, en todos los cambios de estado del proceso, por lo que debes borrarla o reiniciar el proceso antes de continuar. También establece el límite estricto del proceso (memory.max) en el mismo valor, por lo que un presupuesto candidato por debajo del conjunto de trabajo de tu app puede hacer que se finalice el proceso en lugar de solo ralentizarlo. Trata eso como un presupuesto fallido.

También puedes realizar un barrido desde una compilación de comparativas leyendo un valor candidato de un argumento de instrumentación y configurando MemoryBudgetManager.processBudgetBytes. Un presupuesto de tiempo de ejecución solo puede restringir un presupuesto de manifiesto, por lo que debes realizar un barrido en una compilación sin declaraciones <memory-budget>. También permanece vigente en los cambios de estado del proceso hasta que llamas a clearProcessBudget.

Comienza con el memory.current más alto de tu modelo de referencia y disminuye de 10 a 15 MB por vez. Para cada presupuesto candidato, haz lo siguiente:

  1. Inicia un proceso nuevo, llévalo al estado de destino y aplica el presupuesto.
  2. Ejecuta el flujo de usuarios una vez como preparación. Aplicar un presupuesto expulsa las páginas de inicio en frío una vez, y tú quieres medir el estado estable en lugar de esa transición.
  3. Restablece las estadísticas de fotogramas y registra los contadores de fallas de página de inicio y el tiempo de CPU del proceso (utime y stime, en tics de reloj):

    adb shell dumpsys gfxinfo <package-name> reset
    adb shell "grep -E '^workingset_refault_(anon|file) ' $CG/memory.stat"
    adb shell "cut -d ' ' -f 14,15 /proc/$PID/stat"
  4. Ejecuta el pase medido de tu flujo de usuarios.

  5. Vuelve a registrar memory.current, anon, file, los contadores de refault y el tiempo de CPU, y recopila las estadísticas de fotogramas. dumpsys gfxinfo puede imprimir las mismas estadísticas más de una vez, por lo que debes tomar la primera coincidencia, que abarca toda la app:

    adb shell "dumpsys gfxinfo <package-name>" | grep -m 1 'Janky frames:'
    adb shell "dumpsys gfxinfo <package-name>" | grep -m 1 '95th percentile'

Repite cada candidato al menos tres veces y, luego, intercala los candidatos en lugar de ejecutar todas las repeticiones de un candidato de forma consecutiva. Compara cada resultado con la dispersión de tus ejecuciones de referencia, no con una sola ejecución.

Lee los resultados

A medida que disminuye el presupuesto, tu app pasa por tres regímenes:

  • Por encima del límite: memory.current sigue el presupuesto y descarta las páginas file inactivas y el montón inactivo. anon se mantiene estable, los errores de página se mantienen cerca del modelo de referencia, y el tiempo de CPU y la latencia de fotogramas no se mueven. Aquí es donde quieres estar.
  • Debajo de la rodilla: Las páginas inactivas desaparecieron, por lo que el kernel comienza a intercambiar páginas del montón que tu app aún usa. Los errores de página aumentan en cientos o miles de páginas por ejecución, y el tiempo de CPU aumenta. Es posible que la latencia de fotogramas apenas se mueva en un dispositivo de prueba que, de lo contrario, estaría inactivo, pero tu app ahora dedica tiempo de CPU y batería a comprimir y descomprimir su propio conjunto de trabajo.
  • Muy por debajo de la rodilla: Las fallas de recuperación y de intercambio se producen en el subproceso de IU y en RenderThread, y aumentan los tiempos de latencia de fotogramas, de reanudación en caliente y de bloqueos.

En comparación con tu modelo de referencia sin restricciones, un presupuesto candidato se aprueba si cumple con todos los siguientes requisitos, que son un punto de partida razonable:

  • El proceso sobrevive a la ejecución.
  • Los deltas de fallas de página repetidas se mantienen cerca de la referencia
  • El tiempo de CPU del proceso no aumenta más de un 5%.
  • Los fotogramas con bloqueos aumentan en no más de 1 punto porcentual
  • La latencia de fotogramas del P95 no aumenta más de 3 ms.
  • El tiempo de reanudación en caliente (TotalTime de adb shell am start -W) aumenta en no más del 15% para los presupuestos de perceptible y background

El punto de inflexión es el presupuesto candidato más bajo que se aprueba. Por ejemplo, aquí tienes un barrido en primer plano para una app hipotética con un pico sin restricciones de 138 MB:

Presupuesto candidato memory.current anon file Refaults (páginas) Latencia de fotogramas de P95 Regime
Ninguno 138 MB 75 MB 63 MB 0 10.5 ms Modelo de referencia
125 MB 121 MB 74 MB 47 MB 10 10.5 ms Por sobre la rodilla
110 MB 110 MB 75 MB 35 MB 15 10.8 ms Rodilla
95 MB 95 MB 74 MB 21 MB 176 11.2 ms Por debajo de la rodilla
85 MB 85 MB 68 MB 17 MB 2,050 12.8 ms Por debajo de la rodilla
70 MB 70 MB 54 MB 16 MB 8,771 16.5 ms Muy por debajo de la rodilla

Elige tu presupuesto

Establece el android:maxMb entre un 10% y un 20% por encima del punto de inflexión, de modo que las diferencias en los datos del usuario, la configuración regional y las ráfagas de asignación no hagan que tu app quede por debajo de ese valor. En el ejemplo anterior, el punto de inflexión es de 110 MB, por lo que 125 MB es una buena opción.

Luego, instala una compilación de lanzamiento con las nuevas declaraciones de <memory-budget>, vuelve a ejecutar el flujo de usuarios y verifica que se sigan cumpliendo los criterios de aprobación. dumpsys meminfo muestra el presupuesto activo y su fuente en la sección Memory Budget.

Valida los presupuestos que no están en primer plano con un retorno en caliente

Un presupuesto ajustado de perceptible o background puede parecer correcto mientras tu app se encuentra en ese estado y, luego, costarte cuando el usuario regrese. Prueba también el viaje de regreso:

  1. Ejecuta el flujo en primer plano, mueve la app al estado de destino y aplica el presupuesto candidato con am memory-limiter manual.
  2. Deja que el trabajo perceptible o en segundo plano se ejecute durante 10 a 15 segundos.
  3. Borra la anulación (adb shell am memory-limiter manual <PID> none) y, luego, vuelve a abrir la app de inmediato con adb shell am start -W y registra TotalTime. Si, en cambio, configuras el candidato a través de MemoryBudgetManager, llama a clearProcessBudget antes de que se devuelva la app.
  4. Ejecuta una interacción breve en primer plano y verifica los errores de página y las estadísticas de fotogramas con respecto a los criterios de aprobación.

Elige el presupuesto más bajo que pase ambas pruebas mientras la app está en ese estado y en el retorno semicaliente, y agrega el mismo margen del 10% al 20%.

Calibra el ajuste de escala de la pantalla

Si usas android:additionalBytesPerDisplayPixel, busca el punto de inflexión del primer plano en dos pantallas con resoluciones diferentes, como 1080 × 2400 (aproximadamente 2.59 millones de píxeles) y 1440 × 3120 (aproximadamente 4.49 millones de píxeles). Divide la diferencia entre los dos puntos de inflexión, en bytes, por la diferencia en el recuento de píxeles para obtener android:additionalBytesPerDisplayPixel. Resta ese importe por píxel para la pantalla más pequeña de su punto de inflexión para obtener la base independiente de la resolución y agrega margen para obtener android:maxMb.

APIs de entorno de ejecución (opción dinámica secundaria)

Declarar presupuestos de forma estática en AndroidManifest.xml es la solución preferida para casi todas las apps. Sin embargo, para las aplicaciones con cargas de trabajo dinámicas o para la experimentación en el entorno de ejecución, Android proporciona APIs del SDK y del NDK del entorno de ejecución como una opción secundaria.

La API de entorno de ejecución te permite hacer lo siguiente:

  • Consultar el uso de memoria actual y los presupuestos vigentes
  • Ajusta de forma dinámica el presupuesto de tu proceso a la baja.
  • Escucha los eventos de exceso de presupuesto para recortar las cachés de forma proactiva antes de que el sistema operativo active la recuperación directa.

API del SDK de Android (MemoryBudgetManager)

El servicio del sistema MemoryBudgetManager está disponible para las apps escritas en Kotlin y Java a partir de Android 17 QPR2 (versión secundaria del SDK, nivel de API 37.2 / Build.VERSION_CODES_FULL.CINNAMON_BUN_2).

Recupera el servicio

Antes de acceder a MemoryBudgetManager, verifica que el dispositivo ejecute Android 17 QPR2 o una versión posterior. Para ello, consulta SDK_INT_FULL. Si el minSdk de tu app es inferior a 36, primero verifica SDK_INT para que los dispositivos que ejecutan Android 15 (nivel de API 35) o versiones anteriores no arrojen NoSuchFieldError cuando lean SDK_INT_FULL:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA &&
    Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.CINNAMON_BUN_2
) {
    val budgetManager = context.getSystemService(MemoryBudgetManager::class.java)
}

Uso de consultas y presupuestos

// Query current memory charged to this process and the package UID
val processUsageBytes = budgetManager.processCurrentUsageBytes
val packageUsageBytes = budgetManager.packageCurrentUsageBytes

// Query effective budgets (returns LIMIT_IS_DISABLED if unconstrained)
val processBudgetBytes = budgetManager.processBudgetBytes
val packageBudgetBytes = budgetManager.packageBudgetBytes

Establece o borra presupuestos de forma dinámica

Puedes establecer un presupuesto más estricto en el entorno de ejecución para limitar la memoria durante las tareas livianas o borrarlo cuando finalice la tarea:

// Set a tighter dynamic budget on the current process (e.g., 96 MB)
try {
    budgetManager.processBudgetBytes = 96L * 1024L * 1024L
} catch (e: IllegalArgumentException) {
    // Thrown if the budget is <= 0 or exceeds the manifest ceiling or system limit
    Log.e(TAG, "Requested budget exceeds manifest or system ceiling", e)
}

// Clear the dynamic process budget to restore the manifest limit (or unconstrained baseline)
budgetManager.clearProcessBudget()

A diferencia de las cláusulas de manifiesto <memory-budget>, que cambian automáticamente los presupuestos cada vez que el proceso pasa entre los estados foreground, perceptible y background, un presupuesto dinámico establecido a través de MemoryBudgetManager aplica un único límite hasta que tu código lo actualice o lo borre. Si usas la API de tiempo de ejecución para ejecutar experimentos de campo o administrar límites específicos del estado, actualiza o borra el presupuesto dinámico en las transiciones del ciclo de vida (por ejemplo, con ProcessLifecycleOwner o devoluciones de llamada del ciclo de vida de la actividad, como se muestra en el ejemplo de editor de imágenes adaptable).

Rendimiento e idempotencia

Cuando trabajes con MemoryBudgetManager en el entorno de ejecución, ten en cuenta las siguientes características:

  • Idempotencia: Todas las APIs de mutación de presupuesto son idempotentes. Llamar a clearProcessBudget() o clearPackageBudget() de forma repetida o cuando no hay un presupuesto dinámico activo es una operación nula segura. Del mismo modo, establecer setProcessBudgetBytes() o setPackageBudgetBytes() en el mismo valor de forma consecutiva no activa reconfiguraciones redundantes del sistema.
  • Frecuencia de llamada: Cuando se establece, actualiza o borra un presupuesto, se realiza una llamada entre procesos al servicio del sistema para actualizar el controlador de memoria del sistema operativo del proceso. Si bien es ligero, no es completamente gratuito. Llama a estas APIs durante las transiciones discretas del ciclo de vida y los hitos de las tareas (como entrar o salir de una actividad, iniciar o completar el procesamiento en segundo plano, o controlar las solicitudes de voz), y evita llamarlas en bucles cerrados, subprocesos de procesamiento de audio o rutinas de renderización por fotograma.
  • Frecuencia de consultas: Las propiedades de consulta processCurrentUsageBytes y packageCurrentUsageBytes recuperan el uso de memoria en vivo del servicio del sistema. Si bien son rápidas, las consultas se deben invocar a pedido (como durante las transiciones de tareas o dentro de las devoluciones de llamada de OnOverBudgetListener) en lugar de sondearse en bucles continuos.

Escucha las devoluciones de llamada de presión por exceso de presupuesto

Las apps pueden registrar un objeto de escucha para recibir notificaciones cuando el uso de memoria supere el umbral del presupuesto. Esto permite que la app realice una limpieza proactiva a nivel de la app (como borrar las cachés de mapas de bits en la memoria) mientras el sistema operativo recupera memoria para mantener el proceso o el paquete dentro de su presupuesto:

val cleanupThread = HandlerThread("MemoryBudgetCleanup").apply { start() }

val listener = MemoryBudgetManager.OnOverBudgetListener { budgetBytes ->
    Log.w(TAG, "Process exceeded memory budget of $budgetBytes bytes")
    // Proactively evict thread-safe caches on the background Looper
    imageTileCache.evictAll()
}

// Register on a background Looper before tightening a dynamic budget
budgetManager.registerProcessOverBudgetListener(cleanupThread.looper, listener)

// When done (for example, in onStop)
budgetManager.unregisterProcessOverBudgetListener(listener)

Prácticas recomendadas para las devoluciones de llamada por exceso de presupuesto:

  • Usa un Looper en segundo plano: Pasa un Looper en segundo plano HandlerThread a registerProcessOverBudgetListener o registerPackageOverBudgetListener siempre que sea posible. Ejecutar la expulsión de la caché o la limpieza de archivos en un subproceso en segundo plano evita competir con la renderización de fotogramas en el subproceso de IU mientras el proceso está bajo presión de memoria. Reserva Looper.getMainLooper para los objetos de escucha que solo tocan el estado de la IU vinculado al subproceso principal.
  • Espera devoluciones de llamada repetidas: Las devoluciones de llamada por exceso de presupuesto no son notificaciones de un solo intento. Cuando el uso supera el presupuesto, el sistema operativo recupera primero las páginas inactivas y, si la presión continúa, el sistema sigue recuperando memoria y, como máximo, invoca tu objeto de escucha una vez por segundo. Haz que tu devolución de llamada sea idempotente. Si tu app solo necesita una notificación o quiere un tiempo de espera más largo entre las pasadas de limpieza, llama a unregisterProcessOverBudgetListener (o unregisterPackageOverBudgetListener) dentro de la devolución de llamada o protege la limpieza con una marca de tiempo de espera o AtomicBoolean.
  • Regístrate antes de ajustar un presupuesto: Si el uso de memoria actual ya supera un presupuesto nuevo cuando llamas a setProcessBudgetBytes o setPackageBudgetBytes, se activa un evento de exceso de presupuesto en cuanto se aplica el presupuesto nuevo. Registra tu objeto de escucha antes de establecer el presupuesto para que tu app no se pierda esa notificación inicial de exceso de presupuesto.
  • Sé rápido: Las operaciones de recuperación deben ofrecer un alivio inmediato. Los cálculos complejos durante la presión empeoran el rendimiento.
  • Evita las asignaciones: No asignes objetos nuevos ni inicies subprocesos nuevos dentro de la devolución de llamada, ya que, si lo haces, se puede activar la recuperación directa inmediata del sistema operativo.
  • Enfócate en los objetivos de alto rendimiento: Expulsar mapas de bits grandes, renderizar búferes o cerrar archivos asignados a la memoria es mucho más eficaz que liberar muchos objetos pequeños.

API de NDK nativa (<android/memory_budget_manager.h>)

Las aplicaciones nativas pueden usar la API de NDK de C que expone libandroid.so a partir de Android 17 QPR2 (nivel de API 37.2).

Configuración de CMake

find_library(android-lib android)
target_link_libraries(my_native_engine PRIVATE ${android-lib})

Incluye el uso de encabezados y consultas

#include <android/memory_budget_manager.h>

// Query current memory usage
int64_t process_usage = AMemoryBudgetManager_getProcessCurrentUsageBytes();
int64_t package_usage = AMemoryBudgetManager_getPackageCurrentUsageBytes();

// Query current budget
int64_t process_budget = 0;
AMemoryBudgetResult result = AMemoryBudgetManager_getProcessBudget(&process_budget);
if (result == AMEMORY_BUDGET_RESULT_SUCCESS) {
    // Current budget available in process_budget
} else if (result == AMEMORY_BUDGET_RESULT_LIMIT_IS_DISABLED) {
    // No budget is currently active
}

Configura de forma dinámica el presupuesto nativo

// Set a tighter process budget (e.g. 160MB)
AMemoryBudgetResult result = AMemoryBudgetManager_setProcessBudget(160LL * 1024 * 1024);
if (result != AMEMORY_BUDGET_RESULT_SUCCESS) {
    const char* error_msg = AMemoryBudgetManager_resultToString(result);
    // Handle error (e.g. AMEMORY_BUDGET_RESULT_ERROR_EXCEEDS_MANIFEST_LIMIT)
}

// Clear the dynamic budget to resume manifest limits (or unconstrained baseline)
AMemoryBudgetManager_clearProcessBudget();

Supervisa los eventos de presión de memoria

El NDK proporciona dos formas de supervisar los eventos de memoria:

  1. Observador de alto nivel (AMemoryBudgetManager_Watcher_create): Supervisa los eventos en un ALooper con eliminación automática de rebotes.
  2. Descriptor de archivo de bajo nivel: AMemoryBudgetManager_getProcessMemoryPressureFd devuelve un descriptor de archivo nativo que se puede integrar directamente en un bucle de motor epoll personalizado.
void onMemoryPressure(int32_t event_mask, const AMemoryBudgetEvents* events, void* userdata) {
    // High-yield eviction of unused native textures or geometry caches
    purgeNativeTextureCaches();
}

// Register watcher on a background worker ALooper with a 1000ms debounce interval
AMemoryBudgetManagerWatcher* watcher = AMemoryBudgetManager_Watcher_create(
    background_looper,
    AMEMORY_BUDGET_MANAGER_EVENT_PROCESS,
    1000 /* debounce_ms */,
    &onMemoryPressure,
    NULL /* userdata */
);

// When done:
AMemoryBudgetManager_Watcher_destroy(watcher);

Vincula el observador al ALooper de un subproceso de trabajador en segundo plano, en lugar de tu subproceso de IU o renderización principal, de modo que la expulsión de la caché no compita con la renderización de fotogramas. Crea el observador antes de ajustar un presupuesto con AMemoryBudgetManager_setProcessBudget o AMemoryBudgetManager_setPackageBudget para que tu app no se pierda el evento inicial de exceso de presupuesto. Mientras el proceso o el paquete sigan superando el presupuesto y el sistema operativo continúe reclamando memoria, el observador invocará la devolución de llamada de forma repetida (como máximo, una vez por intervalo de debounce_ms). Haz que tu devolución de llamada sea idempotente o llama a AMemoryBudgetManager_Watcher_destroy (que es seguro llamar desde la devolución de llamada) si tu app solo necesita una notificación única.

Ejemplos de la API de Runtime

En los siguientes ejemplos, se muestra cómo implementar las APIs de tiempo de ejecución con la API del SDK de Android (escrita en Kotlin) y la API de NDK nativo (escrita en C++).

Ejemplo del SDK de Android: Editor de imágenes adaptable

En este ejemplo, se muestra una app de edición de imágenes (com.example.imageeditor) cuyo manifiesto declara un límite de 256 MB para alojar su lienzo de varias capas:

<manifest ... >
    <application ... >
        <!-- Manifest ceiling accommodates the heaviest editing workload -->
        <memory-budget android:maxMb="256" />
    </application>
</manifest>

Cuando el usuario explora la galería de miniaturas livianas, la app usa la API del SDK de Android en Kotlin para ajustar de forma dinámica su presupuesto de proceso a 96 MB. Cuando el usuario abre el lienzo de edición de varias capas, la app borra el presupuesto dinámico para restablecer el límite completo de 256 MB del manifiesto. También registra un OnOverBudgetListener en un Looper HandlerThread en segundo plano antes de aplicar el presupuesto de la galería para expulsar los mapas de bits de vista previa almacenados en caché bajo presión sin bloquear el subproceso de IU.

package com.example.imageeditor.ui

import android.app.Activity
import android.app.MemoryBudgetManager
import android.graphics.Bitmap
import android.os.Bundle
import android.os.HandlerThread
import android.util.Log
import android.util.LruCache

class ImageEditorActivity : Activity() {

    private lateinit var budgetManager: MemoryBudgetManager
    private lateinit var cleanupThread: HandlerThread

    // Thread-safe in-memory cache for rendered preview tiles (32MB limit)
    private val previewCache = object : LruCache<String, Bitmap>(32 * 1024 * 1024) {
        override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
    }

    private val overBudgetListener = MemoryBudgetManager.OnOverBudgetListener { budgetBytes ->
        Log.w(TAG, "Process memory pressure detected (budget: ${budgetBytes / 1048576}MB). Evicting preview cache.")
        previewCache.evictAll()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        budgetManager = getSystemService(MemoryBudgetManager::class.java)
        cleanupThread = HandlerThread("MemoryBudgetCleanup").apply { start() }
    }

    override fun onStart() {
        super.onStart()
        // Register listener on a background Looper before tightening the budget
        budgetManager.registerProcessOverBudgetListener(cleanupThread.looper, overBudgetListener)
        // Constrain memory during lightweight gallery browsing
        applyGalleryBudget()
    }

    override fun onStop() {
        super.onStop()
        budgetManager.unregisterProcessOverBudgetListener(overBudgetListener)
    }

    override fun onDestroy() {
        cleanupThread.quitSafely()
        super.onDestroy()
    }

    /**
     * Called when the user enters the high-resolution editing canvas.
     * Clears the dynamic budget, restoring the full 256MB manifest ceiling.
     */
    fun enterEditingCanvas() {
        // Clear the tighter dynamic budget to restore the full manifest ceiling (256MB)
        budgetManager.clearProcessBudget()
        Log.i(TAG, "Restored manifest budget ceiling (256MB) for editing canvas")
    }

    /**
     * Called when the user exits the editor back to the thumbnail gallery.
     * Re-applies the tighter dynamic budget.
     */
    fun exitToGallery() {
        previewCache.trimToSize(8 * 1024 * 1024)
        applyGalleryBudget()
    }

    private fun applyGalleryBudget() {
        try {
            // Dynamically tighten budget to 96MB for the lightweight gallery view
            budgetManager.processBudgetBytes = 96L * 1024L * 1024L
            Log.i(TAG, "Tighter dynamic budget applied for gallery: 96MB")
        } catch (e: IllegalArgumentException) {
            Log.e(TAG, "Could not apply dynamic budget", e)
        }
    }

    companion object {
        private const val TAG = "ImageEditor"
    }
}

Ejemplo de NDK en C++: Motor 3D nativo

En este ejemplo, se muestra un motor de juego nativo de C++ que administra los presupuestos de memoria según el nivel de calidad de gráficos activo, suponiendo que el manifiesto de la aplicación declara un límite de 512 MB para admitir gráficos de alta calidad (android:maxMb="512"). El motor inicia AMemoryBudgetManager_Watcher_create en el ALooper de un subproceso de trabajo en segundo plano antes de ajustar el presupuesto para los ajustes predeterminados de menor calidad y descarga los mapas MIP de textura cuando se supera el presupuesto.

#include <android/memory_budget_manager.h>
#include <android/looper.h>
#include <android/log.h>

#define LOG_TAG "Native3DEngineMemory"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)

class MemoryGovernor {
public:
    MemoryGovernor() : mWatcher(nullptr) {}

    ~MemoryGovernor() {
        stopMonitoring();
    }

    // Configures process budget based on user graphics quality settings
    bool setQualityBudget(int qualityLevel) {
        int64_t targetBytes = 0;
        switch (qualityLevel) {
            case 0: // Low (budget: 128MB)
                targetBytes = 128LL * 1024 * 1024;
                break;
            case 1: // Medium (budget: 256MB)
                targetBytes = 256LL * 1024 * 1024;
                break;
            case 2: // High (budget: 512MB)
                targetBytes = 512LL * 1024 * 1024;
                break;
            default:
                // Clear dynamic override and restore manifest limit
                AMemoryBudgetManager_clearProcessBudget();
                return true;
        }

        AMemoryBudgetResult result = AMemoryBudgetManager_setProcessBudget(targetBytes);
        if (result != AMEMORY_BUDGET_RESULT_SUCCESS) {
            LOGW("Could not set quality budget: %s", AMemoryBudgetManager_resultToString(result));
            return false;
        }
        return true;
    }

    // Call on a background worker ALooper before tightening the budget with setQualityBudget
    bool startMonitoring(ALooper* backgroundLooper) {
        if (!backgroundLooper) return false;

        // Monitor process budget events, debounced to at most once every 1000ms
        mWatcher = AMemoryBudgetManager_Watcher_create(
            backgroundLooper,
            AMEMORY_BUDGET_MANAGER_EVENT_PROCESS,
            1000,
            &MemoryGovernor::onPressureEvent,
            this
        );
        return mWatcher != nullptr;
    }

    void stopMonitoring() {
        if (mWatcher) {
            AMemoryBudgetManager_Watcher_destroy(mWatcher);
            mWatcher = nullptr;
        }
    }

    void unloadUnusedTextures() {
        LOGW("Memory pressure callback triggered. Purging cached texture mipmaps...");
        // Fast, idempotent, high-yield eviction without allocating memory
    }

private:
    static void onPressureEvent(
        int32_t event_mask,
        const AMemoryBudgetEvents* events,
        void* userdata
    ) {
        auto* governor = static_cast<MemoryGovernor*>(userdata);
        governor->unloadUnusedTextures();
    }

    AMemoryBudgetManagerWatcher* mWatcher;
};