Nueva funcionalidad. Una notificación que aparece cada 15 minutos. Añadimos un log para ver en consola que se llame correctamente. Implementado, probado en el emulador y cada quince minutos aparece la notificación y la consola muestra el log. Sin retrasos. Probamos en el móvil y lo dejamos funcionando mientras vamos a una reunión y luego a comer. Al volver, solo hay cuatro ejecuciones en tres horas.
Ni excepción, ni error de red, ni crash de la app. El trabajo programado no se había ejecutado. Sin embargo, el sistema hacía exactamente lo que había prometido hacer. Los que habíamos entendido mal el contrato éramos nosotros mismos.
1. ¿Aquí no debería existir un reloj?
Al escribir cada quince minutos en un backend, ese código se ejecuta cada quince minutos. Esa orden, es un Cron. Si la máquina que tiene que ejecutar está encendida, se cumple. Pero este mismo modelo, no se puede llevar tal cuál al móvil. Y es el origen del problema.
La diferencia es que, en Android (y en iOS), programar un trabajo periódico no es una orden, sino que es una petición. Aquí se cambia el "ejecuta esto cada quince minutos" por un "no ejecutes esto ANTES de que pasen quince minutos". Esto no garantiza que la ejecución real tenga lugar dentro de quince minutos. En cambio, pueden pasar quince minutos o seis horas y ejecutarse. Y el problema es que ambas cosas son comportamiento correcto según la documentación.
El emulador ayuda a esta confusión. Siempre corre con la pantalla encendida, enchufado a una fuente de alimentación virtual y sin restricciones de batería. Es el escenario en el que todas las políticas de ahorro están desactivadas, así que confirma el modelo mental equivocado con una precisión que no va a volver a ocurrir nunca en producción. Es el mejor de los happy path.
Este artículo habla de AOSP, del Android que Google documenta. En un móvil real hay además una capa de fabricante encima que aprieta bastante más, y de eso hablé en los permisos de Android cambian según la marca del móvil. Lo que viene aquí es el suelo: pasa incluso en un Pixel limpio.
2. Doze: la ventana que se abre cada vez menos
Doze, o el modo "descanso", llegó en Android 6 y es la pieza central. Cuando el dispositivo lleva un rato sin interacción, con la pantalla apagada y desenchufado, el sistema lo considera inactivo y entra en Doze. A partir de ese momento, para la aplicación ocurren las siguientes cosas:
- El acceso a red se suspende.
- Los
wakelockse ignoran. - Las alarmas de
AlarmManagerse aplazan. - Los
jobprogramados no se ejecutan. - Las sincronizaciones no se lanzan.
Nada de esto se cancela. Se guarda y acumula. Cada cierto tiempo, el sistema abre una ventana de mantenimiento, detecta todo lo pendiente, deja que se ejecute durante un rato corto y vuelve a dormirse. El trabajo no se pierde, se agrupa y se retrasa. Y esto es lo importante.
Además, las ventanas no son regulares. Durante los primeros minutos, se abren con frecuencia. Pero, cuanto más tiempo lleva el dispositivo quieto, más se espacian. Un móvil, durante toda la noche, quieto sobre la mesilla, pasa de ventanas cada pocos minutos a ventanas cada varias horas. Por eso el fallo tiene esa forma tan característica: durante las primeras horas funciona bien, pero al cabo del tiempo ya no recoge datos correctamente.
Un poco más tarde, con Android 7, se añadió además un Doze ligero, que se activa solo con la pantalla apagada aunque el móvil se esté moviendo. Este Doze es menos agresivo, pero significa hay otro entorno donde los trabajos periódicos no funcionan libremente.
Las soluciones oficiales son pocas y todas tienen límites:
setExactAndAllowWhileIdleysetAndAllowWhileIdle, se saltan el Doze, pero están limitadas a aproximadamente una alarma cada nueve minutos por app.- Un mensaje push de prioridad alta, que mete a la app en una lista blanca temporal durante unos segundos. Puede servir para ciertos escenarios, pero si la app requiere de trabajos pesados o rutinas largas, la ventana de acción queda muy limitada.
- La exención de optimización de batería, que el usuario tiene que conceder a mano. (Y depende del fabricante y su capa, puede no servir de mucho).
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS parece la solución a todo y no lo es. La política de Google Play restringe su uso a un puñado de casos concretos (alarmas, comunicación en tiempo real, algunas apps de salud), y pedirlo sin encajar en uno de ellos es motivo de rechazo en la revisión de la tienda.
3. El bucket te lo asignan, no lo pides
Doze es una propiedad del dispositivo. Los App Standby Buckets, que llegaron en Android 9, son una propiedad de la app en concreto. Y son la parte que más gente desconoce.
El sistema clasifica cada app instalada en un bucket según cómo la usa ese usuario concreto:
- Active: la está usando ahora mismo.
- Working set: la usa a menudo.
- Frequent: la usa con regularidad, pero no todos los días.
- Rare: casi no la abre.
- Restricted (añadido en Android 11): el bucket más duro, para apps que consumen mucho y se usan poco.
Cada nivel lleva asociada una cuota, que representa cuántos job puede ejecutar al día o cuánto se aplaza el acceso a la red, entre muchas otras. Bajar de working set a rare puede significar pasar de decenas de ejecuciones diarias a un par de ellas, rompiendo completamente la funcionalidad de la app.
Lo importante es quién decide a qué nivel pertenece dicha aplicación. Y, por desgracia, no lo decide el desarrollador. Lo decide el propio usuario con la frecuencia del uso. Puedes leer en qué bucket estás con UsageStatsManager, pero es simplemente informativo, no puedes cambiarlo.
Esto tiene una consecuencia que conviene interiorizar pronto: el dispositivo de desarrollo vive permanentemente en ACTIVE, porque se abre la app veinte veces al día para probarla. El usuario que la instaló hace tres semanas y no la ha vuelto a tocar vive en RARE. Las pruebas ocurren en un entorno que la base de usuarios real casi no habita.
El Doze Mode se puede reproducir en local sin esperar semanas con estos comandos:
1adb shell am set-standby-bucket com.tu.paquete rare
2adb shell dumpsys battery unplug
3adb shell cmd deviceidle force-idleEl último fuerza la entrada en Doze. adb shell cmd deviceidle step avanza manualmente al siguiente estado y sirve para provocar una ventana de mantenimiento sin esperar.
4. Programar no es lo mismo que ejecutar
Con todo esto entendido, hay tres opciones y cada una responde a una pregunta distinta.
AlarmManager: Para eventos de tiempo visibles para el usuario: un despertador, un recordatorio de calendario, una cuenta atrás. No funciona con trabajos en segundo plano (aunque antes se usaba para ello). Desde Android 12 las alarmas exactas necesitan el permisoSCHEDULE_EXACT_ALARM, y desde Android 13 ese permiso ya no viene concedido de fábrica en instalaciones nuevas. Por lo que hay que mandar al usuario a los ajustes para que lo active, y debe activarlo él mismo. La alternativa esUSE_EXACT_ALARM, que sí se concede sola y el usuario no puede revocar, pero Google Play especifica que esta función está reservada a apps cuyo uso sea precisamente ese (despertadores, calendarios).JobScheduler: API de bajo nivel del sistema. Permite declarar restricciones y el sistema decide cuándo lanzarse. Funciona, pero te deja gestionar a mano la persistencia entre reinicios y los reintentos.WorkManager: Declarador de restricciones con garantía de persistencia. El desarrollador no indica cuándo quiere que corra algo, sino que especifica que condiciones marcan si tiene sentido que corra. El sistema lo encaja donde puede. A cambio, garantiza que el trabajo sobrevive al cierre de la app y al reinicio del dispositivo.
1// La API no admite un intervalo menor de 15 minutos, y aun así ese número
2// es el mínimo antes de la ejecución, no la cadencia con la que ocurrirá.
3private val SYNC_INTERVAL = 15.minutes
4
5fun scheduleSync(context: Context) {
6 val constraints = Constraints.Builder()
7 // Declaras condiciones, no momentos. Esto es lo importante.
8 .setRequiredNetworkType(NetworkType.CONNECTED)
9 .setRequiresBatteryNotLow(true)
10 .build()
11
12 val request = PeriodicWorkRequestBuilder<SyncWorker>(SYNC_INTERVAL.toJavaDuration())
13 .setConstraints(constraints)
14 // Si el trabajo falla, el sistema espacia los reintentos solo.
15 .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
16 .build()
17
18 WorkManager.getInstance(context).enqueueUniquePeriodicWork(
19 "sync",
20 // KEEP evita duplicar el trabajo en cada arranque de la app.
21 ExistingPeriodicWorkPolicy.KEEP,
22 request,
23 )
24}Un trabajo en segundo plano no se ejecuta cuando tú quieres. Se ejecuta cuando el sistema puede. La única forma de que eso deje de ser un problema es que a tu código le dé igual cuándo.
5. El proceso muere y el trabajo tiene que sobrevivir
La otra mitad del asunto no es el reloj, es el proceso. Generalmente, la app no es un servidor que está siempre levantado. Es un proceso que se crea y se destruye, y es el sistema quién decide cuándo hacerlo según le convenga. Y lo peor es que hay al menos cuatro formas distintas de que una aplicación muera. Y cada una se comporta de forma diferente:
- Necesidad de Memoria RAM. El sistema necesita RAM y mata el proceso de la app que menos importa en ese momento. No puedes decidir que tú app es importante en ese preciso momento, por lo que es jugar con la suerte. No hay callback, no hay aviso, no hay garantía de llamar al
onDestroy. Es la más común y la más silenciosa. - Swipe manual. El usuario quita la app de la lista de recientes (lo que se conoce como hacer "swipe". En AOSP esto no debería matar el trabajo programado, y en la práctica depende del dispositivo. El único aviso posible es recibir un
onTaskremovedsi se utiliza unService. - Cambio de configuración/Actualización de la app. El proceso se reinicia por cualquier motivo. Lo que estuviera programado con un
WorkManagersobrevive porque está en la base de datos, pero todo lo que estuviera en memoria, no. - Force Stop. El usuario entra en ajustes y pulsa "Forzar detención". Esto es manual. Y es distinto a todos los anteriores. En estos casos se cancelan las alarmas, los
job, y la app queda en un estado "sin respuesta" hasta que alguien la abre a mano. No hay forma de solucionarlo.
En React Native hay un extra: el hilo de JavaScript no sobrevive (en general). HeadlessJS levanta un contexto JS sin Activity para atender un evento en segundo plano, y funciona. Pero ese contexto también se congela con Doze y muere con el proceso. Si algo tiene que aguantar de verdad, debe vivir en nativo (Kotlin).
6. Diseñar para un reloj que no te pertenece
Aceptado que el momento en el que muere la aplicación no se puede elegir, el trabajo se vuelve una cuestión de diseño y tiene tres propiedades que negociar.
La primera es idempotencia. WorkManager garantiza al menos una ejecución, pero no exactamente una. Un trabajo que se mató a mitad se vuelve a lanzar entero. Por lo tanto, y si la tarea incrementaba un contador o insertaba una fila sin clave, se duplican los datos sin explicación. Todo lo que se ejecute en segundo plano tiene que poder correr dos veces sin consecuencias.
La segunda son los checkpoints. Un trabajo que procese quinientos elementos y muera en el cuatrocientos debe reanudarse en el cuatrocientos, no empezar de cero (duplicaría trabajo y elementos). Asumiendo que cada ventana de mantenimiento tiene unos pocos segundos para actuar (no minutos), la unidad de trabajo debe ser pequeña por diseño.
Por último las persistencias. La tercera es dejar de preguntar "qué toca hacer ahora" y comenzar a preguntar "qué ha cambiado desde la última vez que terminé bien el proceso". Eso es una marca de agua persistida, y es lo que permite convertir una ejecución perdida en un lote un poco más grande la próxima vez, en lugar de en un agujero de datos.
1// Pequeño a propósito: en una ventana de mantenimiento hay segundos.
2private const val BATCH_SIZE = 50
3
4class SyncWorker(
5 context: Context,
6 params: WorkerParameters,
7 // Inyectado vía WorkerFactory: la factoría por defecto no sabe construirlo.
8 private val repo: SyncRepository,
9) : CoroutineWorker(context, params) {
10
11 override suspend fun doWork(): Result {
12 // No preguntamos qué hora es, preguntamos por dónde íbamos.
13 val watermark = repo.lastSuccessfulWatermark()
14 val batch = repo.changesSince(watermark, limit = BATCH_SIZE)
15
16 if (batch.isEmpty()) return Result.success()
17
18 return try {
19 repo.upload(batch)
20 // El checkpoint se confirma solo cuando el lote está arriba.
21 repo.commitWatermark(batch.last().timestamp)
22 // Quedaba más trabajo: pedimos otro turno en vez de alargar este.
23 if (batch.size == BATCH_SIZE) Result.retry() else Result.success()
24 } catch (e: IOException) {
25 // Fallo transitorio: el backoff exponencial se encarga.
26 Result.retry()
27 } catch (e: SerializationException) {
28 // Fallo permanente: reintentar mil veces no lo va a arreglar.
29 repo.quarantine(batch)
30 Result.failure()
31 }
32 }
33}7. El foreground service es pedir tiempo prestad
Todo lo anterior asume que el trabajo puede esperar. Sin embargo, hay productos donde no. En la app de telemetría de vehículos eléctricos el dato solo existía mientras el coche estaba en marcha. Si el proceso no estaba vivo en ese momento, el trayecto no se registraba y no había forma de recuperarlo después. (Se planteó un sistema de batches donde almacenar el resultado y luego subirlos cuando volviera a tener conexión, pero esto era bastante débil y traía sus propios errores).
Para esos casos Android ofrece un trato explícito. Un sistema llamado foreground service que cuenta con una notificación persistente. Al usuario se le indica que se está trabajando, y a cambio, el sistema no congela la aplicación. Es el único mecanismo que convierte "cuando yo pueda" en "mientras esto dure".
Por desgracia, el trato contiene letra peque ña (y desde Android 14 mucha más). Cada servicio en primer plano debe declarar un foregroundServiceType, y el tipo no es una etiqueta administrativa: cambia las condiciones bajo las que puedes arrancarlo y las garantías que recibes. Además de ser otro punto que se revisa antes de subir a la tienda y que debe tener justificación.
1<!-- El tipo determina qué permiso hace falta y desde dónde puedes arrancarlo. -->
2<service
3 android:name=".TripRecordingService"
4 android:foregroundServiceType="connectedDevice|location"
5 android:exported="false" />connectedDevice para una conexión BLE activa, location para seguimiento de ubicación, mediaPlayback para reproducción. Y dataSync, que es el que la gente elige por defecto cuando no sabe cuál poner (incluido yo mismo al inicio), y es justo el que menos garantías da.
Desde Android 15 los servicios de tipo dataSync tienen un tope de seis horas cada veinticuatro. Al alcanzarlo, el sistema lanza un timoeut y lo detiene. En general, tampoco se permiten arrancar desde segundo plano. dataSync para algo que debe durar todo el día acabará haciendo que lo mate a las seis horas.
La notificación también debe aportar de cara al usuario: si dice algo concreto como "registrando trayecto, 12 km" en lugar de algo genérico como "la app se está ejecutando", el usuario entiende por qué está ahí y no busca cómo quitarla. También le indica que la app está funcionando correctamente, aunque no la tenga abierta en primer plano o con le móvil desbloqueado.
8. Lo que sí se puede controlar
El reloj no es algo que se pueda editar o controlar.
Conociendo esta limitación, se pueden evitar trucos desde el principio, evitando que envejezcan mal. Lo que si es posible hacer es:
- Determinar qué ocurre cuando el reloj vuelve. Convirtiendo un "incidente" en un retraso.
- Tamaño de la ventana. Con la limitación de tiempo, se deben crear trabajos que duran segundos, para asegurar que siempre entren en dicha ventana.
- Informar al usuario. Las notificaciones son unas aliadas en este caso. Una app que muestra "última sincronización: hace 4 horas" junto a un botón de refrescar es una app que funciona incluso cuando el sistema la ha tenido congelada toda la noche.
- Qué se mide. Registrar cada ejecución, tanto esperada como real. De esta forma, se obtiene el porcentaje de ejecuciones que llegan a ocurrir y dejar de ser sensaciones para pasar a datos concretos.
El segundo plano en Android no es una promesa que el sistema hace. Es un permiso que se renueva cada rato. La arquitectura que aguanta no es la que consigue más ejecuciones, es la que no depende de ninguna en concreto.



