Download-Fortsetzung unter iOS mit einem lokalen Range-Server prüfen

Download-Fortsetzung unter iOS mit einem lokalen Range-Server prüfen

Bei der Remote-Entwicklung einer iOS-App, die umfangreiche Offline-Ressourcen herunterladen muss, ist ein weiterlaufender Fortschrittsbalken nach einer Netzunterbrechung noch kein Beleg dafür, dass der Client bereits geladene Bytes tatsächlich wiederverwendet. Möglicherweise lädt er alles erneut herunter und zeigt lediglich den alten Fortschritt an. Auf einem Cloud-Mac lässt sich der Simulator mit einem lokalen Dienst auf demselben Rechner verbinden. Serverprotokolle und ein Dateiabgleich ermöglichen so einen reproduzierbaren Test, ganz ohne öffentlichen Dateidienst.

Erfolgskriterien festlegen

Dieser Test beschränkt sich auf eine Datei und einen einzelnen Bytebereich; parallele Teil-Downloads sind nicht abgedeckt. Die Testdatei darf keine sensiblen Daten enthalten und muss groß genug sein, um den Download zwischendurch manuell abzubrechen. Die erste Anfrage sollte 200 erhalten. Hat der Client gültige Informationen zur Fortsetzung gespeichert, kann eine spätere Anfrage Range: bytes=N- enthalten; der Server antwortet dann mit 206 und einem korrekten Content-Range. Länge und SHA-256-Hash der fertigen Datei müssen mit dem Original übereinstimmen.

resumeData enthält Daten, mit denen der Client versucht, den Download fortzusetzen. Es beweist nicht, dass der Server bereits eine Teilantwort geliefert hat. Ein Abbruch erzeugt außerdem nicht zwangsläufig verwendbare resumeData.

Erzeuge die Datei in einem eigenen Testverzeichnis und halte die Ausgangswerte fest. Ihre Größe soll lediglich Zeit für einen manuellen Abbruch lassen; sie ist weder eine Produktspezifikation noch eine Leistungskennzahl:

mkdir -p ~/range-lab && cd ~/range-lab
python3 - <<'PY'
with open("fixture.bin", "wb") as out:
    for _ in range(64):
        out.write(b"XCZone-range-check" * 65536)
PY
shasum -a 256 fixture.bin
stat -f '%z bytes' fixture.bin

Range-Server nur für lokale Zugriffe einrichten

Der folgende Minimalserver stellt ausschließlich fixture.bin aus dem aktuellen Verzeichnis bereit. Er erzeugt ein ETag für die Datei und kann ab einem angegebenen Offset bis zum Dateiende liefern. Nicht unterstützte Range-Anfragen beantwortet er mit 416. Nach jedem geschriebenen Block pausiert er kurz, damit sich der Download im Simulator unterbrechen lässt. Als öffentlich erreichbarer Dateidienst ist er nicht geeignet.

from hashlib import sha256
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from time import sleep
import re

path = Path("fixture.bin")
size = path.stat().st_size
etag = '"' + sha256(path.read_bytes()).hexdigest() + '"'

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/fixture.bin":
            self.send_error(404)
            return
        requested = self.headers.get("Range")
        if self.headers.get("If-Range") not in (None, etag):
            requested = None
        start = 0
        if requested:
            match = re.fullmatch(r"bytes=(\d+)-", requested)
            if not match or int(match.group(1)) >= size:
                self.send_response(416)
                self.send_header("Content-Range", f"bytes */{size}")
                self.end_headers()
                return
            start = int(match.group(1))
        self.send_response(206 if requested else 200)
        self.send_header("Content-Type", "application/octet-stream")
        self.send_header("Accept-Ranges", "bytes")
        self.send_header("ETag", etag)
        self.send_header("Content-Length", str(size - start))
        if requested:
            self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}")
        self.end_headers()
        with path.open("rb") as source:
            source.seek(start)
            while chunk := source.read(65536):
                try:
                    self.wfile.write(chunk)
                    self.wfile.flush()
                except (BrokenPipeError, ConnectionResetError):
                    break
                sleep(0.01)

HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

Speichere den Code als range_server.py und führe im selben Verzeichnis python3 range_server.py aus. Öffne ein weiteres Terminal und prüfe zunächst mit curl -i -r 1024-2047 http://127.0.0.1:8765/fixture.bin -o /dev/null den Statuscode und die Antwort-Header. Die Ausgabe kann einen Übertragungsfortschritt anzeigen; daraus lässt sich der Inhalt nicht beurteilen. Alternativ lassen sich mit curl -sD - -r 1024-2047 -o /dev/null http://127.0.0.1:8765/fixture.bin gezielt 206, Content-Range: bytes 1024-2047/… und Content-Length prüfen. Beende den Server nach dem Test per Tastaturabbruch und ändere die Listener-Adresse nicht auf eine nach außen erreichbare Netzwerkschnittstelle.

Download im Simulator unterbrechen und fortsetzen

Lass die Testversion der App http://127.0.0.1:8765/fixture.bin mit URLSessionDownloadTask herunterladen. Der Simulator und der Mac, auf dem er läuft, können dafür denselben lokalen Pfad zum Dienst nutzen. Auf einem echten Gerät zeigt 127.0.0.1 dagegen auf das Smartphone; die Adresse lässt sich dort nicht unverändert übernehmen. Falls eine Ausnahme für unverschlüsseltes HTTP nötig ist, richte sie nur in einer separaten Testkonfiguration ein und prüfe, dass sie im Release-Build nicht enthalten ist.

Rufe nach Beginn des Downloads cancel(byProducingResumeData:) auf, speichere die Daten aus dem Callback und erstelle mit downloadTask(withResumeData:) einen neuen Task. Bewahre die Terminalausgabe des Servers auf und protokolliere für jede Anfrage den Range-Header, den Antwortstatus und die Größe der fertigen Datei. Prüfe zuerst, ob ein normaler Download abgeschlossen werden kann, und teste erst danach die Unterbrechung. Andernfalls lassen sich Verbindungsprobleme nur schwer von Problemen bei der Fortsetzung unterscheiden.

Fortsetzung von erneutem Download unterscheiden

Fehlt bei der zweiten Anfrage der Range-Header und antwortet der Server mit 200, hat der Client möglicherweise einen vollständigen Neustart gewählt. Das ist keine erfolgreiche teilweise Fortsetzung. Enthält die Anfrage einen Range-Header, führt ein geändertes Datei-ETag aber zu einer vollständigen Antwort, muss die Datei ebenfalls von vorn geschrieben werden. In keinem Fall darf eine vollständige Antwort an das Ende eines alten Fragments angehängt werden. Der Testcode sollte auch den Fall abdecken, dass nach dem Abbruch keine resumeData verfügbar sind: Ein ausdrücklicher Neustart muss möglich sein, statt den Task im Zustand „Warten auf Fortsetzung“ stehen zu lassen.

Ergebnis per Dateiabgleich verifizieren

Kopiere nach Abschluss des Download-Tasks dessen temporäre Datei in das Testverzeichnis. Führe dort shasum -a 256 下载后的文件路径 aus, vergleiche den Hash exakt mit dem der Quelldatei und prüfe auch die Länge. 206 besagt lediglich, dass der Server Teildaten zurückgegeben hat. Stimmen die Hashes nicht überein, müssen weiterhin Offset, Schreibweise und mögliche Änderungen der Datei zwischen den beiden Anfragen geprüft werden.

Teste zum Schluss einen Gegenfall: Unterbrich den Download, ändere anschließend die Quelldatei und starte den Testserver neu, sodass sich das ETag ändert. Der Client darf das alte Fragment nicht als erste Hälfte der neuen Datei verwenden. Anfrageprotokoll, Statuscodes, Längen und Hashes in einem gemeinsamen Testprotokoll festzuhalten, macht Regressionen leichter auffindbar als ein einzelner Screenshot des Fortschrittsbalkens.

Häufig gestellte Fragen

Beweist eine 206-Antwort bereits eine erfolgreiche Fortsetzung?

Nein. Auch angeforderter Startpunkt, endgültige Dateigröße und SHA-256 müssen stimmen.

Wie ist eine 200-Antwort beim erneuten Versuch zu behandeln?

Als vollständiger Download: Datei neu schreiben, nicht an ein vorhandenes Fragment anhängen.

XCZone · Cloud-Mac

Sie suchen einen exklusiven Mac, auf den Sie remote zugreifen können?

Vergleichen Sie Ausstattung, Standorte und Abrechnungszeiträume von M4 Start, M4 Work und M4 Pro – und finden Sie den passenden Tarif für Ihre Aufgaben.

Cloud-Mac-Tarife ansehen