← Zurück zum Blog

Was Apples Dokumentation zu Background Assets nicht sagt

Mit KI entworfen; von mir recherchiert, redigiert und auf Fakten geprüft — wie ich schreibe.

Das Problem

Eine der unerwarteten Herausforderungen beim Programmieren von Narration Room war nicht die KI selbst, sondern das Modell auf die Geräte der Nutzer zu bekommen.

Die App benötigt ein 3,85 GB großes Modellpaket: einen auf 4 Bit quantisierten Generator mit 3 Milliarden Parametern (rund 2,75 GB) und ein Guardian-Modell mit 0,6 Milliarden Parametern in BF16 (rund 1,1 GB). So etwas packt man nicht einfach in ein App-Binary.

Die naheliegende Notlösung ist ein Download beim ersten Start mit URLSession. Das hält weniger lange, als man denkt. Downloads brechen ab, sobald die App in den Hintergrund geht. Es gibt keinen vom System verwalteten Cache. Bei der Deinstallation wird nichts automatisch aufgeräumt. Der Nutzer verbringt seine erste Sitzung damit, auf einen Spinner zu starren.

Nach 15 Jahren auf Apple-Plattformen hätte ich erwartet, dass es dafür einen etablierten Weg gibt. Den gibt es im Wesentlichen auch — er heißt Background Assets — aber die Dokumentation lässt viele entscheidende Details offen.

Warum Background Assets (und welche Alternativen ich geprüft habe)

Ich habe vier Wege geprüft, ein mehrere Gigabyte großes Modell bereitzustellen. Es lohnt sich, die Überlegung hinter jeder Option kurz durchzugehen.

In-App-Download mit URLSession. Der erste Griff in die Werkzeugkiste — und der erste, der mir im Prototyp wehgetan hat. Downloads pausieren und werden nicht zuverlässig fortgesetzt, sobald die App in den Hintergrund geht. Das Betriebssystem kann einen hängenden Download unter Ressourcendruck verwerfen, man baut am Ende eigene Quota-Logik, und die Dateien bleiben nach der Deinstallation liegen. Für kleine Dateien in Ordnung. Für 4 GB falsch.

App Clips. Das habe ich kurz erwogen und dann verworfen. App Clips sind dafür da, einen Ausschnitt Ihrer App über einen Link oder Tag zu starten — nicht dafür, Ressourcen der Haupt-App bereitzustellen.

On-Demand Resources. Apples ältere Lösung. Mein Eindruck ist, dass sie für große Dateien inzwischen leise durch Background Assets verdrängt wird. Background Assets ist der Bereich, auf den Apple jetzt setzt.

Mein eigener Server. Verlockend, weil ich die volle Kontrolle hätte. Praktisch heißt das aber: Backend, Authentifizierung, CDN-Kosten, Monitoring und Bandbreite, die mit jeder Installation mitwächst. Zu viel Infrastrukturarbeit für zu wenig Nutzen.

Background Assets war am Ende die einzige Option, die mir diese Kombination gab. Das System verwaltet den Lebenszyklus des Downloads: Er übersteht erzwungenes Beenden der App und wird nach Neustarts fortgesetzt. Bei von Apple gehosteten Packs übernimmt Apple den Hosting-Pfad; ich betreibe also keinen eigenen CDN für jede Installation. Die Deinstallation räumt auf. Es gibt keine Infrastruktur pro Nutzer zu betreiben.

Der Preis dafür ist eine steilere Lernkurve und eine deutlich tiefergehende Integration. Für mehrere Gigabyte große Modelle auf Apple-Geräten im Jahr 2026 sehe ich keine echte Alternative.

Das mentale Modell, das die Dokumentation Ihnen nicht vermittelt

Die Architektur war der Teil, an dem ich zuerst hängen blieb, und die meisten Erklärungen überspringen ihn. Dieses mentale Modell hätte ich gern am ersten Tag gesehen.

Sie haben nicht einen Prozess, sondern zwei. Ihre Haupt-App und eine kleine Extension — ein eigenes Target mit eigenem Bundle Identifier und eigenen Entitlements — arbeiten zusammen, um Ressourcen bereitzustellen. Die Extension implementiert Apples Protokoll StoreDownloaderExtension. Dadurch kann das System Ihren Code aufrufen, wenn ein Download ansteht. Ihre App startet den Download nicht direkt. Das System startet die Extension, und die Extension entscheidet, ob der Download laufen darf.

Die beiden kommunizieren über einen gemeinsamen App-Group-Container. Downloads werden dort abgelegt; Ihre Haupt-App liest von dort. Der wichtige Punkt, den ich erst verinnerlichen musste: Der Lebenszyklus der Extension gehört dem Betriebssystem, nicht Ihrer App. Sie kann laufen, obwohl Ihre App nicht geöffnet ist. Downloads laufen weiter, auch wenn die App erzwungen beendet wurde, das Gerät neu startet oder die Wi-Fi-Verbindung kurz abbricht.

Lebenszyklus von Background Assets: Haupt-App und Extension kommunizieren über einen gemeinsamen App-Group-Container; das System startet die Extension, um ein asset pack mit Generator- und Guardian-Modell herunterzuladen, das die Haupt-App anschließend aus dem gemeinsamen Container liest

Sobald mir das klar war, wurde der Rest einfacher. Das mentale Modell verschiebt sich von „meine App lädt eine Datei herunter“ zu „das System verwaltet eine Datei, und meine App beobachtet ihren Zustand“.

Die Stolperfallen, die in Produktion wirklich auftauchen

1. Die Falle bei abhängigen Packs — und warum ein Pack oft besser ist als zwei

Narration Room braucht zwei Modelle, die zusammenarbeiten: den Generator, der die Narration schreibt, und das Guardian-Modell, das Eingaben und Ausgaben prüft. Mein erster Impuls war die technisch saubere Lösung: zwei asset packs, eines pro Modell, jeweils separat versioniert.

Das war falsch. Erst beim Durchdenken der Fehlerfälle wurde klar, warum. Sobald ein Nutzer das eine Modell hat, aber nicht das andere — oder nicht zusammenpassende Versionen beider Modelle —, bricht der Moderationspfad. Mit zwei unabhängigen Downloads ist genau dieser fehlerhafte Zustand erreichbar.

Also bündle ich sie gemeinsam: ein asset pack mit beiden Modellen, im Manifest über mehrere fileSelectors deklariert:

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

Der Nachteil ist spürbar: Wenn eines der beiden Modelle aktualisiert wird, werden die vollen 3,85 GB erneut heruntergeladen. Für mich ist das die richtige Entscheidung. Zu garantieren, dass beide Modelle vorhanden und synchron sind, ist wichtiger als unabhängige Update-Ströme. Wenn Ihre beiden Modelle in sehr unterschiedlichem Rhythmus aktualisiert werden, können getrennte Packs sinnvoll sein. Dann brauchen Sie aber eine echte Antwort auf den Zustand „Generator vorhanden, Guardian-Modell fehlt“. Diesen Code schreibe ich lieber nicht.

2. Das AAR-Archivformat (das die Dokumentation kaum dokumentiert)

Asset packs sind .aar-Dateien — Apple Asset Archives. Sie erzeugen ein solches Archiv mit xcrun ba-package aus einem JSON-Manifest wie oben. Aus mehreren Verzeichnissen wird ein einzelnes Archiv, das atomar entpackt wird.

Diese Atomarität ist der unscheinbare Vorteil. Es gibt keinen halb entpackten Zustand, in dem ein Teil des Generators vorhanden ist, aber das Guardian-Modell fehlt. Entweder liegt alles auf der Platte, oder nichts davon.

Im Inneren liegen einfach die Modelldateien, die man erwartet: .safetensors, config.json, tokenizer.json, die üblichen Tokenizer-Begleiter, manchmal ein .jinja-Chat-Template. Das AAR ist nur der Wrapper.

3. Upload und Verarbeitung in ASC dauern länger, als Sie denken

Das hat mich kalt erwischt. Nachdem ba-package das Archiv gebaut hat, laden Sie es zu App Store Connect hoch. Danach verarbeitet Apple es asynchron, bevor es herunterladbar ist. Planen Sie diese Asynchronität ein — hängen Sie Ihren Veröffentlichungstermin nicht an einen Turnaround am selben Tag.

Ehrlich gesagt: Eine verlässliche Verarbeitungszeit konnte ich nie festnageln, und das stört mich immer noch. Sie schwankt, und Apple veröffentlicht keine Zahl.

Was ich Ihnen sagen kann: Laden Sie früh hoch und pollen Sie den Status, statt darauf zu blockieren. Die asc-CLI und die App Store Connect API funktionieren dafür beide. Wenn der Launch näher rückt, ist die Warteschlange für asset packs genau so ein Detail, das unauffällig einen Tag frisst.

4. Ablehnungsrisiko im App Review — Richtlinie 2.1

Dieser Punkt hat mir am meisten Sorgen gemacht. Apples Richtlinie 2.1 (App Completeness) verlangt, dass Apps während der Prüfung ihren vollen Funktionsumfang zeigen. Stellen Sie sich vor, der Reviewer tippt auf „Generieren“ und bekommt statt einer funktionierenden Funktion nur einen „zum Verwenden herunterladen“-Prompt. Das wirkt unfertig und kann zur Ablehnung führen.

Die Lösung: Reichen Sie die asset packs zusammen mit Ihrem Build zur Prüfung ein und prüfen Sie wirklich, dass sie in der Einreichung enthalten sind — nicht nur irgendwo hochgeladen. Asset packs haben ihren eigenen Review-Pfad (bis zu zehn pro Einreichung), und sie müssen ihn durchlaufen, bevor externe Nutzer sie laden können.

Was bei mir funktioniert hat:

  1. Binary und Packs in derselben Einreichung hochladen.
  2. Prüfen, dass beides unter „Items to Review“ erscheint, bevor Sie die Einreichung abschicken.
  3. Bei hohem Risiko: Erwägen Sie, die Download-Policy des Packs von onDemand auf prefetch umzustellen, damit das Modell schon da ist, wenn der Reviewer — oder der Nutzer — die Funktion zum ersten Mal öffnet. Sie zahlen mit vorab genutzter Bandbreite; dafür sieht der Reviewer die Funktion tatsächlich arbeiten.

Interne TestFlight-Tester können ungeprüfte Packs sofort verwenden. Das hat Dogfooding deutlich angenehmer gemacht. Externe Tester und App-Store-Nutzer warten auf die Prüfung.

5. Der UX-Ablauf Einwilligung → Download → Warmup → Nutzung

Der erste Kaltstart einer KI-Funktion hat vier klar getrennte Zustände. Ich habe schmerzhaft gelernt, dass es Nutzer verwirrt, wenn man sie vermischt:

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

Jeder Zustand bekommt eine eigene UI:

  • Nicht heruntergeladen — ein ausdrückliches Opt-in. Ich lade nicht automatisch herunter; ich nenne dem Nutzer die Größe und was er dafür bekommt.
  • Wird heruntergeladen — Fortschritt und Abbrechen-Option. Entscheidend ist, dass der Download weiterlaufen muss, auch wenn der Nutzer den Bildschirm verlässt. Die View ist nicht dafür verantwortlich; das System ist es.
  • Bereit — Funktion freigeschaltet.
  • Fehlgeschlagen — ein echter Fehler mit einer Wiederholmöglichkeit.

Es gibt einen fünften Zustand, den die meisten Artikel auslassen, und der mich überrascht hat: Warmup. Ein 2,75 GB großes Modell in den GPU-Speicher zu laden, dauert mehrere Sekunden, selbst wenn die Datei lokal vorliegt. Die Cold-Start-Latenz der ersten Inferenz ist spürbar. Ich führe beim ersten Einsatz einen Warmup-Durchlauf aus und zeige ihn als eigenen Schritt an. Lassen Sie ihn weg, und Nutzer geben der Funktion die Schuld an der Langsamkeit, obwohl das Modell in Wirklichkeit nur geladen wird.

6. Die Einschränkung der URL-Lebensdauer, die niemand erwähnt

Diese Einschränkung hat mich einen halben Tag gekostet, und es ärgert mich immer noch, dass sie nicht dokumentiert ist. Wenn Sie das Framework fragen, wo eine heruntergeladene Ressource liegt, bekommen Sie eine URL zurück — und diese URL ist nur für den aktuellen Prozess gültig. Cachen Sie sie nicht. Schreiben Sie sie nicht in UserDefaults. Geben Sie sie keinem Hintergrund-Task mit, der den Prozess überlebt.

Das robuste Muster: Lösen Sie die URL bei jedem Start neu auf und reichen Sie sie dann an den Code weiter, der sie braucht.

Diese Einschränkung hat mich zu einem Design gedrängt, mit dem ich wirklich zufrieden bin. Es ist derselbe Instinkt, auf den ich mich inzwischen überall stütze: Zuständigkeiten früh in eigene Packages auslagern. Ein Package löst die Ressource auf und gibt eine frische URL zurück; ein anderes Package nimmt diese URL entgegen und führt die Inferenz aus. Die App führt beide zusammen, sodass die URL-Lebensdauerregel auf der Ressourcenseite bleibt.

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>
}

Diese Art Trennung zahlt sich später aus. Wenn die URL-Einschränkung zuschlägt, liegt der Fix an genau einer Stelle.

7. Download-Kontinuität, wenn die auslösende UI verschwindet

Eine Frage hatte ich ziemlich früh: Wenn der Nutzer die Einstellungen öffnet, den Download startet und die Einstellungen wieder schließt — läuft der Download weiter?

Ja. Der Download hängt an der Extension, nicht an der View, die ihn gestartet hat. Das System pausiert und setzt ihn über App-Starts und sogar Neustarts hinweg fort. Ein Nutzer kann die App vollständig beenden und am nächsten Tag zu einem fertigen Download zurückkehren. Ihre UI muss nur damit umgehen können, sich wieder an einen laufenden Download anzuhängen, statt einen neuen zu starten.

Die Extension selbst ist fast nichts:

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(_:) ist der Ort, an dem ich Bedingungen vor dem Erlauben eines Downloads prüfen würde: freier Speicherplatz, Wi-Fi, ein Consent-Flag in den gemeinsamen Defaults. Ich gebe true zurück, weil der Consent-Dialog bereits in der App stattgefunden hat, bevor der Nutzer den Download überhaupt auslösen kann.

8. Versprechen Sie bei „on-device“ nicht zu viel

Beim Marketing musste ich mich hier selbst bremsen. Es ist verlockend, „keine Internetverbindung erforderlich“ zu schreiben. Ich würde davon abraten.

Der Nutzer braucht das Netz mindestens einmal, um das Modell herunterzuladen. Je nachdem, wie Ihre App schwierigere Anfragen weiterleitet, gibt es vielleicht auch später Pfade nach außen. Also schreibe ich nur, was tatsächlich stimmt: Das Modell wird einmal heruntergeladen und arbeitet danach auf Ihrem Mac. Ich behaupte nicht „vollständig offline“, denn für die meisten realen Apps kann man für diesen Satz nicht sauber einstehen.

Ein solcher Widerspruch — „offline“ im Marketing, mehrere Gigabyte Download beim ersten Start — ist genau die Art von Detail, die in einer Ein-Stern-Bewertung landet. Präziser Text hält länger.

9. Vor der Veröffentlichung: asset packs an TestFlight anhängen

Asset packs leben in App Store Connect getrennt von Ihrem Binary: eigenständig hochgeladen, eigenständig verarbeitet, mit eigenem Prüfstatus.

Interne TestFlight-Tester können ein Pack verwenden, sobald es fertig verarbeitet ist — kein App Review nötig. Damit sind interne Builds der schnellste Weg, den echten Download-Ablauf auf einem Gerät zu testen. Externe Tester und App-Store-Nutzer warten auf die Prüfung. Maximal zehn Packs pro Einreichung; für ein oder zwei Modelle ist das mehr als genug.

10. Ihre IPA wächst nicht — aber der residente RAM schon

Am Anfang habe ich immer wieder zwei Zahlen vermischt, die man sauber trennen sollte. „Meine Modelle sind 4 GB, also ist meine App 4 GB“ — stimmt nicht. Mit Background Assets bleibt die IPA klein. Der Speicherplatz wird nur belegt, wenn Nutzer sich für den Download entscheiden, und das Betriebssystem legt die Ressourcen außerhalb Ihrer Sandbox ab.

Was wächst, ist der residente Arbeitsspeicher zur Inferenzzeit. Der Generator (4 Bit, ~2,6 GB resident) plus das Guardian-Modell (BF16 bei 8K Kontext, mehr als 1,1 GB) erreichen Spitzen um die 4,9 GB. Gut auf einem 32-GB-Mac. Knapp bei 16 GB. Außer Reichweite auf einem 8-GB-iPad. Ich nenne in der App-Store-Beschreibung sowohl das Minimum an Speicherplatz als auch das Minimum an RAM. Die Speicherplatzzahl ist offensichtlich, die RAM-Zahl nicht. Mir ist lieber, ein Nutzer weiß das vorher, als dass er es durch einen OOM-Crash herausfindet.

Lokale Entwicklung mit dem Mock-Server

Wenn ich aus dem Ganzen nur eine praktische Sache weitergeben dürfte, dann diese.

Ohne Mock nimmt jede Iteration den Umweg über App Store Connect — Minuten bis Stunden pro Zyklus. Das ist unbrauchbar. Apple liefert xcrun ba-serve mit, und fast kein Tutorial erwähnt es:

# 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>

Richten Sie Ihren Debug-Build auf den lokalen Server, entwickeln Sie gegen diesen lokalen Server und pushen Sie erst dann auf ASC, wenn das Verhalten stimmt. Damit wurde Background Assets für mich von „einreichen und hoffen“ zu etwas, das ich lokal wirklich entwickeln konnte.

Produktions-Checkliste

Was ich vor dem Einreichen prüfe:

  • Asset packs in App Store Connect hochgeladen und verarbeitet (Status überprüfen; nicht einfach einreichen).
  • Asset packs in derselben Einreichung wie das App-Binary enthalten oder separat vorab genehmigt.
  • Das App-Group-Entitlement stimmt zwischen Haupt-App und Extension überein.
  • Die Info.plist der Extension deklariert EXExtensionPointIdentifier = com.apple.background-asset-downloader-extension.
  • Auf einem sauberen Gerät getestet — nicht auf der Entwicklungsmaschine mit zwischengespeicherten Ressourcen. Der Kaltstart ist es, worauf es ankommt.
  • Alle vier Download-Zustände (nicht gestartet, wird heruntergeladen, pausiert/fehlgeschlagen-mit-Wiederholung, bereit) durchgespielt.
  • Der Warmup-Durchlauf findet statt und ist sichtbar vom Download zu unterscheiden.
  • Der Marketing-Text sagt „einmaliger Download“, niemals „keine Internetverbindung“.
  • Die App-Store-Beschreibung nennt das Minimum an Speicherplatz und das Minimum an RAM.
  • Interner TestFlight-Build mit dem Pack einem Smoke-Test unterzogen.
  • Externer TestFlight-Build getestet, nachdem das Pack die Prüfung durchlaufen hat.

Wo Apples Dokumentation noch Lücken hat

Ich hätte diesen Artikel nicht geschrieben, wenn die Dokumentation vollständig wäre. Diese Lücken haben mich am meisten Zeit gekostet:

  • xcrun ba-serve wird kaum erwähnt, obwohl der lokale Iterations-Workflow wahrscheinlich der praktisch nützlichste Teil des Frameworks ist.
  • Das Atomaritätsmuster für abhängige Packs bleibt unausgesprochen. Man lernt es sonst erst nach einem missglückten Release.
  • Die Einschränkung der URL-Lebensdauer ist an der API-Oberfläche nicht erkennbar und eine stille Quelle für Abstürze.
  • Für Upload- und Verarbeitungszeit in ASC gibt es keine veröffentlichte Schätzung; planen Sie Stunden ein.
  • Die Folgen der Richtlinie 2.1 für noch nicht heruntergeladene Packs sind nirgendwo dokumentiert, wo ich es finden konnte — man entdeckt sie erst im Review.
  • Die Unterschiede bei der Bereitstellung zwischen macOS und iOS verdienen einen eigenen Artikel.

Das Framework selbst ist gut. Die Dokumentation braucht Hilfe, und die Community braucht mehr Artikel von Leuten, die damit wirklich in Produktion gehen. Genau deshalb habe ich diesen hier geschrieben.

Wie es weitergeht

Wenn Ihnen solche Praxisnotizen helfen, ist der Newsletter der beste Weg, die nächste nicht zu verpassen.