wandres.dev
REDES EN APPS · APIs y sincronización

Subidas y descargas en segundo plano

La sesión de background como transferencia delegada a un proceso del sistema que sobrevive a la muerte de la app: por qué prohíbe async/await y obliga al delegado, cómo el sistema relanza la app para entregar el resultado, qué hay que mover de sitio en el instante exacto, y por qué todo estado que viva solo en memoria es estado que vas a perder.

⏱ 22 min

Todo lo construido hasta aquí comparte una premisa silenciosa: que la app está viva mientras la petición viaja. Es una premisa razonable para un JSON de treinta kilobytes y es falsa para un vídeo de dos gigabytes, porque el usuario cambiará de app, bloqueará el teléfono y el sistema, que administra memoria y batería sin pedir permiso, suspenderá tu proceso y con el tiempo lo matará. La sesión de background existe para ese caso, y no es una variante de URLSession con otra bandera: es un modelo de ejecución distinto, donde la transferencia deja de pertenecerte y pasa a un demonio del sistema que trabaja aunque tu app ya no exista. Aceptar esa cesión —y programar en consecuencia— es todo el contenido de esta lección.

🎯 Al terminar esta lección sabrás
  • Entender por qué una transferencia larga no cabe en el ciclo de vida de la app.
  • Configurar una sesión de background y respetar sus restricciones reales.
  • Implementar el delegado como único punto de reentrada del resultado.
  • Sobrevivir a la muerte del proceso persistiendo el estado imprescindible.

Lo que no cabe en el ciclo de vida de la app

Cuando el usuario sale de la app, iOS le concede unos segundos y después la suspende: el proceso sigue en memoria pero no ejecuta nada. Una URLSession normal se congela con él; si la memoria escasea, el sistema termina el proceso sin aviso y la transferencia muere sin dejar rastro. Ese comportamiento no es un defecto que haya que sortear con trucos, es la política de recursos que hace que un teléfono dure el día, y ninguna app la sortea.

La salida que ofrece el sistema es de otra naturaleza: dejar de transferir tú y encargarlo. Una sesión de background entrega la descripción de la transferencia a un demonio del sistema que la ejecuta en su propio proceso, con sus propias decisiones sobre red y energía. La consecuencia es liberadora y exigente a la vez. Liberadora, porque la transferencia continúa con la app suspendida, terminada o incluso tras un reinicio del dispositivo. Exigente, porque el resultado llegará en un momento que no controlas, posiblemente a una instancia recién nacida de tu app que no recuerda nada de lo que la anterior estaba haciendo.

⚠️
El cierre forzado por el usuario sí lo detiene

Si el usuario expulsa la app deslizándola en el conmutador, iOS interpreta que no la quiere ejecutando y no la relanzará para entregar eventos de background. Las transferencias quedan pendientes hasta el siguiente arranque manual. Es una decisión deliberada del sistema y no hay forma legítima de esquivarla: diseña para que la reanudación al arrancar sea normal, no excepcional.

La sesión de background y sus reglas

La configuración se crea con un identificador que debe ser estable entre ejecuciones y único en la app, porque es la única llave para reconectar con las transferencias en curso tras un relanzamiento.

let configuracion = URLSessionConfiguration.background(withIdentifier: "com.miapp.transferencias")
configuracion.isDiscretionary = true              // el sistema elige el mejor momento
configuracion.sessionSendsLaunchEvents = true     // relanzame para entregar el resultado
configuracion.timeoutIntervalForResource = 24 * 60 * 60

let sesion = URLSession(configuration: configuracion,
                        delegate: CoordinadorTransferencias.compartido,
                        delegateQueue: nil)

Las restricciones no son negociables y sorprenden a quien llega desde el mundo de async/await. No hay métodos de conveniencia: las variantes con closure de finalización y las asíncronas están prohibidas en una sesión de background, porque asumen que quien las llamó seguirá vivo para recibir la respuesta. Solo existen downloadTask y uploadTask; una dataTask no tiene sentido cuando el resultado debe sobrevivir al proceso. Y una subida debe partir de un fichero en disco, no de un Data en memoria, por la misma razón: el demonio necesita leer el contenido cuando tu proceso ya no está.

// Descarga: solo la URL, el resultado llega al delegado.
sesion.downloadTask(with: url).resume()

// Subida: siempre desde fichero, nunca desde memoria.
var peticion = URLRequest(url: destino)
peticion.httpMethod = "PUT"
sesion.uploadTask(with: peticion, fromFile: ficheroLocal).resume()

isDiscretionary merece un comentario aparte. Al activarlo cedes al sistema la decisión de cuándo transferir, y el sistema esperará a que haya wifi y batería o corriente. Es lo correcto para una copia de seguridad y es un desastre para algo que el usuario está mirando; nota además que las tareas creadas mientras la app está en segundo plano se tratan como discrecionales aunque no lo pidas.

El delegado: el único punto de reentrada

Con las conveniencias prohibidas, todo el resultado llega por el delegado, y dos de sus métodos concentran los errores más caros. El primero entrega la descarga en una URL temporal cuyo fichero se borra en cuanto el método retorna: hay que moverlo de forma síncrona, sin saltos a otra tarea asíncrona ni despachos diferidos. El segundo informa del final de cualquier tarea, con o sin error.

final class CoordinadorTransferencias: NSObject, URLSessionDownloadDelegate {
    static let compartido = CoordinadorTransferencias()
    var alTerminarEventos: (() -> Void)?

    func urlSession(_ sesion: URLSession,
                    downloadTask tarea: URLSessionDownloadTask,
                    didFinishDownloadingTo temporal: URL) {
        // Sincrono y ahora: al volver de este metodo el fichero ya no existe.
        let destino = URL.documentsDirectory.appending(path: tarea.taskDescription ?? "archivo")
        try? FileManager.default.removeItem(at: destino)
        try? FileManager.default.moveItem(at: temporal, to: destino)
        Registro.transferencias.marcarCompletada(tarea.taskDescription)
    }

    func urlSession(_ sesion: URLSession, task tarea: URLSessionTask, didCompleteWithError error: Error?) {
        guard let error else { return }
        let reanudable = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data
        Registro.transferencias.marcarFallida(tarea.taskDescription, datosReanudacion: reanudable)
    }

    func urlSessionDidFinishEvents(forBackgroundURLSession sesion: URLSession) {
        DispatchQueue.main.async { self.alTerminarEventos?(); self.alTerminarEventos = nil }
    }
}

Los datos de reanudación del último método son valiosos: permiten retomar una descarga interrumpida desde donde iba con downloadTask(withResumeData:) en lugar de empezar de cero. Guárdalos en disco, no en una propiedad.

📥

Solo descarga y subida

Una dataTask acumula bytes en memoria y por eso está prohibida: el resultado debe poder aterrizar en un fichero.

🧾

Delegado obligatorio

Las variantes con closure y las asíncronas asumen un llamante vivo. En background no lo hay, así que no existen.

💾

Subir desde fichero

El demonio lee el contenido cuando le conviene, quizá horas después. Un Data en memoria ya no estará ahí.

Conviene también informar del progreso, porque una transferencia larga sin retroalimentación es indistinguible de una transferencia rota. El delegado recibe avisos periódicos con los bytes escritos y el total esperado, y ese es el único sitio donde alimentar la interfaz sin inventarse estimaciones. Ten en cuenta que el total puede ser desconocido si el servidor no envía la longitud, y que un indicador determinado que salta de golpe al final es peor que uno indeterminado honesto.

Cuando el sistema mata la app

Esta es la parte que distingue una integración correcta de una que funciona en el simulador y falla en manos de usuarios. Cuando la transferencia termina y tu app no está viva, el sistema la relanza en segundo plano y llama al delegado de aplicación con el identificador de la sesión. Tu trabajo es recrear la sesión con ese mismo identificador —lo que la reconecta con sus tareas— y guardar el bloque de finalización que el sistema te entrega, para invocarlo cuando el delegado avise de que ya no quedan eventos. Si no lo llamas, iOS considerará que tu app abusa del tiempo de background y la penalizará.

final class DelegadoApp: NSObject, UIApplicationDelegate {
    func application(_ app: UIApplication,
                     handleEventsForBackgroundURLSession identificador: String,
                     completionHandler: @escaping () -> Void) {
        _ = ServicioTransferencias.compartido.sesion(con: identificador)  // recrear y reconectar
        CoordinadorTransferencias.compartido.alTerminarEventos = completionHandler
    }
}

@main
struct MiApp: App {
    @UIApplicationDelegateAdaptor(DelegadoApp.self) var delegado
    var body: some Scene { WindowGroup { RaizView() } }
}
flowchart TD
A[La app crea la tarea y llama a resume] --> B[El demonio del sistema transfiere]
B --> C[La app es suspendida o terminada]
C --> D[La transferencia continua fuera del proceso]
D --> E[El sistema relanza la app en segundo plano]
E --> F[handleEventsForBackgroundURLSession]
F --> G[Recrear la sesion con el mismo identificador]
G --> H[El delegado recibe el fichero temporal]
H --> I[Mover el fichero de forma sincrona]
I --> J[Llamar al completion handler guardado]
style D fill:#89b4fa,color:#11111b
style I fill:#f9e2af,color:#11111b
style J fill:#a6e3a1,color:#11111b

De ahí la consecuencia práctica más importante: cualquier estado que solo viva en memoria ha desaparecido. El diccionario que asociaba el identificador numérico de la tarea con el mensaje del usuario, el cierre que ibas a ejecutar al terminar, el modelo que estaba en pantalla: nada de eso sobrevive. Lo que sí sobrevive es lo que escribiste en disco, y por eso la técnica canónica consiste en marcar cada tarea con un taskDescription que sea la clave de un registro persistente, y en llamar a getAllTasks al arrancar para reconciliar lo que el sistema recuerda con lo que tu registro dice.

Programar para el segundo plano es programar sin dar por hecho que existirás

El modelo mental que casi todo el mundo trae —la app pide algo, espera y recibe la respuesta— descansa en un supuesto tan cotidiano que se vuelve invisible: que el sujeto que pregunta seguirá ahí cuando llegue la contestación. La sesión de background retira ese supuesto, y al retirarlo revela que no era una propiedad de la programación sino un lujo del primer plano. Lo que queda cuando desaparece es una forma de diseñar mucho más cercana a los sistemas distribuidos que a la interfaz: la transferencia deja de ser una llamada dentro de tu programa y se convierte en un trabajo encargado a otro proceso, con un identificador acordado, un contrato de entrega y ninguna garantía sobre cuándo ni a quién se entregará el resultado, porque quien lo reciba será una instancia futura de tu app que no comparte memoria con la que lo pidió. Por eso las restricciones de la API no son arbitrariedades: no hay async porque no hay continuación a la que volver, no hay subida desde memoria porque tu memoria puede no existir cuando toque leerla, y el fichero descargado se borra al retornar del delegado porque el sistema no puede reservar espacio indefinidamente para un proceso que quizá vuelva a morir. La disciplina que todo esto impone se resume en una frase que conviene tener presente mucho más allá de este tema: el estado que importa vive en disco, la identidad se acuerda de antemano y toda reanudación debe ser una reconciliación, no un recuerdo. Escribir código así cuesta más y produce apps que sobreviven a lo que el sistema decida hacer con ellas, que es exactamente el estándar que iOS impone y que sus usuarios dan por descontado.

⚔️ Sobrevive a tu propia muerte
  1. Crea una sesión de background con identificador estable y comprueba que los métodos asíncronos de conveniencia no compilan con ella.
  2. Lanza una descarga larga, sal de la app y verifica que continúa; después mata el proceso desde Xcode y observa el relanzamiento.
  3. Mueve el fichero descargado de forma síncrona en el delegado y demuestra qué ocurre si lo intentas más tarde.
  4. Persiste un registro que asocie taskDescription con la operación de dominio y reconcílialo al arrancar con getAllTasks.
  5. Guarda los datos de reanudación de una descarga fallida y retómala desde donde iba en el siguiente arranque.