← Retour au blog

Ce que la documentation Background Assets d'Apple ne vous dit pas

Brouillon rédigé avec l'IA ; recherche, édition et vérification des faits par moi — comment j'écris.

Le problème

L'un des défis les plus surprenants dans la création de Narration Room n'a pas été l'IA elle-même — c'était d'amener le modèle sur l'appareil de l'utilisateur.

L'app embarque un modèle de 3,85 Go : un générateur de 3 milliards de paramètres quantifié sur 4 bits (environ 2,75 Go) et un modèle gardien de 0,6 milliard de paramètres en BF16 (environ 1,1 Go). Impossible d'intégrer cela dans le binaire de l'app.

La solution de repli évidente consiste à le télécharger au premier lancement avec URLSession. Cela s'effondre plus vite qu'on ne le croit. Les téléchargements s'interrompent quand l'utilisateur met l'app en arrière-plan. Il n'y a pas de cache géré par le système. Rien n'est nettoyé à la désinstallation. L'utilisateur passe sa première session à fixer un indicateur d'activité.

Après 15 ans sur les plateformes Apple, je pensais qu'il existerait ici un chemin bien balisé. C'est en grande partie le cas — cela s'appelle Background Assets — mais la documentation laisse beaucoup de choses dans l'ombre.

Pourquoi Background Assets (et les alternatives que j'ai pesées)

J'ai examiné quatre options pour livrer un modèle de plusieurs Go, et il vaut la peine de détailler mon raisonnement sur chacune.

Téléchargement intégré avec URLSession. La première chose vers laquelle on se tourne, et la première qui m'a piégé au prototypage. Les téléchargements se mettent en pause et ne reprennent jamais proprement quand l'app passe en arrière-plan, le système peut récupérer un téléchargement bloqué sous la pression mémoire, on finit par réinventer la gestion des quotas, et les fichiers subsistent après la désinstallation. Très bien pour quelque chose de petit. Inadapté pour 4 Go.

App Clips. J'y ai pensé brièvement, puis j'ai réalisé que ce n'était pas le bon outil — les App Clips servent à lancer une tranche de votre app depuis un lien ou un tag, pas à livrer les ressources de l'app principale.

On-Demand Resources. L'ancienne réponse d'Apple, et d'après moi elle est discrètement supplantée pour les cas de gros fichiers. C'est sur Background Assets qu'Apple porte désormais son attention.

Mon propre serveur. Tentant, car je contrôlerais tout. Mais cela suppose un backend, de l'authentification, une facture de CDN, du monitoring, et une bande passante qui croît à chaque installation. Plus de plomberie que la valeur ne le justifie.

Background Assets l'a emporté parce qu'il me donnait une combinaison que je ne trouvais nulle part ailleurs. Le système gère le cycle de vie du téléchargement : il survit aux fermetures forcées de l'app et reprend après les redémarrages. Avec les packs hébergés par Apple, Apple prend en charge le chemin d'hébergement, donc je n'exploite pas mon propre CDN pour chaque installation. La désinstallation fait le ménage toute seule. Il n'y a aucune infrastructure par utilisateur à maintenir.

Le prix à payer : une courbe d'apprentissage plus raide et une intégration plus intrusive. Pour des modèles de plusieurs Go sur les appareils Apple en 2026, rien d'autre ne fait vraiment le poids.

Le modèle mental que la documentation ne vous donne pas

L'architecture est ce qui m'a fait trébucher, et la plupart des explications l'ignorent. Voici le modèle que j'aurais aimé qu'on me dessine dès le premier jour.

Vous n'avez pas un seul processus. Vous en avez deux. Votre app principale et une petite extension — une target distincte, avec son propre bundle identifier et ses propres entitlements — coopèrent pour livrer les ressources. L'extension adopte le protocole StoreDownloaderExtension d'Apple, et c'est cette pièce qui permet au système d'appeler votre code au moment de télécharger. Votre app ne déclenche jamais un téléchargement directement. Le système déclenche l'extension, et l'extension décide s'il faut poursuivre.

Les deux communiquent via un conteneur App Group partagé. Les téléchargements y atterrissent ; votre app principale y lit. Ce qu'il m'a fallu du temps à intérioriser : le cycle de vie de l'extension appartient au système, pas à votre app. Elle peut s'exécuter alors que votre app n'est même pas ouverte. Les téléchargements se poursuivent malgré les fermetures forcées de l'app, malgré les redémarrages, malgré les coupures Wi-Fi.

Cycle de vie de Background Assets : l'app principale et l'extension communiquent via un conteneur App Group partagé ; le système déclenche l'extension pour télécharger un asset pack contenant le générateur et le modèle gardien, que l'app principale lit ensuite depuis le conteneur partagé

Une fois que ça a fait tilt, tout le reste est devenu plus simple. Le basculement se fait de « mon app télécharge un fichier » à « le système gère un fichier, et mon app s'abonne à son état ».

Les pièges que vous rencontrez vraiment en production

1. Le piège des packs dépendants — et pourquoi un seul pack en vaut souvent deux

Narration Room a besoin de deux modèles qui travaillent ensemble : le générateur qui écrit la narration, et le gardien qui filtre ce qui entre et ce qui sort. Mon premier réflexe a été le plus ordonné — deux asset packs, un par modèle, versionnés chacun de leur côté.

C'était une erreur, et il m'a fallu réfléchir aux cas d'échec pour comprendre pourquoi. Dès qu'un utilisateur se retrouve avec un modèle mais pas l'autre, ou des versions dépareillées de chacun, le chemin modéré se brise. Et avec deux téléchargements indépendants, cet état défectueux est atteignable.

Je les empaquette donc ensemble — un seul asset pack, les deux modèles, déclarés avec plusieurs fileSelectors dans le manifeste :

{
    "assetPackID": "app-models",
    "downloadPolicy": {
        "onDemand": {}
    },
    "fileSelectors": [
        { "directory": "Models/Generator-3B-Instruct-4bit" },
        { "directory": "Models/Guardian-0.6B-BF16" }
    ],
    "platforms": ["macOS"]
}

Le compromis est réel : pour mettre à jour l'un ou l'autre modèle, les 3,85 Go entiers se retéléchargent. Pour moi c'est le bon choix — garantir que les deux modèles sont présents et synchronisés au moment de l'installation compte plus que des flux de mise à jour indépendants. Si vos deux modèles évoluent selon des calendriers très différents, des packs séparés peuvent valoir le coup. Mais alors il vous faut un vrai scénario pour « générateur présent, gardien manquant », et je préfère ne pas écrire ce code.

2. Le format d'archive AAR (que la documentation documente à peine)

Les asset packs sont des fichiers .aar — des Apple Asset Archives. On en construit un avec xcrun ba-package à partir d'un manifeste JSON comme celui ci-dessus, et plusieurs répertoires entrent dans une seule archive qui s'extrait de façon atomique.

L'atomicité est le gain discret. Il n'existe pas d'état à moitié extrait où vous auriez une partie du générateur et rien du gardien. Tout est sur le disque, ou rien ne l'est.

À l'intérieur, ce sont simplement les fichiers de modèle attendus — .safetensors, config.json, tokenizer.json, les compagnons habituels du tokenizer, parfois un gabarit de chat .jinja. L'AAR n'est que le wrapper.

3. Le téléversement et le traitement sur ASC prennent plus de temps qu'on ne le pense

Celui-là m'a pris au dépourvu. Après que ba-package a construit l'archive, vous la téléversez sur App Store Connect, puis Apple la traite de façon asynchrone avant qu'elle ne soit téléchargeable. Il faut donc prévoir l'asynchrone — ne calez pas votre date de sortie sur un délai d'une seule journée.

Je vais être honnête : je n'ai jamais déterminé un temps de traitement exact, et ça me dérange encore un peu. C'est variable, et Apple ne publie aucun chiffre.

Ce que je peux vous dire, c'est de téléverser tôt et d'interroger le statut plutôt que de bloquer dessus. La CLI asc et l'API App Store Connect font l'affaire. Si vous foncez vers un lancement, la file d'attente des asset packs est exactement le genre de chose qui vous coûte discrètement une journée.

4. Risque de rejet par l'App Review — directive 2.1

C'est celui qui m'a vraiment inquiété. La directive 2.1 d'Apple (App Completeness) attend des apps qu'elles démontrent toutes leurs fonctionnalités pendant la revue. Imaginez le relecteur qui appuie sur Générer et obtient une invite « télécharger pour utiliser » au lieu d'une fonctionnalité opérationnelle — cela passe pour incomplet, et peut vous valoir un rejet.

La solution consiste à soumettre les asset packs pour revue avec votre build, et à vérifier réellement qu'ils apparaissent dans la soumission plutôt que de simplement rester téléversés. Les asset packs ont leur propre circuit de revue (jusqu'à dix par soumission), et ils doivent le franchir avant que des utilisateurs externes puissent les récupérer.

Ce qui a marché pour moi :

  1. Téléverser le binaire et les packs dans la même soumission.
  2. Vérifier que les deux figurent sous « Items to Review » avant de l'envoyer.
  3. Pour tout ce qui présente un risque élevé, envisagez de faire passer la politique du pack de onDemand à prefetch afin que le modèle soit déjà là quand le relecteur — ou l'utilisateur — ouvre la fonctionnalité pour la première fois. Vous payez de la bande passante en amont ; vous obtenez un relecteur qui voit la chose fonctionner pour de vrai.

Les testeurs TestFlight internes peuvent utiliser des packs non revus immédiatement, ce qui a beaucoup simplifié les tests internes avec notre propre app. Les testeurs externes et les utilisateurs de l'App Store attendent la revue.

5. Le parcours UX consentement → téléchargement → warmup → utilisation

Le premier lancement à froid d'une fonctionnalité d'IA comporte quatre états distincts, et j'ai appris à mes dépens que les confondre déroute les gens :

public enum ModelAssetState: Sendable {
    case notDownloaded
    case downloading(progress: Double)
    case ready(URL)
    case failed(Error)
}

Chacun a sa propre UI :

  • Non téléchargé — un opt-in explicite. Je ne télécharge pas automatiquement ; j'indique à l'utilisateur la taille et ce qu'il obtient en échange.
  • Téléchargement — une progression et une option d'annulation, et surtout le téléchargement doit se poursuivre même si l'utilisateur quitte l'écran. La vue ne le possède pas ; le système, si.
  • Prêt — fonctionnalité déverrouillée.
  • Échec — une vraie erreur avec une reprise possible.

Il existe un cinquième état que la plupart des articles passent sous silence, et il m'a surpris : le warmup. Charger un modèle de 2,75 Go en mémoire GPU prend de vraies secondes même une fois le fichier en local, et la latence d'inférence à froid est rude. J'exécute une passe de warmup à la première utilisation et je l'affiche comme une étape à part. Sautez-la, et les utilisateurs reprochent à la fonctionnalité d'être lente alors qu'elle ne fait que charger.

6. La contrainte de durée de vie de l'URL dont personne ne parle

Voici celui qui m'a coûté une demi-journée, et je suis encore un peu agacé qu'il ne soit pas documenté. Quand vous demandez au framework où se trouve une ressource téléchargée, vous récupérez une URL — et cette URL n'est valable que pour le processus en cours. Ne la mettez pas en cache. Ne l'écrivez pas dans UserDefaults. Ne la confiez pas à une tâche en arrière-plan qui survit au processus.

Le motif qui fonctionne : la résoudre à neuf à chaque lancement, puis la transmettre à qui en a besoin.

Cette contrainte m'a poussé vers une conception dont je suis sincèrement satisfait — et c'est le même réflexe sur lequel je m'appuie partout désormais, à savoir séparer tôt les responsabilités dans leurs propres packages. Un package résout la ressource et renvoie une URL fraîche ; un autre package accepte cette URL et exécute l'inférence. L'app compose les deux, si bien que la règle de durée de vie de l'URL reste côté ressources.

public protocol ModelAssetManaging: Sendable {
    func ensureAvailable(for descriptor: ModelAssetDescriptor) async throws -> URL
    func updates(for descriptor: ModelAssetDescriptor) -> AsyncStream<ModelAssetState>
}

// Runtime accepts a URL; it does not know how the URL was resolved.
public protocol ModelRuntime: Sendable {
    func warmLoad(modelDirectory: URL) async throws
    func generate(_ request: GenerationRequest) async throws -> AsyncThrowingStream<Token, Error>
}

C'est le genre de séparation qu'on apprécie plus tard. Le jour où la contrainte d'URL se manifeste, le correctif tient en un seul endroit.

7. La continuité du téléchargement quand l'UI initiatrice disparaît

Une question que je me suis posée tôt : si l'utilisateur ouvre les Réglages, lance le téléchargement, puis ferme les Réglages — est-ce que ça continue ?

Oui. L'extension possède le téléchargement, pas la vue qui l'a lancé. Le système le met en pause et le reprend d'un lancement à l'autre, et même après les redémarrages. Un utilisateur peut quitter l'app entièrement et revenir le lendemain à un téléchargement terminé. Votre UI doit simplement savoir se rattacher à un téléchargement en cours plutôt que d'en démarrer un nouveau.

L'extension elle-même n'est presque rien :

import BackgroundAssets
import ExtensionFoundation
import StoreKit
import OSLog

@main
struct DownloaderExtension: StoreDownloaderExtension {
    private static let logger = Logger(
        subsystem: "com.example.app.BackgroundAssetDownloader",
        category: "AssetDownload"
    )

    func shouldDownload(_ assetPack: AssetPack) -> Bool {
        Self.logger.info("Evaluating download for asset pack: \(assetPack.id)")
        return true
    }
}

shouldDownload(_:) est l'endroit où je vérifierais les conditions avant d'autoriser un téléchargement — espace disque libre, présence du Wi-Fi, un drapeau de consentement dans les defaults partagés. Je renvoie true, parce que la conversation sur le consentement a déjà eu lieu dans l'app avant que l'utilisateur ne puisse déclencher tout ceci.

8. Attention à ce que « on-device » promet

J'ai dû me reprendre sur le marketing ici. Il est tentant d'écrire « aucune connexion requise ». Je m'en abstiendrais.

L'utilisateur a besoin du réseau au moins une fois, pour récupérer le modèle. Selon la façon dont votre app achemine ses requêtes les plus lourdes, certains chemins peuvent solliciter le réseau plus tard aussi. Je dis donc ce qui est réellement vrai : le modèle se télécharge une fois, puis fait son travail sur votre Mac. Je ne prétends pas « entièrement hors ligne », car pour la plupart des vraies apps c'est une phrase qu'on ne peut pas pleinement assumer.

Un décalage de ce genre — « hors ligne » sur l'emballage, un téléchargement de plusieurs Go au premier lancement — est exactement le genre de chose qui se transforme en avis une étoile. Une formulation précise est la version qui tient dans le temps.

9. Avant publication : attachez les asset packs à TestFlight

Les asset packs vivent séparément de votre binaire dans App Store Connect — téléversés à part, traités à part, avec leur propre état de revue.

Les testeurs TestFlight internes peuvent utiliser un pack dès qu'il a fini d'être traité — sans App Review — ce qui fait des builds internes le moyen le plus rapide de tester le vrai parcours de téléchargement sur un appareil. Les testeurs externes et les utilisateurs de l'App Store attendent la revue. Dix packs maximum par soumission, ce qui, pour un ou deux modèles, suffit amplement.

10. Votre IPA ne grossit pas — mais la RAM résidente, si

J'ai longtemps confondu deux chiffres au début, et il vaut la peine de les séparer. « Mes modèles font 4 Go, donc mon app fait 4 Go » — faux. Avec Background Assets, l'IPA reste petite ; les utilisateurs ne paient le coût en disque que s'ils optent pour, et le système le stocke en dehors de votre sandbox.

Ce qui grossit, c'est la RAM résidente au moment de l'inférence. Le générateur (4 bits, ~2,6 Go résidents) plus le gardien (BF16 à 8K de contexte, au-delà de 1,1 Go) culminent autour de 4,9 Go. Confortable sur un Mac de 32 Go. Juste sur 16 Go. Hors de portée sur un iPad de 8 Go. J'indique à la fois le minimum de disque et le minimum de RAM dans la description de l'App Store — le chiffre du disque est évident, celui de la RAM ne l'est pas, et je préfère qu'un utilisateur le sache plutôt qu'il le découvre par un plantage faute de mémoire.

Développement local avec le mock server

Si je ne devais partager qu'une seule chose pratique de tout ceci, ce serait celle-ci.

Sans mock server, chaque itération fait l'aller-retour avec App Store Connect — des minutes à des heures par cycle. C'est impraticable. Apple fournit xcrun ba-serve, et presque aucun tutoriel n'en parle :

# serve your local asset packs over HTTPS, then point the debug build at them
xcrun ba-serve --host localhost --choose-identity-automatically
xcrun ba-serve url-override https://localhost:<port>

Pointez votre build de debug sur le serveur local, itérez dessus, et ne poussez sur ASC qu'une fois le comportement au point. C'est ce qui a transformé Background Assets de « soumettre et prier » en quelque chose contre quoi je pouvais réellement développer.

Liste de contrôle avant production

Ce que je vérifie avant de soumettre :

  • Asset packs téléversés et traités dans App Store Connect (vérifiez le statut ; ne vous contentez pas de soumettre).
  • Asset packs inclus dans la même soumission que le binaire de l'app, ou préapprouvés séparément.
  • L'entitlement App Group correspond entre l'app principale et l'extension.
  • L'Info.plist de l'extension déclare EXExtensionPointIdentifier = com.apple.background-asset-downloader-extension.
  • Testé sur un appareil vierge — pas la machine de dev avec des ressources en cache. C'est le démarrage à froid qui compte.
  • Les quatre états de téléchargement (non démarré, en cours, en pause/échec-avec-reprise, prêt) exercés.
  • Le warmup s'exécute, et se distingue visiblement du téléchargement.
  • Le texte marketing dit « téléchargement unique », jamais « aucune connexion ».
  • La description de l'App Store indique le minimum de disque et le minimum de RAM.
  • Build TestFlight interne passé au smoke test avec le pack.
  • Build TestFlight externe testé après que le pack a franchi la revue.

Là où la documentation d'Apple est encore fausse ou absente

Je n'aurais pas écrit ceci si la documentation était complète. Les lacunes qui m'ont le plus coûté :

  • xcrun ba-serve est à peine mentionné, et le flux d'itération qu'il débloque est le fait pratique le plus utile à propos du framework.
  • Le motif d'atomicité des packs dépendants est laissé en exercice. On l'apprend en grillant une version.
  • La contrainte de durée de vie de l'URL est passée sous silence dans la surface de l'API, et c'est une source discrète de plantages.
  • Le temps de téléversement et de traitement sur ASC n'a aucune estimation publiée ; prévoyez des heures.
  • Les implications de la directive 2.1 pour les packs pas encore téléchargés ne sont documentées nulle part où j'aie pu trouver — on les découvre au moment de la revue.
  • Les différences de livraison entre macOS et iOS méritent leur propre article.

Le framework lui-même est bon. La documentation a besoin d'aide, et la communauté a besoin de plus d'articles de gens qui livrent réellement avec — c'est la seule raison pour laquelle j'ai pris le temps d'écrire cet article.

La suite

Si ce genre de note de terrain vous est utile, la newsletter est le meilleur moyen de ne pas manquer la suivante.