Hace poco lancé las descargas offline de la app iOS de DIALØGUE — justo la parte que en mi post anterior despaché con la mano como "offline resistente" sin explicar nunca qué significaba eso. Este post es la explicación. Y empieza conmigo a punto de construir lo que no era.
Aquí va la confesión. DIALØGUE es una app de podcasts con IA: le das un tema o un PDF y genera una conversación entre dos presentadores. Así que cuando empecé con el modo offline, mi cabeza saltó directo a algo con forma de IA. Quizá "offline" significaba pre-generar el audio en el dispositivo. Quizá cachear la salida del modelo, o alguna pipeline ingeniosa capaz de reconstruir un episodio sin red. Tenía toda una arquitectura formándose en la cabeza. Por poco escribo el design doc de esa manera.
Entonces me detuve y me hice una pregunta más aburrida: ¿qué me da iOS ya de fábrica para esto?
La respuesta, resultó, era "casi todo". Y el trabajo de verdad de esta funcionalidad no era construir algo inteligente. Era resistir las ganas de hacerlo.
La pregunta aburrida me ahorró un mes
Esta es la lección que vuelvo a aprender post tras post: la complejidad no es tu amiga. Sobre todo cuando eres una sola persona construyendo de noche y los fines de semana.
Un podcast con IA, una vez generado, es solo un archivo de audio que vive en una URL. "Offline" no significa volver a generarlo. Significa descargar ese archivo y reproducirlo más tarde. Eso no es un problema de IA. Es un problema que Apple lleva resolviendo desde antes de que existiera la App Store.
Así que en lugar de una pipeline ingeniosa, eché mano de la herramienta más aburrida y probada de la caja: una background URLSession. Si nunca te has topado con ella, la idea es simple — le entregas la descarga al sistema operativo, y este sigue adelante incluso cuando tu app está en segundo plano o la han cerrado, y luego vuelve a abrir tu app para entregarte el archivo terminado.
let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.downloads")
config.sessionSendsLaunchEvents = true
Unas dos líneas, y ahora el sistema operativo se encarga de las partes genuinamente difíciles. No escribí un motor de descargas. Tomé prestado el de Apple.
La misma historia se repetía mirara donde mirara:
- ¿Descarga interrumpida a la mitad? iOS te entrega "resume data" — un pequeño blob que permite que una descarga retome donde se quedó en vez de empezar desde cero. Yo solo lo escribo en disco (en la carpeta
Caches, porque es desechable) y se lo paso de vuelta la próxima vez. - ¿No quieres quemar los datos móviles del usuario? Una línea:
request.allowsCellularAccess = !wifiOnly. Después el sistema operativo espera el WiFi por su cuenta. No monitoreo la conexión. No hago polling de nada. - ¿Necesitas mostrar cuánto espacio ocupan las descargas?
ByteCountFormatterconvierte un conteo de bytes en "23,4 MB" con las unidades correctamente localizadas. Eso tampoco lo escribí yo.
Quiero ser honesto sobre lo bien que se siente esto, porque va en contra del instinto. Visto en retrospectiva, la jugada correcta no era código más ingenioso. Era admitir que esto era un problema ya resuelto y tomar prestado el trabajo en vez de intentar ser más listo que él.
Pero "aburrido" no significaba "fácil"
Ahora tengo que admitir algo, porque si te dijera que las prácticas estándar volvieron esto trivial, estaría mintiendo — y cualquier desarrollador de iOS que lea esto me pillaría en unos diez segundos.
Estándar no es lo mismo que trivial. Las herramientas aburridas son aburridas porque están muy rodadas, no porque no tengan fricción. Estas son las cosas que de verdad me costaron tiempo, por si la lista te ahorra algo:
1. El archivo temporal desaparece mientras lo estás mirando. Cuando una descarga en segundo plano termina, iOS te llama de vuelta y apunta al archivo en una ubicación temporal. La trampa que nadie menciona: ese archivo se borra en el instante en que tu callback retorna. Tienes que moverlo a un lugar permanente de forma síncrona, ahí mismo dentro de la función, antes de hacer cualquier otra cosa. Se pone más incómodo, porque ese callback corre fuera del hilo principal y solo sabe qué descarga terminó, no a qué episodio pertenece en los términos de mi app. Así que el código hace un feo paso doble: mover el archivo a un nombre temporal de inmediato, y luego saltar al hilo principal para renombrarlo una vez que sabe qué es. No es bonito. Pero la alternativa es un archivo que desaparece antes de que puedas guardarlo.
2. Las sesiones en segundo plano no hacen nada, en silencio, en el simulador. Pasé un tramo vergonzoso convencido de que mi código estaba roto. No lo estaba. La background URLSession simplemente no se comporta de forma fiable en el Simulador de iOS — sin error, sin crash, no se descarga nada. El arreglo es feo y me hago cargo:
#if DEBUG
// Use a default session in the simulator — background sessions can silently fail
session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
#else
session = URLSession(configuration: .background(withIdentifier: identifier), delegate: self, delegateQueue: nil)
#endif
Ese #if DEBUG no es elegante. Pero fingir que el simulador se comporta como un dispositivo real me habría costado mucho más que admitir que no lo hace.
3. Swift 6 me obligó a decir exactamente quién corre en qué hilo. Los callbacks de descarga llegan en un hilo de segundo plano. Mi estado de UI — los anillitos de progreso, la palomita verde de "Descargado" — tiene que actualizarse en el hilo principal. El chequeo de concurrencia más estricto de Swift 6 se negó a dejarme ser descuidado en esa frontera. Terminé con un commit de limpieza titulado, literalmente, "resolve Swift 6 actor-isolation warnings." Molesto en el momento, correcto en retrospectiva. El compilador tenía razón y yo estaba equivocado.
4. El servidor no siempre nombra el archivo de forma limpia. El audio a veces vuelve como audio/mpeg, a veces como m4a, de vez en cuando como otra cosa. Así que hay una funcioncita sosa que deduce la extensión del archivo a partir del content type de la respuesta, recurre a la URL si falla, y por defecto usa .mp3. Nada glamoroso. Necesario.
Ninguno de estos es un problema de IA. Cada uno es un problema de "cómo funcionan realmente las computadoras". Ese es el impuesto del camino aburrido. Es real — pero lo pagaría cada vez antes que el costo de mantener algo ingenioso que inventé yo mismo.
Lo que deliberadamente NO construí
Creo que la parte más útil de toda esta funcionalidad es la lista de cosas que no hice:
- No escribí un motor de descargas. (Background
URLSession.) - No escribí un monitor de conectividad. (
allowsCellularAccess.) - No inventé un protocolo de reanudación. (Resume data del sistema operativo.)
- No construí nada específico de IA. (Es un archivo. Se descarga.)
En lo que sí puse cuidado fue pequeño y nada glamoroso: una cola diminuta para que no corran más de tres descargas a la vez y el resto espere su turno; persistir los títulos de los episodios para que la pantalla de almacenamiento muestre nombres reales tras un arranque en frío en vez de "Episodio, Episodio, Episodio"; ordenar esa pantalla por archivo más grande primero, para que cuando intentes liberar espacio los peores culpables queden arriba. Y saqué las decisiones de verdad — cuántos espacios quedan libres, cómo ordenar la lista — a funciones simples sin ninguna URLSession pegada, para poder hacerles unit-test como es debido. Aburrido. Testeable. Aburrido porque testeable.
La lección, por enésima vez
A estas alturas ya he escrito alguna versión de este post sobre reconstruir mi web, sobre una factura de API de 54 dólares, sobre reconstruir esta misma app. La lección siempre es la misma, y no dejo de necesitar volver a aprenderla: la solución ingeniosa suele ser la cara. "¿Qué me da ya la plataforma?" — esa es la pregunta más valiosa que puedo hacerme antes de escribir una sola línea de código.
Para un solo builder, en especial: cada cosa ingeniosa que inventas es una cosa que tú solo tendrás que mantener cuando se rompa en el peor momento posible. Cada cosa aburrida y estándar que tomas prestada la mantiene una empresa con miles de ingenieros detrás. Por mi experiencia, ese trato casi siempre vale la pena — y, sinceramente, la disciplina para hacerlo es más difícil que la astucia que reemplaza. Aunque podría estar equivocado sobre dónde está exactamente la línea. Seguro hay problemas en los que la plataforma no te da nada y tienes que construir tú mismo la parte difícil — solo que no dejo de comprobar que eso es más raro de lo que mi instinto insiste.
Algunas de las personas que usan DIALØGUE ahora pueden descargar un episodio, subirse a un avión y escucharlo sin señal. Se siente como una funcionalidad que importa. El código detrás es casi agresivamente anodino. Ya hice las paces con eso. Creo que ese es el trabajo.
¿Tú también haces esto — pillarte estirando la mano hacia la versión ingeniosa cuando la aburrida estaba justo ahí? ¿O soy solo yo, lento para aprender la misma lección una y otra vez? De verdad me gustaría saber cómo combaten otros este instinto.
Un abrazo, Chandler