← Retour au blog

Ce que les documents Background Assets de Apple ne vous disent pas

2026-05-20

Le problème

L'un des défis les plus surprenants liés à la création de Narration Room n'était pas l'IA elle-même, mais plutôt l'installation du modèle sur l'appareil de l'utilisateur.

L'application est livrée avec un modèle de 3,85 Go : un générateur de paramètres quantifiés sur 4 bits de 3,3 B (environ 2,75 Go) et un modèle gardien de paramètres de 0,6 B dans BF16 (environ 1,1 Go). Vous ne pouvez pas regrouper cela dans un binaire d'application. La solution de rechange évidente – téléchargez-la au premier lancement avec URLSession – s'effondre plus rapidement que prévu : les téléchargements s'arrêtent lorsque l'utilisateur met l'application en arrière-plan, il n'y a pas de cache géré par le système, rien n'est nettoyé lors de la désinstallation et l'utilisateur passe sa première session à regarder un spinner.

Après 15 ans sur les plateformes Apple, je pensais qu'il y aurait ici un chemin bien tracé. Il y en a principalement — ça s'appelle Background Assets — mais la documentation laisse beaucoup de non-dits.

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

J'ai examiné quatre options pour expédier un modèle multi-Go, et cela vaut la peine d'expliquer comment j'ai raisonné sur chacune d'entre elles.

URLSession téléchargement intégré à l'application. La première chose que vous recherchez et la première chose qui m'a mordu dans le prototypage. Les téléchargements s'interrompent et ne reprennent jamais proprement lorsque l'application est mise en arrière-plan, le système d'exploitation peut récupérer un téléchargement bloqué sous la pression de la mémoire, vous finissez par réinventer la gestion des quotas et les fichiers persistent après la désinstallation. Très bien pour quelque chose de petit. Faux pour 4 Go.

App Clips. J'ai réfléchi brièvement à cela, puis j'ai réalisé que ce n'était pas le bon outil : App Clips consiste à lancer une tranche de votre application à partir d'un lien ou d'une balise, et non à fournir les ressources de l'application principale.

On-Demand Resources. L'ancienne réponse de Apple, et d'après ce que j'ai lu, elle est discrètement remplacée pour les cas de fichiers volumineux. Background Assets est l'endroit où Apple porte actuellement son attention.

Mon propre serveur. Tentant, car je contrôlerais tout. Mais cela signifie un backend, une authentification, une facture CDN, une surveillance et une bande passante qui évoluent à chaque installation. Plus de plomberie que la valeur ne le justifie.

Background Assets a gagné pour une combinaison que je ne pouvais obtenir nulle part ailleurs : le système gère le cycle de vie du téléchargement (il survit aux suppressions d'applications et reprend lors des redémarrages), le CDN de Apple est gratuit, la désinstallation se nettoie après elle-même et il n'y a pas d'infrastructure par utilisateur à exécuter. Le coût est une courbe d’apprentissage plus abrupte et une intégration plus invasive. Pour les modèles multi-Go sur Apple appareils en 2026, je ne pense pas que quelque chose d'autre soit en concurrence.

Le modèle mental que les documents ne vous donnent pas

L'architecture est la partie qui m'a fait trébucher, et la plupart des explicateurs l'ignorent. Voici le modèle que j'aurais aimé que quelqu'un dessine pour moi dès le premier jour.

Vous n'avez pas un seul processus. Vous en avez deux. Votre application principale et une petite extension (une cible distincte, avec son propre identifiant de bundle et ses propres droits) coopèrent pour fournir des ressources. L'extension adopte le protocole StoreDownloaderExtension de Apple, et c'est l'élément qui permet au système d'appeler votre code au moment du téléchargement. Votre application ne lance jamais directement un téléchargement. Le système déclenche l'extension et l'extension décide si elle doit continuer.

Les deux parlent via un conteneur de groupe d'applications partagé. Les téléchargements atterrissent là-bas ;votre application principale lit à partir de là. La partie qui m'a pris du temps à internaliser : le cycle de vie de l'extension appartient au système d'exploitation, pas à votre application. Il peut s'exécuter lorsque votre application n'est même pas ouverte. Les téléchargements continuent après les suppressions d'applications, les redémarrages et Wi-Fi suppressions.

Background Assets lifecycle: main app and extension communicating through a shared app-group container; the system triggers the extension to download an asset pack containing the generator and guardian models, which the main app then reads from the shared container

Une fois que cela a cliqué, tout le reste est devenu plus facile. Le passage se fait de « mon application télécharge un fichier » à « le système gère un fichier et mon application s'abonne à son état ».

Les pièges que vous rencontrez réellement en production

1. Le piège des packs dépendants – et pourquoi un pack en bat souvent deux

Narration Room a besoin de deux modèles travaillant ensemble : le générateur qui écrit la narration et le gardien qui filtre ce qui entre et ce qui sort. Mon premier instinct a été le plus ordonné : deux packs d'actifs, un par modèle, chacun versionné séparément.

C’était une erreur, et il 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 avec l’autre, ou avec des versions incompatibles de chacun, le chemin modéré s’interrompt. Et avec deux téléchargements indépendants, cet état de panne est accessible.

Je les regroupe donc - un pack d'actifs, les deux modèles, déclaré 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, la totalité des 3,85 Go est retéléchargée. Pour moi, c'est la bonne décision : garantir que les deux modèles sont présents et synchronisés au moment de l'installation compte plus que les flux de mise à jour indépendants. Si vos deux modèles dérivent selon des horaires très différents, des packs séparés pourraient en valoir la peine. Mais alors vous vous devez une vraie histoire pour « générateur présent, gardien disparu », et je préfère ne pas écrire ce code.

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

Les packs d'actifs sont constitués de .aar fichiers — Apple Archives d'actifs. Vous en construisez un avec xcrun ba-package à partir d'un manifeste JSON comme celui ci-dessus, et plusieurs répertoires sont placés dans une seule archive qui est extraite de manière atomique.

L’atomicité est la victoire tranquille. Il n'y a pas d'état à moitié extrait où vous avez une partie du générateur et aucun du gardien. Tout est sur le disque, ou rien ne l'est.

À l'intérieur, ce ne sont que les fichiers de modèle auxquels vous vous attendez : .safetensors, config.json, tokenizer.json, les compagnons habituels du tokenizer, parfois un modèle de discussion .jinja. L'AAR n'est que le wrapper.

3. Le temps de téléchargement et de traitement ASC est plus long que vous ne le pensez

Celui-ci m’a pris au dépourvu. Une fois que ba-package a créé l'archive, vous la téléchargez sur App Store Connect, puis Apple la traite de manière asynchrone avant qu'elle ne soit téléchargeable. Vous devez donc planifier l'asynchrone : ne liez pas votre date de sortie à un délai d'exécution le jour même.

Je vais être honnête : je n'ai jamais fixé de délai de traitement exact, et cela me dérange quand même un peu. Cela varie et Apple ne publie pas de numéro. Ce que je peux vous dire, c'est de télécharger tôt et d'interroger l'état plutôt que de le bloquer - la CLI asc ou l'API App Store Connect fonctionnent toutes les deux. Si vous vous précipitez vers un lancement, la file d'attente des packs d'actifs est exactement le genre de chose qui vous coûte tranquillement une journée.

4. App Review risque de rejet — Ligne directrice 2.1

C'est celui-là qui m'a vraiment inquiété. La ligne directrice 2.1 de Apple (exhaustivité des applications) souhaite que les applications démontrent toutes leurs fonctionnalités lors de l'examen. Imaginez le réviseur en train d'appuyer sur Générer et d'obtenir une invite de « téléchargement à utiliser » au lieu d'une fonctionnalité fonctionnelle – qui se lit comme incomplète et peut vous faire rejeter.

Le correctif consiste à soumettre les packs d'actifs pour examen avec votre build et à confirmer qu'ils apparaissent dans la soumission plutôt que de simplement les télécharger. Les packs de ressources ont leur propre piste de révision (jusqu'à dix par soumission) et ils doivent l'effacer avant que les utilisateurs externes puissent les extraire.

Ce qui a fonctionné pour moi :

  1. Téléchargez le binaire et les packs dans la même soumission.
  2. Vérifiez que les deux apparaissent sous « Éléments à vérifier » avant de l'envoyer.
  3. Pour tout ce qui présente un risque élevé, envisagez de modifier la politique du pack de onDemand à prefetch afin que le modèle soit déjà là lorsque le réviseur (ou l'utilisateur) ouvre la fonctionnalité pour la première fois. Vous payez d'avance la bande passante ;vous obtenez un critique qui voit que la chose fonctionne réellement.

Les testeurs internes TestFlight peuvent utiliser immédiatement des packs non examinés, ce qui rend le dogfooding indolore. Les testeurs externes et les utilisateurs App Store attendent leur examen.

5. Le consentement → télécharger → préchauffage → utiliser le flux UX

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

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

Chacun a sa propre interface utilisateur :

  • 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 en obtient.
  • Téléchargement — progression et option d'annulation, et surtout, le téléchargement doit continuer à survivre à l'utilisateur quittant l'écran. La vue n'en est pas propriétaire ;le système le fait.
  • Prêt — fonctionnalité déverrouillée.
  • Échec — une véritable erreur lors d'une nouvelle tentative.

Il y a un cinquième état que la plupart des articles sautent, et cela m'a surpris : l'échauffement. Le chargement d'un modèle de 2,75 Go dans la mémoire GPU prend de vraies secondes, même une fois que le fichier est local, et la latence d'inférence à froid est difficile. J'exécute une passe d'échauffement lors de la première utilisation et je la montre comme sa propre étape. Ignorez-le et les utilisateurs reprochent à la fonctionnalité d'être lente alors qu'elle ne fait que se charger.

6. La contrainte de durée de vie de l'URL que personne ne mentionne

Voici celui qui m'a coûté une demi-journée, et je suis quand même un peu ennuyé qu'il ne soit pas documenté. Lorsque vous demandez au framework où se trouve un actif téléchargé, vous obtenez un retour URL - et cette URL n'est valide que pour le processus en cours. Ne le cachez pas. Ne l'écrivez pas à UserDefaults. Ne confiez pas cette tâche à une tâche en arrière-plan qui survit au processus.

Le modèle qui fonctionne : résolvez-le à nouveau à chaque lancement, puis transmettez-le à quiconque en a besoin.

Cette contrainte m'a poussé vers un design dont je suis vraiment satisfait - et c'est le même instinct sur lequel je m'appuie partout maintenant, qui est de prendre en compte les préoccupations dans leurs propres packages le plus tôt possible. Le code de résolution des actifs et le moteur d'inférence vivent dans des packages SPM distincts avec une connexion nette entre eux : le côté actif rend un URL, le côté exécution prend un URL et l'application compose les deux.

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 que l’on apprécie plus tard. Le jour où la contrainte d'URL intervient, le correctif se trouve exactement au même endroit.

7. Continuité du téléchargement lorsque l'interface utilisateur de lancement disparaît

Une question que je me suis posée au début : si l'utilisateur ouvre les paramètres, démarre le téléchargement et ferme les paramètres, est-ce que cela continue ?

C’est le cas. L'extension est propriétaire du téléchargement, pas de la vue qui l'a démarré. Le système le met en pause et le reprend lors des lancements et même des redémarrages. Un utilisateur peut quitter complètement l'application et revenir le lendemain pour un téléchargement terminé. Votre interface utilisateur doit simplement gérer la rattachement à un téléchargement en cours au lieu d'en démarrer un nouveau.

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

import BackgroundAssets
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ù se trouverait le contrôle d'exécution - disque libre, sur Wi-Fi, un indicateur de consentement dans les valeurs par défaut partagées. Je renvoie true, car la conversation de consentement a déjà eu lieu dans l'application avant que l'utilisateur puisse déclencher quoi que ce soit.

8. Faites attention à ce que promet « sur l'appareil »

J'ai dû me rattraper sur le marketing ici. Il est tentant d'écrire « pas d'Internet requis ». J'y résisterais. L'utilisateur a besoin du réseau au moins une fois pour abaisser le modèle - et en fonction de la manière dont votre application achemine ses requêtes les plus difficiles, il peut également y avoir des chemins qui s'étendent plus tard. 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 applications réelles, c'est une phrase que vous ne pouvez pas complètement respecter.

Une inadéquation comme celle-là – "hors ligne" sur la boîte, un téléchargement de plusieurs Go lors de la première exécution – est exactement le genre de chose qui se transforme en une critique d'une étoile. La copie exacte est la version qui tient le coup.

9. Pré-publication : joignez les packs de ressources à TestFlight

Les packs d'actifs vivent séparément de votre binaire dans App Store Connect - téléchargés seuls, traités seuls, avec leur propre état de révision.

Les testeurs TestFlight internes peuvent utiliser un pack une fois le traitement terminé (aucun App Review n'est nécessaire), ce qui fait des versions internes le moyen le plus rapide de tester le flux de téléchargement réel sur un appareil. Les testeurs externes et les utilisateurs App Store attendent leur examen. Dix packs maximum par soumission, ce qui est suffisant pour un ou deux modèles.

10. Votre IPA ne grandit pas, mais la RAM résidente le fait

J'ai continué à confondre deux nombres au début, et ils valent la peine d'être séparés."Mes modèles font 4 Go, donc mon application fait 4 Go" - ce n'est pas vrai. Avec Background Assets, l'IPA reste petit ;les utilisateurs ne paient le coût du disque que s'ils acceptent, et le système d'exploitation le stocke en dehors de votre bac à sable.

Ce qui augmente, c'est la RAM résidente au moment de l'inférence. Le générateur (4 bits, ~ 2,6 Go résident) plus le gardien (BF16 dans un contexte 8K, au nord de 1,1 Go) culminent à environ 4,9 Go. Très bien sur un Mac de 32 Go. Serré sur 16 Go. Hors de portée sur un iPad de 8 Go. J'indique à la fois les minimums de disque et de RAM dans la description App Store - le numéro de disque est évident, le numéro de RAM ne l'est pas, et je préfère qu'un utilisateur le sache plutôt que de le découvrir via un crash de mémoire insuffisante.

Développement local avec le serveur fictif

Si je pouvais partager une seule chose pratique de tout cela, c’est celle-ci.

Sans simulation, chaque itération fait un aller-retour de App Store Connect — minutes à heures par cycle. C'est irréalisable. Apple est livré avec xcrun ba-serve, et presque aucun tutoriel ne le mentionne :

xcrun ba-serve --asset-path Models/

Pointez votre version de débogage sur le serveur local, effectuez une itération dessus et ne poussez vers ASC qu'une fois que le comportement est correct. C'est ce qui a transformé Background Assets de « se soumettre et prier » en quelque chose contre lequel je pouvais réellement me développer.

Liste de contrôle de production

Les choses que je vérifie avant de soumettre :

  • Packs d'actifs téléchargés et traités en App Store Connect (vérifiez le statut ; ne vous contentez pas de soumettre).
  • Packs d'actifs inclus dans la même soumission que le binaire de l'application, ou pré-approuvés séparément.
  • Les droits du groupe d'applications correspondent entre l'application principale et l'extension.
  • Le Info.plist de l'extension déclare EXExtensionPointIdentifier = com.apple.background-asset-downloader-extension.
  • Testé sur un appareil propre – pas sur la machine de développement avec des actifs en cache. Le démarrage à froid est ce qui compte.
  • Les quatre états de téléchargement (non démarré, téléchargement, pause/échec avec nouvelle tentative, prêt) ont été exercés.
  • Le Warmup s'exécute et est visiblement distinct du téléchargement. - Le texte marketing indique "téléchargement unique", jamais "pas d'Internet".
  • La description App Store indique le disque minimum et la RAM minimale.
  • Construction interne TestFlight testée avec le pack.
  • Version externe TestFlight testée après que le pack ait été examiné.

Où les documents de Apple sont toujours erronés ou absents

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 de travail d'itération qu'il débloque est le fait pratique le plus utile sur le framework.
  • Le modèle d'atomicité des packs dépendants est laissé en exercice. Vous l'apprenez en gravant une version.
  • La contrainte de durée de vie de l'URL est silencieuse dans la surface de l'API et constitue une source de crash silencieuse.
  • Le temps de téléchargement et de traitement ASC n'a pas d'estimation publiée ;prévoir des heures.
  • Les implications de la directive 2.1 pour les packs non encore téléchargés ne sont documentées nulle part que j'ai pu trouver - vous les découvrez au moment de la révision.
  • Les différences de livraison macOS par rapport à iOS méritent leur propre rédaction.

Le cadre en lui-même est bon. La documentation a besoin d'aide, et la communauté a besoin de plus d'articles de la part de personnes qui l'accompagnent réellement - c'est la seule raison pour laquelle j'ai écrit celle-ci.

Quelle est la prochaine étape

Ceci est le premier d'une série que j'écris alors que je travaille sur l'expédition de l'IA sur Apple Silicon. J'apprends toujours au fur et à mesure et les flux de travail changent sous mes pieds, alors traitez-les comme des notes de terrain plutôt que comme un évangile. Ensuite :

  • ** Budgétisation rapide ** lorsque votre fenêtre contextuelle contient 8 000 jetons : débordement, segmentation et compromis.
  • TTS multi-voix sur Apple Silicon — l'architecture de pipeline réelle derrière Narration Room.
  • Affiner un classificateur d'intention — l'ensemble de données, le harnais d'évaluation et ce que je ferais différemment.

Si c'est votre genre de chose, la newsletter est le meilleur moyen d'attraper le reste.