Binary Downloads in Android
Du lernst, große Binärdateien sicher zu laden. Fokus: Fortschritt, Abbruch und saubere Speicherung.
Binary Downloads sind Downloads von Binärdateien wie PDFs, ZIP-Archiven, Bildern, Audio-Dateien, Modellen oder Datenbanken. In Android geht es dabei nicht nur darum, eine URL aufzurufen. Du musst große Datenmengen als Stream lesen, Fortschritt melden, einen Abbruch sauber behandeln und die Datei so speichern, dass sie nach App-Neustarts, Fehlern und schlechten Netzwerken nicht kaputt oder unauffindbar ist.
Was ist das?
Ein Binary Download lädt keine strukturierte Antwort wie JSON, sondern eine Folge von Bytes. Deine App interessiert sich dabei oft nicht für jedes einzelne Byte, sondern für das Ergebnis: eine Datei auf dem Gerät. Genau dort entsteht der Unterschied zu vielen einfachen Netzwerkbeispielen. Bei JSON liest du meistens eine relativ kleine Antwort, wandelst sie in Kotlin-Objekte um und gibst sie an die UI weiter. Bei einer großen Datei kann diese Strategie problematisch werden, weil der Arbeitsspeicher begrenzt ist und der Download länger dauert.
Das mentale Modell ist: Netzwerk, Datei und Zustand arbeiten zusammen. Das Netzwerk liefert Bytes in Blöcken. Deine App schreibt diese Blöcke in einen sicheren Speicherort. Parallel entsteht ein Zustand: noch nicht gestartet, läuft mit Fortschritt, abgeschlossen, abgebrochen oder fehlgeschlagen. Dieser Zustand ist für die Oberfläche wichtig, besonders in Jetpack Compose. Compose sollte nicht selbst herunterladen, sondern nur den Zustand anzeigen und Benutzeraktionen wie „Abbrechen“ weitergeben.
Im modernen Android-Kontext gehört ein solcher Ablauf in die Data Layer. Dort liegen Repositorys, Datenquellen und Regeln dafür, wie Daten geladen und gespeichert werden. Die UI ruft also nicht direkt eine Download-API auf, sondern spricht mit einem Repository oder Use Case. Das passt auch zu Offline-First-Denken: Wenn eine Datei erfolgreich gespeichert ist, kann sie später ohne Netz verwendet werden. Deine App sollte deshalb nicht nur fragen „Kann ich herunterladen?“, sondern auch „Habe ich schon eine gültige lokale Datei?“.
Binary Downloads begegnen dir im Alltag häufiger, als es am Anfang wirkt. Eine Lern-App lädt Kursmaterial. Eine Banking-App speichert Kontoauszüge. Eine Karten-App lädt Offline-Kacheln. Eine Medien-App cached Audiodateien. In allen Fällen muss der Download auch bei langsamem Netz verständlich bleiben. Der Nutzer braucht Feedback, und deine App braucht klare Regeln für Speicherort, Dateinamen, Fehler und Aufräumen.
Wie funktioniert es?
Technisch besteht ein stabiler Binary Download aus wenigen Bausteinen. Zuerst öffnest du eine Netzwerkverbindung, etwa über OkHttp oder eine Retrofit-Response mit ResponseBody. Dann liest du den Eingabestream in kleinen Puffern, zum Beispiel mit 8 KiB oder 32 KiB. Jeden gelesenen Block schreibst du direkt in einen Ausgabestream einer Datei. Dadurch liegt nie die komplette Datei im RAM.
Fortschritt berechnest du nur, wenn die Gesamtgröße bekannt ist. Viele Server senden einen Content-Length-Header. Dann kannst du bytesReadTotal / contentLength in Prozent umrechnen. Fehlt die Länge, solltest du keinen falschen Prozentwert anzeigen. In diesem Fall ist ein unbestimmter Fortschritt sinnvoll, etwa ein laufender Indikator mit Text wie „Lade Datei“. Ein häufiger Fehler ist, bei fehlender Länge einfach 100 oder 0 zu erzwingen. Das sieht in der UI stabil aus, ist aber fachlich falsch und erschwert Debugging.
Cancellation ist bei Kotlin Coroutines ein Kernkonzept. Wenn ein Download in einer Coroutine läuft, sollte ein Abbruch die Coroutine beenden, Streams schließen und keine halbfertige Datei als gültig markieren. Dafür ist wichtig, kooperativ zu arbeiten: Während der Schleife prüfst du, ob die Coroutine noch aktiv ist, oder du nutzt suspendierende APIs, die Cancellation unterstützen. Außerdem gehört Stream-Schließen in use-Blöcke oder in finally, damit Dateihandles nicht offen bleiben.
Speichersicherheit ist der dritte Teil. Schreibe große Downloads nicht direkt in den finalen Dateinamen. Wenn die App abstürzt oder der Nutzer abbricht, bleibt sonst eine Datei zurück, die von außen wie vollständig aussieht. Besser ist ein temporärer Name, etwa manual.pdf.part. Erst wenn der Download vollständig war und die erwartete Länge stimmt, benennst du die Datei atomar oder kontrolliert um. Bei Abbruch oder Fehler löschst du die temporäre Datei oder markierst sie ausdrücklich als fortsetzbaren Teilstand, wenn du Resume später wirklich implementierst.
Der Speicherort hängt vom Zweck ab. App-interne Dateien sind passend, wenn nur deine App die Datei braucht. Der Cache ist passend, wenn die Datei erneut geladen werden kann und bei Speicherknappheit verschwinden darf. Shared Storage oder der MediaStore sind passend, wenn der Nutzer die Datei außerhalb deiner App sehen soll. Diese Entscheidung ist Teil deiner Architektur, nicht nur ein technisches Detail.
In der Praxis
Ein gutes Repository gibt der UI keinen rohen Stream, sondern einen Zustandsfluss. So kann Compose den aktuellen Stand anzeigen, ohne Download-Logik zu kennen. Das folgende Beispiel ist bewusst kompakt. Es zeigt das Prinzip: in Blöcken lesen, Fortschritt melden, Abbruch respektieren, temporär speichern und erst am Ende finalisieren.
sealed interface DownloadState {
data object Idle : DownloadState
data class Running(
val bytesRead: Long,
val totalBytes: Long?
) : DownloadState
data class Finished(val file: File) : DownloadState
data object Cancelled : DownloadState
data class Failed(val message: String) : DownloadState
}
class FileDownloadRepository(
private val client: OkHttpClient,
private val filesDir: File
) {
fun download(url: String, fileName: String): Flow<DownloadState> = flow {
val finalFile = File(filesDir, fileName)
val tempFile = File(filesDir, "$fileName.part")
emit(DownloadState.Running(bytesRead = 0, totalBytes = null))
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
emit(DownloadState.Failed("HTTP ${response.code}"))
return@flow
}
val body = response.body ?: run {
emit(DownloadState.Failed("Leere Antwort"))
return@flow
}
val total = body.contentLength().takeIf { it > 0 }
var readTotal = 0L
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
try {
body.byteStream().use { input ->
tempFile.outputStream().use { output ->
while (currentCoroutineContext().isActive) {
val read = input.read(buffer)
if (read == -1) break
output.write(buffer, 0, read)
readTotal += read
emit(DownloadState.Running(readTotal, total))
}
}
}
if (!currentCoroutineContext().isActive) {
tempFile.delete()
emit(DownloadState.Cancelled)
return@flow
}
if (total != null && readTotal != total) {
tempFile.delete()
emit(DownloadState.Failed("Download unvollständig"))
return@flow
}
if (finalFile.exists()) finalFile.delete()
tempFile.renameTo(finalFile)
emit(DownloadState.Finished(finalFile))
} catch (e: CancellationException) {
tempFile.delete()
emit(DownloadState.Cancelled)
throw e
} catch (e: IOException) {
tempFile.delete()
emit(DownloadState.Failed(e.message ?: "Netzwerk- oder Dateifehler"))
}
}.flowOn(Dispatchers.IO)
In einer echten App würdest du zusätzlich entscheiden, ob der Download bei App-Schließen weiterlaufen soll. Für kurze, nutzernahe Aktionen reicht oft ein ViewModel mit Coroutine und sichtbarem Abbrechen-Button. Für lange Downloads, die zuverlässig im Hintergrund laufen sollen, prüfst du WorkManager oder eine Foreground Service-Strategie mit Benachrichtigung. Diese Entscheidung hängt von Nutzererwartung, Dateigröße und Plattformregeln ab.
In Compose kann der Screen den Zustand sammeln und darstellen. Wichtig ist, dass der Button nicht selbst Dateien schreibt. Er startet nur eine Aktion im ViewModel. Das ViewModel hält die Job-Referenz und kann cancel() aufrufen. So bleibt Cancellation kontrollierbar. Wenn du mehrere Downloads erlaubst, brauchst du außerdem IDs, damit Fortschritt und Abbruch nicht versehentlich den falschen Download treffen.
Eine klare Entscheidungsregel lautet: Sobald eine Datei größer sein kann als „klein und sofort verarbeitet“, behandelst du sie als Stream und nicht als ByteArray. response.body()?.bytes() ist verführerisch, weil es wenig Code braucht. Bei großen Dateien lädt es aber die komplette Antwort in den Speicher. Das kann zu Speicherproblemen führen, besonders auf älteren Geräten oder wenn die App parallel Bilder, Compose-Listen oder andere Daten hält.
Eine typische Stolperfalle ist unklarer Dateizustand. Stell dir vor, ein PDF wird zu 60 Prozent geladen, die App wird beendet, und beim nächsten Start findet deine Logik manual.pdf. Wenn du direkt in diese Datei geschrieben hast, kann die App sie für gültig halten. Der Nutzer öffnet dann eine beschädigte Datei. Mit .part-Dateien und einer finalen Umbenennung vermeidest du diesen Fehler. Noch besser ist eine kleine Metadatenstruktur, in der du URL, erwartete Größe, Prüfsumme, lokalen Pfad und Status speicherst.
Eine zweite Stolperfalle ist Fortschritt im falschen Layer. Wenn du Prozentwerte nur in der Composable berechnest, verteilst du Download-Wissen in die UI. Besser ist: Das Repository liefert gelesene Bytes und Gesamtgröße, das ViewModel formt daraus UI-State, und Compose zeigt diesen State an. So kannst du die Download-Logik testen, ohne eine Oberfläche zu starten.
Testen kannst du mit einem Fake-Server oder einer Fake-DataSource. Prüfe mindestens drei Fälle: erfolgreicher Download, Abbruch nach einigen Blöcken und Fehler bei einer unvollständigen Antwort. In Unit-Tests kannst du kontrollieren, ob nach Abbruch keine finale Datei existiert. In Code-Reviews solltest du gezielt nach vier Fragen suchen: Wird gestreamt? Wird Fortschritt korrekt berechnet? Wird Cancellation weitergereicht? Bleiben keine halbfertigen Dateien als fertige Dateien zurück?
Fazit
Binary Downloads wirken zuerst wie normale Netzwerkaufrufe, verlangen aber mehr Sorgfalt: Du arbeitest mit Dateien, Fortschritt, Abbruch und Speicherzustand gleichzeitig. Wenn du große Dateien gestreamt lädst, temporär speicherst, Cancellation ernst nimmst und den Zustand sauber durch Repository, ViewModel und UI führst, baust du eine robuste Grundlage für reale Android-Apps. Prüfe dein Verständnis aktiv: Implementiere einen kleinen PDF-Download, brich ihn im Debugger nach der Hälfte ab, starte die App neu und kontrolliere, ob nur gültige Dateien angezeigt werden. Genau solche Tests zeigen dir, ob deine Architektur nur im Erfolgsfall funktioniert oder auch unter echten Bedingungen stabil bleibt.