Zum Inhalt springen
Chandler Nguyen
KI6 Min. Lesezeit

KI

Der Offline-Modus für meine KI-App brauchte keine KI. Er brauchte das Langweilige.

Ich hätte fast einen cleveren, KI-spezifischen Offline-Modus für die iOS-App von DIALØGUE gebaut. Dann stellte ich eine langweiligere Frage — was gibt mir iOS eigentlich schon mit? — und die eigentliche Arbeit bestand darin, dem Drang zu widerstehen, clever zu sein. Hier ist die ganz normale, unspektakuläre Maschinerie hinter „lade eine Folge herunter und hör sie im Flugzeug", und warum das Langweilige die reifere Wahl war.

Vor Kurzem habe ich für die iOS-App von DIALØGUE die Offline-Downloads veröffentlicht — genau der Teil, den ich in meinem letzten Beitrag nur beiläufig als „robustes Offline" abgewunken habe, ohne je zu erklären, was das heißt. Dieser Beitrag ist die Erklärung. Und er beginnt damit, dass ich beinahe das Falsche gebaut hätte.

Hier das Geständnis. DIALØGUE ist eine KI-Podcast-App: Du gibst ihr ein Thema oder ein PDF, und sie erzeugt ein Gespräch zwischen zwei Hosts. Als ich also mit dem Offline-Modus anfing, sprang mein Kopf sofort zu etwas KI-Förmigem. Vielleicht hieß „offline", das Audio vorab auf dem Gerät zu erzeugen. Vielleicht den Model-Output zu cachen, oder irgendeine clevere Pipeline, die eine Folge ohne Netz wieder zusammenbauen könnte. Eine ganze Architektur formte sich in meinem Kopf. Fast hätte ich das Design-Doc genau so geschrieben.

Dann hielt ich inne und stellte eine langweiligere Frage: Was gibt mir iOS dafür eigentlich schon mit?

Die Antwort, wie sich herausstellte, war „fast alles". Und die eigentliche Arbeit an diesem Feature bestand nicht darin, etwas Schlaues zu bauen. Sie bestand darin, dem Drang zu widerstehen.

Die langweilige Frage hat mir einen Monat gespart

Das ist es, was ich Beitrag für Beitrag immer wieder neu lerne: Komplexität ist nicht dein Freund. Vor allem, wenn du allein bist und abends und am Wochenende baust.

Ein KI-Podcast ist, sobald er erzeugt wurde, nur eine Audiodatei, die unter einer URL liegt. „Offline" heißt nicht, ihn neu zu erzeugen. Es heißt, diese Datei herunterzuladen und sie später abzuspielen. Das ist kein KI-Problem. Es ist ein Problem, das Apple schon löst, seit es den App Store noch gar nicht gab.

Statt einer cleveren Pipeline habe ich also zum langweiligsten, bewährtesten Werkzeug im Kasten gegriffen: einer background URLSession. Falls du sie noch nicht kennengelernt hast — die Idee ist simpel: Du übergibst den Download dem Betriebssystem, und es läuft weiter, selbst wenn deine App im Hintergrund ist oder beendet wurde, und startet deine App dann neu, um dir die fertige Datei zu übergeben.

let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.downloads")
config.sessionSendsLaunchEvents = true

Ungefähr zwei Zeilen, und jetzt besitzt das Betriebssystem die wirklich harten Teile. Ich habe keine Download-Engine geschrieben. Ich habe mir die von Apple geliehen.

Dieselbe Geschichte wiederholte sich überall, wo ich hinsah:

  • Download mittendrin abgebrochen? iOS gibt dir „Resume-Data" — einen kleinen Blob, mit dem ein Download dort weitermacht, wo er aufgehört hat, statt bei null anzufangen. Ich schreibe ihn einfach auf die Platte (in den Caches-Ordner, weil er entbehrlich ist) und reiche ihn beim nächsten Mal wieder rein.
  • Du willst nicht das mobile Datenvolumen der Nutzer verbrennen? Eine Zeile: request.allowsCellularAccess = !wifiOnly. Danach wartet das Betriebssystem von allein auf WLAN. Ich überwache keine Verbindung. Ich polle gar nichts.
  • Du musst anzeigen, wie viel Platz die Downloads belegen? ByteCountFormatter macht aus einer Byte-Zahl „23,4 MB" mit den korrekt lokalisierten Einheiten. Das habe ich auch nicht geschrieben.

Ich will ehrlich sein, wie gut sich das anfühlt, denn es geht gegen den Instinkt. Im Rückblick war der richtige Zug nicht clevererer Code. Es war zuzugeben, dass das ein gelöstes Problem ist, und sich die Arbeit zu leihen, statt sie überlisten zu wollen.

Aber „langweilig" hieß nicht „einfach"

Jetzt muss ich etwas zugeben, denn wenn ich dir erzählen würde, dass Standardpraktiken das trivial gemacht haben, würde ich lügen — und jeder iOS-Entwickler, der das hier liest, würde mich in etwa zehn Sekunden ertappen.

Standard ist nicht dasselbe wie trivial. Die langweiligen Werkzeuge sind langweilig, weil sie abgegriffen sind, nicht weil sie reibungslos wären. Hier die Dinge, die mich tatsächlich Zeit gekostet haben, falls die Liste dir welche spart:

1. Die temporäre Datei verschwindet, während du sie noch ansiehst. Wenn ein Hintergrund-Download fertig ist, ruft iOS dich zurück und zeigt auf die Datei an einem temporären Ort. Der Haken, den niemand erwähnt: Diese Datei wird in dem Moment gelöscht, in dem dein Callback zurückkehrt. Du musst sie synchron an einen festen Ort verschieben, genau dort in der Funktion, bevor du irgendetwas anderes tust. Es wird noch umständlicher, denn dieser Callback läuft abseits des Main-Threads und weiß nur, welcher Download fertig ist, nicht, welche Folge das in den Begriffen meiner App ist. Also macht der Code einen hässlichen kleinen Zweischritt: die Datei sofort auf einen temporären Namen verschieben, dann auf den Main-Thread springen und sie umbenennen, sobald klar ist, was sie ist. Nicht schön. Aber die Alternative ist eine Datei, die weg ist, bevor du sie speichern kannst.

2. Background-Sessions tun im Simulator klammheimlich gar nichts. Ich habe eine peinlich lange Strecke in der Überzeugung verbracht, mein Code sei kaputt. War er nicht. Background URLSession verhält sich im iOS-Simulator schlicht nicht zuverlässig — kein Fehler, kein Crash, es lädt nichts herunter. Der Fix ist hässlich, und ich stehe dazu:

#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

Dieses #if DEBUG ist nicht elegant. Aber so zu tun, als verhielte sich der Simulator wie ein echtes Gerät, hätte mich weit mehr gekostet als das Eingeständnis, dass er es nicht tut.

3. Swift 6 hat mich gezwungen, genau zu sagen, wer auf welchem Thread läuft. Die Download-Callbacks kommen auf einem Hintergrund-Thread an. Mein UI-State — die kleinen Fortschrittsringe, das grüne „Heruntergeladen"-Häkchen — muss auf dem Main-Thread aktualisiert werden. Swift 6s strengere Concurrency-Prüfung wollte mir an dieser Grenze keine Schlampigkeit durchgehen lassen. Am Ende stand ein Aufräum-Commit mit dem Titel — wörtlich — „resolve Swift 6 actor-isolation warnings." Im Moment nervig, im Nachhinein richtig. Der Compiler hatte recht, und ich lag falsch.

4. Der Server benennt die Datei nicht immer sauber. Audio kommt mal als audio/mpeg zurück, mal als m4a, gelegentlich als etwas anderes. Also gibt es eine kleine, stumpfe Funktion, die die Dateiendung aus dem Content-Type der Response herleitet, auf die URL zurückfällt und im Zweifel .mp3 nimmt. Nicht glamourös. Notwendig.

Nichts davon ist ein KI-Problem. Jedes davon ist ein „wie Computer wirklich funktionieren"-Problem. Das ist die Steuer auf dem langweiligen Weg. Sie ist real — aber ich würde sie jedes einzelne Mal zahlen, lieber als den Preis dafür, etwas Cleveres zu pflegen, das ich mir selbst ausgedacht habe.

Was ich bewusst NICHT gebaut habe

Ich glaube, der nützlichste Teil dieses ganzen Features ist die Liste der Dinge, die ich nicht getan habe:

  • Ich habe keine Download-Engine geschrieben. (Background URLSession.)
  • Ich habe keinen Verbindungs-Monitor geschrieben. (allowsCellularAccess.)
  • Ich habe kein Resume-Protokoll erfunden. (Resume-Data des Betriebssystems.)
  • Ich habe nichts KI-Spezifisches gebaut. (Es ist eine Datei. Sie wird heruntergeladen.)

Worauf ich wirklich Sorgfalt verwendet habe, war klein und unspektakulär: eine winzige Queue, damit nie mehr als drei Downloads gleichzeitig laufen und der Rest wartet, bis er dran ist; das Speichern der Folgentitel, damit der Speicher-Screen nach einem Kaltstart echte Namen zeigt statt „Folge, Folge, Folge"; das Sortieren dieses Screens nach größter Datei zuerst, damit, wenn du Platz schaffen willst, die schlimmsten Brocken ganz oben sitzen. Und die eigentlichen Entscheidungen — wie viele Slots frei sind, wie die Liste sortiert wird — habe ich in schlichte Funktionen ohne jede URLSession herausgezogen, damit ich sie ordentlich unit-testen kann. Langweilig. Testbar. Langweilig weil testbar.

Die Lektion, ungefähr zum hundertsten Mal

Inzwischen habe ich irgendeine Version dieses Beitrags über den Neubau meiner Website geschrieben, über eine API-Rechnung von 54 Dollar, über den Neubau genau dieser App. Die Lektion ist immer dieselbe, und ich muss sie immer wieder neu lernen: Die clevere Lösung ist meist die teure. „Was gibt mir die Plattform eigentlich schon mit?" — das ist die wertvollste Frage, die ich stellen kann, bevor ich eine einzige Zeile Code schreibe.

Gerade für einen Solo-Builder gilt: Jede clevere Sache, die du erfindest, ist eine Sache, die du allein pflegen musst, wenn sie zum schlechtestmöglichen Zeitpunkt kaputtgeht. Jede langweilige, standardisierte Sache, die du dir leihst, wird von einer Firma mit Tausenden Ingenieuren im Rücken gepflegt. Aus meiner Erfahrung lohnt sich dieser Tausch fast immer — und ehrlich gesagt ist die Disziplin, ihn zu machen, schwerer als die Cleverness, die sie ersetzt. Ich könnte mich allerdings irren, wo genau die Grenze verläuft. Es gibt sicher Probleme, bei denen die Plattform dir nichts mitgibt und du das Harte selbst bauen musst — ich stelle nur immer wieder fest, dass das seltener ist, als mein Instinkt behauptet.

Ein paar der Leute, die DIALØGUE nutzen, können jetzt eine Folge herunterladen, ins Flugzeug steigen und sie ohne Empfang hören. Das fühlt sich nach einem sinnvollen Feature an. Der Code dahinter ist beinahe aggressiv unauffällig. Ich habe meinen Frieden damit gemacht. Ich glaube, das ist der Job.

Machst du das auch — dich dabei ertappen, wie du nach der cleveren Variante greifst, während die langweilige direkt daneben lag? Oder bin nur ich zu langsam und lerne dieselbe Lektion wieder und wieder? Ich würde wirklich gern hören, wie andere gegen diesen Instinkt ankämpfen.

Viele Grüße, Chandler