J'ai récemment publié les téléchargements offline de l'app iOS de DIALØGUE — exactement la partie que, dans mon dernier article, j'ai balayée d'un revers de main en parlant d'« offline résilient » sans jamais expliquer ce que ça voulait dire. Cet article, c'est l'explication. Et ça commence par moi sur le point de construire la mauvaise chose.
Voici l'aveu. DIALØGUE est une app de podcasts avec l'IA : tu lui donnes un sujet ou un PDF, et elle génère une conversation entre deux animateurs. Donc quand j'ai attaqué le mode offline, mon cerveau a sauté direct vers un truc en forme d'IA. Peut-être qu'« offline » voulait dire pré-générer l'audio sur l'appareil. Peut-être mettre en cache la sortie du modèle, ou une pipeline maligne capable de reconstruire un épisode sans réseau. Toute une architecture se formait dans ma tête. J'ai failli écrire le design doc comme ça.
Puis je me suis arrêté et je me suis posé une question plus ennuyeuse : qu'est-ce qu'iOS me donne déjà pour ça ?
La réponse, il s'est avéré, c'était « presque tout ». Et le vrai travail de cette fonctionnalité, ce n'était pas de construire quelque chose d'intelligent. C'était de résister à l'envie de le faire.
La question ennuyeuse m'a fait gagner un mois
Voici la chose que je réapprends article après article : la complexité n'est pas ton amie. Surtout quand tu es seul à construire le soir et le week-end.
Un podcast IA, une fois généré, n'est qu'un fichier audio posé sur une URL. « Offline » ne veut pas dire le régénérer. Ça veut dire télécharger ce fichier et le lire plus tard. Ce n'est pas un problème d'IA. C'est un problème qu'Apple résout depuis avant même l'existence de l'App Store.
Alors au lieu d'une pipeline maligne, j'ai attrapé l'outil le plus ennuyeux et le plus éprouvé de la boîte : une background URLSession. Si tu ne l'as jamais croisée, l'idée est simple — tu confies le téléchargement au système d'exploitation, et il continue même quand ton app est en arrière-plan ou a été tuée, puis il relance ton app pour te remettre le fichier terminé.
let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.downloads")
config.sessionSendsLaunchEvents = true
Environ deux lignes, et maintenant le système possède les parties vraiment difficiles. Je n'ai pas écrit de moteur de téléchargement. J'ai emprunté celui d'Apple.
La même histoire se répétait partout où je regardais :
- Téléchargement coupé en plein milieu ? iOS te remet des « resume data » — un petit blob qui permet à un téléchargement de reprendre là où il s'est arrêté au lieu de repartir de zéro. Je l'écris simplement sur le disque (dans le dossier
Caches, puisque c'est jetable) et je le repasse la fois suivante. - Tu ne veux pas griller les données mobiles de l'utilisateur ? Une ligne :
request.allowsCellularAccess = !wifiOnly. Ensuite le système attend le WiFi tout seul. Je ne surveille pas la connexion. Je ne fais de polling sur rien. - Tu dois montrer combien d'espace prennent les téléchargements ?
ByteCountFormattertransforme un nombre d'octets en « 23,4 Mo » avec les bonnes unités localisées. Ça non plus, ce n'est pas moi qui l'ai écrit.
Je veux être honnête sur le plaisir que ça procure, parce que ça va à l'encontre de l'instinct. Avec le recul, le bon coup, ce n'était pas du code plus malin. C'était d'admettre que c'était un problème déjà résolu et d'emprunter le travail au lieu d'essayer d'être plus malin que lui.
Mais « ennuyeux » ne voulait pas dire « facile »
Maintenant je dois avouer quelque chose, parce que si je te disais que les pratiques standard ont rendu ça trivial, je mentirais — et n'importe quel développeur iOS qui lit ça me prendrait la main dans le sac en une dizaine de secondes.
Standard, ce n'est pas la même chose que trivial. Les outils ennuyeux sont ennuyeux parce qu'ils sont rodés, pas parce qu'ils sont sans frottement. Voici les choses qui m'ont vraiment coûté du temps, au cas où la liste t'en ferait gagner :
1. Le fichier temporaire disparaît pendant que tu le regardes. Quand un téléchargement en arrière-plan se termine, iOS te rappelle et pointe vers le fichier à un emplacement temporaire. Le piège que personne ne mentionne : ce fichier est supprimé à l'instant où ton callback retourne. Tu dois le déplacer vers un endroit permanent de façon synchrone, là, dans la fonction, avant de faire quoi que ce soit d'autre. Ça devient plus pénible encore, parce que ce callback tourne hors du thread principal et sait seulement quel téléchargement est terminé, pas quel épisode c'est dans les termes de mon app. Donc le code fait un petit pas de deux moche : déplacer le fichier vers un nom temporaire tout de suite, puis sauter sur le thread principal pour le renommer une fois qu'il sait ce que c'est. Pas joli. Mais l'alternative, c'est un fichier déjà parti avant que tu puisses le sauvegarder.
2. Les sessions en arrière-plan ne font rien, en silence, dans le simulateur. J'ai passé un moment gênant convaincu que mon code était cassé. Il ne l'était pas. La background URLSession ne se comporte tout simplement pas de façon fiable dans le simulateur iOS — pas d'erreur, pas de crash, rien ne se télécharge. Le correctif est moche et je l'assume :
#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
Ce #if DEBUG n'est pas élégant. Mais faire comme si le simulateur se comportait comme un vrai appareil m'aurait coûté bien plus cher que d'admettre que non.
3. Swift 6 m'a forcé à dire exactement qui tourne sur quel thread. Les callbacks de téléchargement arrivent sur un thread d'arrière-plan. Mon état d'UI — les petits anneaux de progression, la coche verte « Téléchargé » — doit se mettre à jour sur le thread principal. Le contrôle de concurrence plus strict de Swift 6 a refusé de me laisser être négligent à cette frontière. J'ai fini avec un commit de nettoyage intitulé, littéralement, « resolve Swift 6 actor-isolation warnings ». Agaçant sur le moment, correct avec le recul. Le compilateur avait raison et j'avais tort.
4. Le serveur ne nomme pas toujours le fichier proprement. L'audio revient parfois en audio/mpeg, parfois en m4a, de temps en temps en autre chose. Donc il y a une petite fonction terne qui déduit l'extension du fichier à partir du content type de la réponse, retombe sur l'URL, et par défaut prend .mp3. Pas glamour. Nécessaire.
Aucun de ces problèmes n'est un problème d'IA. Chacun est un problème de « comment les ordinateurs marchent vraiment ». C'est la taxe de la route ennuyeuse. Elle est réelle — mais je la paierais à chaque fois plutôt que le coût d'entretenir un truc malin que j'aurais inventé moi-même.
Ce que je n'ai délibérément PAS construit
Je crois que la partie la plus utile de toute cette fonctionnalité, c'est la liste des choses que je n'ai pas faites :
- Je n'ai pas écrit de moteur de téléchargement. (Background
URLSession.) - Je n'ai pas écrit de moniteur de connectivité. (
allowsCellularAccess.) - Je n'ai pas inventé de protocole de reprise. (Resume data du système.)
- Je n'ai rien construit de spécifique à l'IA. (C'est un fichier. Il se télécharge.)
Ce sur quoi j'ai vraiment mis du soin était petit et sans éclat : une toute petite file pour que pas plus de trois téléchargements tournent à la fois et que le reste attende son tour ; persister les titres des épisodes pour que l'écran de stockage affiche de vrais noms après un démarrage à froid au lieu de « Épisode, Épisode, Épisode » ; trier cet écran par fichier le plus gros d'abord, pour que, quand tu essaies de libérer de l'espace, les pires coupables soient tout en haut. Et j'ai extrait les vraies décisions — combien d'emplacements sont libres, comment trier la liste — dans des fonctions toutes simples sans aucune URLSession, pour pouvoir les tester unitairement comme il faut. Ennuyeux. Testable. Ennuyeux parce que testable.
La leçon, pour à peu près la centième fois
J'ai maintenant écrit une version de cet article sur la reconstruction de mon site, sur une facture d'API de 54 dollars, sur la reconstruction de cette app même. La leçon reste toujours la même, et j'ai sans cesse besoin de la réapprendre : la solution maligne est généralement la solution chère. « Qu'est-ce que la plateforme me donne déjà ? » — c'est la question la plus précieuse que je puisse poser avant d'écrire une seule ligne de code.
Pour un solo builder surtout : chaque truc malin que tu inventes est un truc que toi seul devras entretenir quand il cassera au pire moment possible. Chaque truc ennuyeux et standard que tu empruntes est entretenu par une boîte avec des milliers d'ingénieurs derrière. D'après mon expérience, ce marché vaut presque toujours le coup — et, honnêtement, la discipline de le faire est plus dure que l'ingéniosité qu'elle remplace. Cela dit, je me trompe peut-être sur l'endroit exact où passe la ligne. Il y a sûrement des problèmes où la plateforme ne te donne rien et où tu dois construire toi-même la partie difficile — je n'arrête simplement pas de constater que c'est plus rare que mon instinct ne l'insiste.
Quelques personnes qui utilisent DIALØGUE peuvent maintenant télécharger un épisode, monter dans un avion et l'écouter sans signal. Ça donne l'impression d'une fonctionnalité qui compte. Le code derrière est presque agressivement banal. J'ai fait la paix avec ça. Je crois que c'est ça, le boulot.
Tu fais ça aussi, toi — te surprendre à tendre la main vers la version maligne alors que l'ennuyeuse était juste là ? Ou est-ce juste moi, lent à apprendre la même leçon encore et encore ? J'aimerais vraiment savoir comment les autres luttent contre cet instinct.
À bientôt, Chandler