wandres.dev
WORKMANAGER · trabajo diferido

Constraints y programación: declarar condiciones

Encolar un trabajo sin restricciones es delegar en el sistema una decisión que solo tú puedes tomar: bajo qué condiciones tiene sentido gastar batería y datos del usuario en esto. Esta lección recorre el catálogo completo de restricciones y su semántica conjuntiva, explica el retraso inicial y la política de retroceso con sus topes reales, distingue el trabajo puntual del periódico con su intervalo mínimo y su ventana de flexibilidad, y termina con el trabajo expedito y su sistema de cuotas, que es la única puerta legítima hacia la baja latencia.

⏱ 19 min

Un trabajo sin restricciones se ejecutará en cuanto el sistema tenga un hueco, y eso significa que puede ejecutarse con un dos por ciento de batería, sobre una conexión de datos móviles en itinerancia y con el almacenamiento al borde del colapso. El sistema no lo impedirá, porque el sistema no sabe qué hace tu trabajo: no puede distinguir una sincronización de mil fotografías de un envío de dos líneas de telemetría, y ante la duda ejecuta. La única entidad que conoce el coste real de la tarea eres tú, y las restricciones son el vocabulario con el que se lo comunicas. Lo notable es que ese vocabulario no es una capa de cortesía opcional para aplicaciones bien educadas: es la pieza que convierte el sistema en un aliado. Un trabajo bien restringido no espera de más, espera exactamente hasta el primer instante en que su ejecución es barata para el usuario, y ese instante lo encuentra el sistema mucho mejor que cualquier lógica que puedas escribir tú.

🎯 Al terminar esta lección sabrás
  • Declarar el catálogo completo de restricciones y entender su combinación conjuntiva.
  • Configurar el retraso inicial y la política de retroceso conociendo sus mínimos y sus topes.
  • Distinguir el trabajo puntual del periódico con su intervalo mínimo y su ventana de flexibilidad.
  • Usar el trabajo expedito dentro de su sistema de cuotas y saber qué ocurre al agotarlo.

El catálogo de restricciones

Las restricciones se construyen con un constructor propio y se adjuntan a la petición. Son seis familias y se combinan siempre con la conjunción lógica: el trabajo permanece bloqueado hasta que todas se cumplen a la vez, y basta que una deje de cumplirse para que vuelva a bloquearse.

val condiciones = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)
    .setRequiresBatteryNotLow(true)
    .setRequiresStorageNotLow(true)
    .setRequiresCharging(false)
    .build()

val sincronizacion = OneTimeWorkRequestBuilder<SincronizarWorker>()
    .setConstraints(condiciones)
    .build()

La restricción de red admite varios grados y la elección entre ellos es una decisión de producto, no técnica. Conectado exige cualquier red y sirve para cargas pequeñas. No medida exige una red que el usuario o el operador no facturen por volumen, típicamente una red doméstica, y es lo correcto para cualquier transferencia grande: una copia de seguridad, un vídeo, un modelo de aprendizaje automático. Sin itinerancia protege al usuario del cargo desagradable al viajar. Y en las versiones modernas de la librería se puede además expresar la exigencia con el objeto de petición de red del sistema, lo que permite matices que el enumerado no cubre.

📶

Red

Desde ninguna exigencia hasta red no medida o sin itinerancia. La regla práctica: si la transferencia se mide en megabytes, no medida; si son kilobytes, conectado basta.

🔋

Batería y carga

Batería no baja evita ejecutar cuando el sistema considera crítico el nivel. Exigir carga es más fuerte todavía y adecuado para trabajo intensivo que puede esperar a la noche.

💾

Almacenamiento

Almacenamiento no bajo impide que un trabajo que escribe archivos empeore una situación ya crítica y acabe fallando a mitad dejando basura.

😴

Reposo y contenido

Dispositivo en reposo reserva el trabajo para cuando nadie usa el móvil. Los disparadores por URI de contenido despiertan el trabajo cuando cambia un proveedor observado.

Hay un comportamiento a mitad de ejecución que conviene tener muy presente y que casi nadie prueba: si una restricción deja de cumplirse mientras el trabajador está corriendo, el sistema lo detiene y lo devuelve a la cola. El usuario que sale de casa con una subida en curso sobre red no medida verá su trabajo interrumpido y reencolado, y eso significa que la próxima ejecución empezará desde el principio salvo que tú hayas guardado el progreso. La restricción no es solo una condición de arranque, es una condición de permanencia.

Los disparadores por contenido merecen un párrafo propio porque resuelven un problema que mucha gente ataca con sondeo periódico. En lugar de despertar cada quince minutos a comprobar si el usuario hizo una foto nueva, se declara un interés en una URI de contenido y el sistema despierta el trabajo cuando el proveedor observado cambia, con una espera configurable que permite agrupar varios cambios seguidos en una sola ejecución. Es más barato, más inmediato y más respetuoso que cualquier ciclo de sondeo, y es la respuesta correcta siempre que el evento que te interesa esté expuesto como proveedor de contenido.

Y una advertencia sobre la avaricia de condiciones. Cada restricción añadida reduce la ventana de oportunidad, y la combinación de reposo, carga y red no medida puede describir un estado que en el dispositivo de un usuario concreto no se da nunca. Un trabajo que exige demasiado no falla: simplemente no ocurre jamás, silenciosamente, y ese es el modo de fallo más difícil de diagnosticar que produce esta librería.

Retraso inicial y política de retroceso

Dos parámetros gobiernan el tiempo y se confunden con frecuencia. El retraso inicial fija cuánto debe esperar el trabajo antes de ser siquiera candidato a ejecutarse; el retroceso fija cuánto se espera entre un reintento y el siguiente.

El retraso inicial expresa un mínimo, nunca un instante. Pedir dos horas significa que no se ejecutará antes de dos horas, y en absoluto significa que se ejecutará a las dos horas: si el dispositivo está en reposo profundo, la ventana de mantenimiento puede posponerlo bastante más.

val informe = OneTimeWorkRequestBuilder<InformeWorker>()
    .setInitialDelay(2, TimeUnit.HOURS)
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
    .build()

La política de retroceso tiene dos formas. La lineal multiplica el retraso base por el número de intento, produciendo una progresión suave. La exponencial lo duplica en cada intento, produciendo una progresión que se aleja deprisa. La exponencial es casi siempre la correcta frente a fallos de red o de servidor, porque es la única que evita que mil dispositivos que fallaron a la vez vuelvan a golpear el servidor a la vez, agravando la caída que causó el fallo.

Conviene no confundir el retraso inicial con la periodicidad, error frecuente en trabajos que deben ocurrir de madrugada. Un retraso calculado hasta las tres de la mañana funciona para la primera ejecución, pero si el trabajo es periódico cada veinticuatro horas y una ejecución se retrasa dos horas por restricciones, todas las siguientes heredan la desviación y en pocas semanas el trabajo nocturno ocurre a media tarde. Cuando la hora importa de verdad, el patrón robusto es un trabajo periódico frecuente y barato que compruebe si toca y salga en el acto si no, en lugar de intentar que el planificador conserve una fase.

Los límites reales importan. El retraso base no puede bajar de diez segundos, y el retraso resultante está topado en cinco horas, de modo que la progresión exponencial se aplana al llegar ahí. Conviene también recordar que estos valores se aplican sobre la ejecución que devolvió reintento, y que el número de intento que consulta tu trabajador es el mismo que usa el planificador para calcular la espera.

⚠️
El retroceso sin dispersión sigue siendo una estampida

La progresión exponencial separa los intentos de un dispositivo, pero no separa entre sí a los dispositivos: si un fallo del servidor afecta a toda tu base instalada, todos reintentan con la misma curva y golpean en los mismos instantes. Añadir una dispersión aleatoria propia —un retraso inicial de unos pocos minutos elegido al azar, por ejemplo— es lo que convierte el retroceso en una defensa real del servidor.

Puntual frente a periódico

El trabajo puntual se ejecuta una vez, con sus reintentos si los hubiera, y termina. El periódico se repite indefinidamente hasta que alguien lo cancele, y tiene un conjunto de reglas propias que sorprenden si se esperan las de un temporizador.

La primera y más citada es el intervalo mínimo de quince minutos. No hay manera de pedir menos, y el intento de lograrlo encadenando trabajos puntuales que se reencolan a sí mismos es un antipatrón que el sistema acaba castigando con el racionamiento de los cubos de espera. Si de verdad necesitas granularidad menor, es que no estás ante trabajo diferible.

La segunda es la ventana de flexibilidad. Cada periodo se divide en dos partes: un tramo inicial en el que el trabajo no se ejecutará y una ventana final dentro de la cual el sistema elegirá el momento que mejor le venga, agrupando tu despertar con el de otras aplicaciones para no encender la radio más veces de las necesarias. Definir explícitamente esa ventana comunica al sistema cuánta libertad le concedes.

val limpieza = PeriodicWorkRequestBuilder<LimpiezaWorker>(
    repeatInterval = 6, repeatIntervalTimeUnit = TimeUnit.HOURS,
    flexTimeInterval = 30, flexTimeIntervalUnit = TimeUnit.MINUTES,
).setConstraints(condiciones).build()

La tercera regla es que el periodo no es una garantía de frecuencia sino un techo de aspiración. Si las restricciones no se cumplen durante toda la ventana, ese ciclo se pierde y el trabajo esperará al siguiente. Una sincronización cada seis horas que exige red no medida puede ejecutarse dos veces al día en un usuario y ocho en otro, y el diseño tiene que tolerarlo.

Hay además una consecuencia de mantenimiento que sorprende al publicar la segunda versión de una aplicación: un trabajo periódico encolado por una versión anterior sigue vivo con la configuración con la que se creó, y volver a encolarlo en cada arranque sin declarar unicidad produce duplicados que se acumulan versión tras versión. El trabajo periódico se encola siempre con nombre único, sin excepciones.

La cuarta es que el trabajo periódico no admite encadenamiento ni ejecución expedita. Cuando necesitas una secuencia recurrente, el patrón correcto es un trabajo periódico ligero que encole la cadena puntual.

flowchart LR
A[Inicio del periodo] --> B[Tramo rigido sin ejecucion]
B --> C[Ventana flexible]
C --> D{Restricciones cumplidas}
D -->|Si| E[El sistema elige el instante y ejecuta]
D -->|No| F[Ciclo perdido y se espera al siguiente]
E --> A
F --> A
style E fill:#a6e3a1,color:#11111b
style F fill:#f9e2af,color:#11111b

Expedito: la puerta legítima a la baja latencia

Hay trabajo que es genuinamente importante para el usuario y no puede esperar a la próxima ventana de mantenimiento: enviar el mensaje que acaba de escribir, procesar un pago, aplicar un ajuste que pidió de forma explícita. Para ese caso existe la ejecución expedita, que pide al sistema arrancar cuanto antes y con una cuota de recursos elevada.

val envio = OneTimeWorkRequestBuilder<EnviarMensajeWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .setInputData(datos)
    .build()

El privilegio viene con un contador. Cada aplicación dispone de una cuota de ejecución expedita que depende del cubo de espera en el que el sistema la haya colocado según el uso que le dé el usuario, y esa cuota se consume con cada ejecución. Al agotarla, el parámetro de política decide el desenlace: degradar el trabajo a normal, que es casi siempre lo correcto porque conserva la garantía, o descartarlo, que solo tiene sentido cuando un trabajo tardío no aporta nada.

Hay además dos exigencias formales. La primera es que el trabajo expedito no admite retraso inicial ni periodicidad, lo que es coherente con su propósito. La segunda es que en dispositivos anteriores a Android 12 la ejecución expedita se implementa como servicio en primer plano, de modo que el trabajador debe ser capaz de proporcionar la información de notificación correspondiente; sin ella, fallará en esos dispositivos.

Conviene no confundir la ejecución expedita con el trabajo de larga duración, que es un régimen distinto y responde a otra necesidad. El expedito pide arrancar pronto y sigue sujeto al límite de tiempo habitual; el de larga duración declara una notificación visible y promueve el proceso a primer plano para poder exceder ese límite, a cambio de que el usuario vea lo que ocurre y pueda detenerlo. Se activa desde dentro del trabajador entregando la información de notificación, y es la salida correcta para una exportación de media hora o una descarga enorme que el usuario pidió de forma explícita.

La tentación evidente es marcar todo como expedito. Conviene resistirla por dos motivos. El primero es que la cuota es finita y compartida, así que el abuso hace que el trabajo verdaderamente urgente se degrade justo cuando lo necesitas. El segundo es que el sistema observa el comportamiento agregado de la aplicación para decidir su cubo de espera, y una aplicación que exige urgencia constante acaba en un cubo restringido que empeora todo lo demás.

Las restricciones no son una cortesía: son la moneda con la que se compra prioridad

Vale la pena detenerse en la lógica económica que gobierna todo este apartado, porque explica decisiones de diseño que de otro modo parecen arbitrarias y porque predice el comportamiento del sistema mejor que cualquier lista de reglas. El planificador de un teléfono moderno administra un recurso escaso —energía, radio, ciclos de procesador, atención— entre agentes que no cooperan entre sí y cuyos incentivos individuales apuntan todos en la misma dirección: cada aplicación cree que su trabajo es el importante y, si se le deja, ejecutará siempre lo antes posible. Un sistema que atendiera esas peticiones al pie de la letra colapsaría la batería en un día, y por eso Android dejó de creerse las peticiones. Lo que hizo en su lugar fue montar un mercado: quien declara flexibilidad recibe fiabilidad, y quien exige inmediatez la paga con racionamiento. Las restricciones son literalmente eso, la divisa con la que declaras flexibilidad. Al decir que tu trabajo puede esperar a la red no medida y a la carga, no estás siendo educado, estás comprando la certeza de que el sistema lo ejecutará sin dudar cuando esas condiciones lleguen, porque en ese momento ejecutarlo no le cuesta nada al usuario. Y al pedir ejecución expedita estás gastando de una cuenta con saldo limitado que el propio sistema recalcula observando cuánto usa el usuario tu aplicación: cuanto menos la abre, menos crédito tienes. De ahí se sigue el corolario que más equipos aprenden por las malas: la agresividad no escala. Una aplicación que lo marca todo como urgente, que no declara ninguna restricción y que reintenta sin retroceso no consigue más ejecución, consigue menos, porque el sistema la clasifica como derrochadora y la castiga en un cubo restringido donde hasta su trabajo genuinamente importante llega tarde. La estrategia ganadora es exactamente la contraria y tiene una elegancia casi contraintuitiva: sé barato en el noventa y cinco por ciento de tu trabajo para poder ser caro en el cinco por ciento que de verdad importa.

⚔️ Mide el coste de cada condición
  1. Encola el mismo trabajo con y sin la restricción de red no medida y registra en ambos casos cuánto tarda en ejecutarse en un dispositivo real de uso normal.
  2. Interrumpe una restricción a mitad de ejecución cambiando de red y comprueba en el registro que el trabajador es detenido y reencolado.
  3. Construye una combinación deliberadamente imposible de restricciones y observa durante un día que el trabajo nunca se ejecuta ni informa de nada.
  4. Compara la progresión de reintentos con política lineal y exponencial anotando los instantes reales de cada intento.
  5. Marca un trabajo como expedito, ejecútalo repetidamente hasta agotar la cuota y verifica qué ocurre según la política que hayas declarado.