BGTaskScheduler en iOS: por qué una tarea programada no corre cuando la programaste
Cuando implementamos actualización en segundo plano en una app iOS por primera vez, la expectativa inicial suele ser la misma: programamos una BGAppRefreshTaskRequest con earliestBeginDate a quince minutos en el futuro, y asumimos que la tarea va a correr quince minutos después, o poco más tarde. En dispositivos reales eso casi nunca pasa así. La tarea puede correr media hora después, puede correr seis horas después, o puede no correr en todo el día si el usuario no abrió la app. earliestBeginDate es exactamente lo que dice su nombre: la fecha más temprana en la que la tarea podría ejecutarse, no la fecha en la que se va a ejecutar. El resto de la decisión —cuándo, si es que ocurre— la toma iOS, y la toma con criterios que no están expuestos a la app y que pueden cambiar entre versiones del sistema operativo.
Qué mira el sistema antes de despertar tu app
BGTaskScheduler no reparte tiempo de ejecución en segundo plano de forma pareja entre todas las apps instaladas. El sistema mantiene un presupuesto de ejecución en segundo plano por app, y ese presupuesto se ajusta según el patrón de uso: una app que el usuario abre con frecuencia y en horarios predecibles recibe más oportunidades de ejecutar tareas en segundo plano que una que el usuario abrió una sola vez y no volvió a tocar. Esto significa que el mismo código, con el mismo earliestBeginDate, se comporta de forma distinta según el dispositivo y el historial de uso de cada usuario, y que no hay forma de forzar ese comportamiento desde la app.
Además del historial de uso, el tipo de tarea importa. BGAppRefreshTaskRequest está pensada para actualizaciones cortas y frecuentes —refrescar un feed, sincronizar contenido liviano— y el sistema es conservador con cuánto tiempo le da. BGProcessingTaskRequest está pensada para trabajo más largo y postergable —reindexar una base de datos local, entrenar un modelo, limpiar caché— y admite condiciones adicionales que el sistema respeta antes de ejecutar la tarea:
let request = BGProcessingTaskRequest(identifier: "com.tuurt.app.mantenimiento")
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60)
request.requiresNetworkConnectivity = true
request.requiresExternalPower = true
try? BGTaskScheduler.shared.submit(request)
Con requiresExternalPower en true, esa tarea de mantenimiento no va a correr hasta que el dispositivo esté cargando, sin importar cuánto tiempo haya pasado desde earliestBeginDate. Es una restricción que conviene usar quirúrgicamente: sirve para trabajo pesado que de verdad no debería competir por batería, pero cada condición adicional reduce todavía más las ventanas en las que el sistema va a considerar viable ejecutar la tarea.
El registro tiene que pasar antes de que termine el lanzamiento
Un error común, y silencioso, es registrar el handler de la tarea en un punto del ciclo de vida de la app que corre después de que application(_:didFinishLaunchingWithOptions:) termina. BGTaskScheduler.shared.register(forTaskWithIdentifier:using:launchHandler:) tiene que ejecutarse durante ese método, no en un viewDidLoad ni en una función llamada de forma diferida. Si el registro llega tarde, el sistema simplemente no tiene un handler asociado al identificador cuando decide lanzar la tarea, y la ejecución no ocurre sin que aparezca ningún error explícito en consola que apunte a la causa real.
El identificador en sí también tiene que declararse en Info.plist, bajo la clave BGTaskSchedulerPermittedIdentifiers, y coincidir exactamente con el string usado tanto en register como en el identifier de la request. Una diferencia de mayúsculas o un typo entre esos tres lugares produce el mismo síntoma que un registro tardío: la tarea nunca corre, y no hay excepción ni log que lo señale directamente.
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.tuurt.app.refresh", using: nil) { task in
self.manejarRefresh(task: task as! BGAppRefreshTask)
}
return true
}
Diseñar la tarea asumiendo que puede no terminar
Cada tarea recibe un expirationHandler que el sistema invoca cuando el tiempo asignado se agota antes de que la tarea llame a setTaskCompleted. Si la tarea no maneja esa expiración —cancelando el trabajo en curso y llamando a setTaskCompleted(success: false)— el sistema la mata de todas formas, pero además penaliza al identificador de esa tarea para lanzamientos futuros, porque desde su perspectiva la app está pidiendo tiempo de ejecución que después no administra bien.
func manejarRefresh(task: BGAppRefreshTask) {
programarSiguienteRefresh()
let operacion = SincronizarContenidoOperation()
task.expirationHandler = {
operacion.cancel()
}
operacion.completionBlock = {
task.setTaskCompleted(success: !operacion.isCancelled)
}
OperationQueue().addOperation(operacion)
}
Nótese que programarSiguienteRefresh() se llama al principio del handler, no al final. Esto no es un detalle de estilo: si la tarea actual expira o el proceso se termina antes de llegar al final del handler, la siguiente solicitud ya quedó programada. Depender de programar la siguiente tarea al final del bloque de éxito deja a la app sin ninguna tarea futura programada si algo interrumpe la ejecución actual, y no hay una segunda oportunidad automática para corregirlo.
Lo que no funcionó: asumir una cadencia y construir la funcionalidad sobre eso
En un proyecto construimos una función de sincronización de borradores que dependía de BGAppRefreshTaskRequest corriendo aproximadamente cada quince o veinte minutos mientras el usuario tenía la app instalada, para que un borrador escrito en un dispositivo apareciera pronto en los demás. La función funcionó bien en pruebas internas, donde el equipo abría la app varias veces al día y el sistema, en consecuencia, le daba ventanas de ejecución frecuentes. En producción, con usuarios que abrían la app una o dos veces por semana, el mismo código dejaba borradores sin sincronizar durante días, porque el presupuesto de segundo plano que el sistema le asignaba a esos patrones de uso era mucho menor que el que veíamos en desarrollo. No era un bug: era el comportamiento documentado del framework funcionando exactamente como está diseñado, aplicado a un caso de uso que necesitaba una garantía de tiempo que BGTaskScheduler nunca ofreció.
La corrección no fue ajustar parámetros de la request, porque ningún parámetro convierte una ejecución en segundo plano en una ejecución garantizada. Movimos la sincronización de borradores a dos disparadores distintos: sincronización inmediata cuando la app pasa a primer plano, que cubre el caso más común, y una notificación push silenciosa (content-available) para el caso en que otro dispositivo necesita enterarse de un cambio sin que el usuario haya abierto la app. Dejamos BGProcessingTaskRequest únicamente para trabajo de mantenimiento donde el retraso no afecta a ningún usuario de forma directa, como comprimir archivos de caché antiguos o recalcular índices de búsqueda local, que es exactamente el tipo de tarea para la que el framework está pensado.
Qué revisamos antes de dar por buena una tarea en segundo plano
Antes de confiar funcionalidad visible al usuario a BGTaskScheduler, verificamos que el registro del handler ocurra durante el lanzamiento de la app y no después, que el identificador coincida exactamente entre Info.plist, register y la request, que la siguiente solicitud se programe al inicio del handler y no al final, y que el expirationHandler cancele el trabajo en curso en vez de dejarlo corriendo sin control. Y sobre todo, que la funcionalidad tolere que la tarea tarde horas en correr o no corra en absoluto durante un día entero, porque esa es la garantía real que ofrece el framework: mejor esfuerzo cuando el sistema lo considera oportuno, nunca una cadencia fija que la app pueda dar por sentada.