Background App Refresh en SwiftUI: actualiza datos sin abrir la app
Arturo Rivas Arias
🔄 Las tareas en segundo plano son una de esas capacidades de iOS que parecen sencillas hasta que intentas implementar alguna de ellas. La idea es muy tentadora: permitir que una app actualice datos, limpie cachés o prepare contenido aunque el usuario no la tenga en primer plano. En SwiftUI, además, Apple ofrece una integración bastante cómoda mediante el modificador .backgroundTask, pero eso no significa que el sistema vaya a ejecutar nuestro código cuando queramos ni durante todo el tiempo que nos gustaría.
📱 El caso más habitual es el de Background App Refresh, pensado para trabajos breves. Una app de noticias puede descargar titulares recientes, una app de hábitos puede preparar el resumen del día, una app de viajes puede refrescar reservas próximas, y una app de productividad puede sincronizar pequeños cambios pendientes. No hablamos de mantener una app viva indefinidamente, ni de ejecutar lógica crítica en una hora exacta, sino de pedirle al sistema una ventana de ejecución para hacer una tarea corta cuando considere que es un momento propicio.
🧠 Ese matiz es fundamental. Cuando programamos una tarea con BGTaskScheduler, no estamos creando una alarma. Estamos enviando una solicitud. iOS decide si la ejecuta, cuándo la ejecuta y durante cuánto tiempo, teniendo en cuenta factores como batería, uso reciente de la app, conectividad, modo de bajo consumo y hábitos del usuario. Por eso una actualización en segundo plano nunca debería ser la única forma de mantener los datos actualizados. La app debe funcionar bien aunque esa tarea no llegue a ejecutarse nunca.
⚙️ Para usar este mecanismo hay tres piezas que deben encajar: activar la capability en Xcode, declarar los identificadores permitidos en el Info.plist y registrar un handler en el ciclo de vida de SwiftUI. Si falta cualquiera de ellas, la tarea no funcionará correctamente. En algunos casos el fallo será bastante directo: si se programa una tarea para la que no existe un handler registrado, la app puede terminar con una excepción indicando que no hay un launch handler para ese identificador.
🧩 La configuración empieza en el target de la app, dentro de Signing & Capabilities. Hay que añadir Background Modes y activar Background fetch. Después, en el Info.plist, se debe declarar la clave BGTaskSchedulerPermittedIdentifiers, que Xcode muestra como Permitted background task scheduler identifiers. Cada identificador que vayamos a usar debe aparecer en ese array. Un patrón habitual es construirlo a partir del bundle identifier de la app más un sufijo descriptivo para evitar colisión de nombres.
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.example.RecipeBox.refresh</string>
</array>
📦 A partir de ahí podemos encapsular la lógica en un objeto observable. El ejemplo siguiente no usa la temática del artículo original: imaginemos una app de recetas que muestra sugerencias diarias. La tarea en segundo plano intenta descargar una selección pequeña y guardarla en caché para que la pantalla principal aparezca actualizada cuando el usuario abra la app.
import BackgroundTasks
import Foundation
import Observation
@Observable
final class RecipeSuggestionStore {
let refreshTaskIdentifier = "com.example.RecipeBox.refresh"
private let cacheURL: URL
var suggestions: [RecipeSuggestion] = []
var lastRefreshDate: Date?
init(cacheURL: URL) {
self.cacheURL = cacheURL
}
func load() async {
await loadCachedSuggestions()
if shouldRefreshOnLaunch {
await refreshSuggestions()
}
scheduleBackgroundRefresh()
}
func refreshSuggestions() async {
do {
let suggestions = try await RecipeAPI.fetchDailySuggestions()
self.suggestions = suggestions
self.lastRefreshDate = Date()
try await RecipeCache.save(suggestions, to: cacheURL)
} catch {
// En una tarea en segundo plano conviene fallar de forma silenciosa
// y conservar la caché anterior si sigue siendo válida.
print("No se pudieron actualizar las sugerencias: \(error)")
}
}
private var shouldRefreshOnLaunch: Bool {
guard let lastRefreshDate else { return true }
return !Calendar.current.isDateInToday(lastRefreshDate)
}
private func loadCachedSuggestions() async {
suggestions = (try? await RecipeCache.load(from: cacheURL)) ?? []
}
}
🗓️ La programación de la tarea se realiza con BGAppRefreshTaskRequest. Esta clase representa una solicitud de actualización ligera en segundo plano. Podemos indicar una fecha mínima con earliestBeginDate, pero esa fecha no está garantizada. Solo expresa que no queremos que el sistema ejecute la tarea antes de ese momento.
extension RecipeSuggestionStore {
func scheduleBackgroundRefresh() {
let request = BGAppRefreshTaskRequest(identifier: refreshTaskIdentifier)
// Pedimos una oportunidad a partir de mañana por la mañana,
// pero iOS decidirá el momento real de ejecución.
request.earliestBeginDate = Calendar.current.nextDate(
after: Date(),
matching: DateComponents(hour: 7, minute: 30),
matchingPolicy: .nextTime
)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("No se pudo programar el refresh en segundo plano: \(error)")
}
}
}
🚨 Un error frecuente es programar la tarea una sola vez y olvidarse de ella. En la práctica, conviene volver a programarla después de cada ejecución correcta o después de que la aplicación haya estado en primer plano. El sistema no interpreta nuestra solicitud como una recurrencia permanente. Si queremos que haya futuras oportunidades de actualización, debemos volver a enviar una nueva solicitud.
🏁 En una app SwiftUI, el registro del handler se hace en Scene con .backgroundTask. Para una tarea de tipo app refresh, usamos .appRefresh y pasamos el mismo identificador que hemos declarado en el Info.plist y usado al crear la solicitud.
import SwiftUI
@main
struct RecipeBoxApp: App {
@State private var store = RecipeSuggestionStore(
cacheURL: URL.cachesDirectory.appending(path: "daily-recipes.json")
)
var body: some Scene {
WindowGroup {
RecipeHomeView()
.environment(store)
.task {
await store.load()
}
}
.backgroundTask(.appRefresh(store.refreshTaskIdentifier)) {
await store.refreshSuggestions()
store.scheduleBackgroundRefresh()
}
}
}
⏱️ El trabajo dentro del bloque debe ser corto. Apple no ofrece este mecanismo para procesamientos largos ni para descargas pesadas. Si la tarea tarda demasiado, el sistema puede finalizar la app y penalizar futuras ejecuciones. Como regla práctica, el bloque debería hacer lo mínimo imprescindible: consultar conjuntos de datos pequeños, actualizar una caché local y salir. Para transferencias de red largas es más apropiado usar una URLSession en segundo plano; para tareas más costosas puede encajar mejor BGProcessingTaskRequest, siempre que el caso de uso lo justifique.
🧱 El diseño de caché es casi más importante que la propia tarea. Una buena implementación no debería asumir que el refresh se ha ejecutado. La pantalla principal debe cargar primero lo que tenga en disco, decidir si está suficientemente actualizado y si es válido y, si hace falta, forzar la actualización de nuevo en primer plano. La tarea en segundo plano solo mejora la experiencia: reduce esperas, prepara contenido y evita que el usuario vea datos antiguos al abrir la app.
struct RecipeHomeView: View {
@Environment(RecipeSuggestionStore.self) private var store
var body: some View {
List(store.suggestions) { suggestion in
VStack(alignment: .leading, spacing: 4) {
Text(suggestion.title)
.font(.headline)
Text(suggestion.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.overlay {
if store.suggestions.isEmpty {
ContentUnavailableView(
"Sin sugerencias todavía",
systemImage: "fork.knife",
description: Text("Abre la app con conexión para cargar recetas recientes.")
)
}
}
}
}
🧪 Probar estas tareas también tiene truco. No basta con ejecutar la app y esperar. Primero hay que asegurarse de que la solicitud se ha registrado. Para depuración, puede ser útil consultar las peticiones pendientes justo después de llamar a submit.
func printPendingBackgroundRequests() async {
let requests = await BGTaskScheduler.shared.pendingTaskRequests()
for request in requests {
print("Background request pendiente: \(request.identifier)")
}
}
🛠️ Después, con la app ejecutándose en un dispositivo físico desde Xcode, se puede pausar el proceso y simular el lanzamiento de la tarea desde la consola del depurador. Es una técnica de depuración, no una API pública para producción, pero resulta muy útil para validar que el handler se invoca y que la caché queda en el estado esperado.
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.RecipeBox.refresh"]
🔍 Al depurar, conviene comprobar cuatro cosas:
- Que el identificador coincide exactamente en el
Info.plist, en la solicitud y en.backgroundTask. - Que la capacidad Background fetch está activada.
- Que se está probando en un dispositivo real, porque el comportamiento del simulador no representa bien este tipo de ejecución. - Que el usuario no tiene desactivada la actualización en segundo plano para la app o para el sistema completo.
🧯 También merece la pena procesar los errores de forma conservadora. Si falla la red, no borres la caché anterior. Si el servidor devuelve datos incompletos, conserva el último estado válido. Si la tarea se cancela, termina rápido y deja el sistema en un estado consistente. Una tarea en segundo plano no es el sitio ideal para flujos complejos de recuperación, pantallas de error ni reintentos agresivos.
extension RecipeSuggestionStore {
func refreshSuggestionsSafely() async {
guard !Task.isCancelled else { return }
do {
let newSuggestions = try await RecipeAPI.fetchDailySuggestions()
guard !Task.isCancelled else { return }
guard !newSuggestions.isEmpty else { return }
suggestions = newSuggestions
lastRefreshDate = Date()
try await RecipeCache.save(newSuggestions, to: cacheURL)
} catch is CancellationError {
return
} catch {
print("Refresh conservador: se mantiene la caché anterior")
}
}
}
📐 La arquitectura ideal separa tres responsabilidades. La vista muestra datos. El almacén o store decide cuándo cargar, cuándo refrescar y cuándo programar la siguiente tarea. La capa de persistencia guarda una representación local de lectura rápida. Si todo queda mezclado en la vista principal, el código acaba dependiendo demasiado del ciclo de vida de SwiftUI y resulta más difícil aventurar qué ocurre cuando la app se despierta sin interfaz visible.
✅ BackgroundTasks y .backgroundTask encajan muy bien con SwiftUI cuando se usan con expectativas realistas. No son una forma de ejecutar código a una hora exacta ni de mantener una app sincronizada en todo momento. Son una oportunidad controlada por el sistema para hacer pequeños trabajos que mejoran la experiencia. Si el diseño acepta esa incertidumbre, el resultado es una app que se siente más rápida, consume menos recursos y respeta mejor las reglas de iOS.