iOS-Paginierung auf Wettläufe und Dubletten prüfen

iOS-Paginierung auf Wettläufe und Dubletten prüfen

Ein Nutzer wechselt in einer iOS-Liste rasch die Filterbedingungen. Die zweite Seite der alten Abfrage ist noch unterwegs, während die erste Seite der neuen Abfrage bereits angezeigt wird. Sekunden später trifft die alte Antwort ein, und plötzlich stehen unpassende Einträge in der Liste. Bei stabiler Verbindung lässt sich dieser Fehler nur schwer reproduzieren. Auch wiederholtes manuelles Scrollen auf einem Cloud-Mac garantiert nicht, dass zwei Anfragen in der für den Fehler nötigen Reihenfolge zurückkommen. Zuverlässiger ist es, zunächst die Regeln für Schreibzugriffe bei der Paginierung als Zustandsmodell festzulegen, sie mit Antworten in vorgegebener Reihenfolge zu prüfen und das Modell erst danach an die Oberfläche anzubinden.

Festlegen, wie sich die Liste ändern darf

Das folgende Beispiel verwendet Seitenzahlen ab 1 und ganzzahlige fachliche IDs. Eine echte API kann weitere Felder liefern. Das Modell muss aber mindestens zwischen der aktuellen Abfrage, der Seite mit ausstehender Antwort, den bereits angezeigten IDs und der Angabe des Servers unterscheiden, ob eine weitere Seite existiert. Jeder Filterwechsel beginnt eine neue Abfrage. Selbst wenn die alte und die neue Abfrage dieselben Einträge liefern, darf eine alte Anfrage nicht in die neue Liste schreiben.

Jede Abfrage erhält eine aufsteigende generation. Zusammen mit der Seitenzahl bildet sie ein Anfrage-Ticket. Eine Antwort darf nur dann in die Liste schreiben, wenn ihr Ticket exakt zur aktuell ausstehenden Anfrage passt. Löst das Erreichen des Listenendes während einer laufenden Anfrage erneut einen Ladevorgang aus, wird das Ereignis ignoriert, statt zwei Anfragen für dieselbe Seite zu senden.

Das Abbrechen einer Anfrage spart Netzwerk- und Rechenressourcen. Es beweist nicht, dass der alte Callback nicht mehr ausgeführt wird. Prüfen Sie das Anfrage-Ticket weiterhin vor jedem Schreibzugriff auf die Liste.

Drei Antwortreihenfolgen mit einem ausführbaren Modell prüfen

Speichern Sie den folgenden Inhalt als Pager.swift und führen Sie auf einem Mac mit installierten Xcode-Kommandozeilenwerkzeugen xcrun swift Pager.swift aus. Der Code verwendet weder Netzwerk noch Timer. Daher prüft jeder Lauf dieselben Fehlerabläufe.

struct Ticket: Equatable {
    let generation: Int
    let page: Int
}

struct Pager {
    private(set) var generation = 0
    private(set) var nextPage = 1
    private(set) var ids: [Int] = []
    private(set) var inFlight: Ticket?
    private(set) var hasMore = true

    mutating func start() -> Ticket? {
        guard hasMore, inFlight == nil else { return nil }
        let ticket = Ticket(generation: generation, page: nextPage)
        inFlight = ticket
        return ticket
    }

    mutating func reset() {
        generation += 1
        nextPage = 1
        ids = []
        inFlight = nil
        hasMore = true
    }

    mutating func finish(
        _ ticket: Ticket,
        rows: [Int],
        hasMore: Bool
    ) {
        guard inFlight == ticket else { return }
        var seen = Set(ids)
        for id in rows where seen.insert(id).inserted {
            ids.append(id)
        }
        nextPage += 1
        self.hasMore = hasMore
        inFlight = nil
    }
}

var pager = Pager()
let page1 = pager.start()!
assert(pager.start() == nil)
pager.finish(page1, rows: [1, 2], hasMore: true)

let page2 = pager.start()!
pager.finish(page2, rows: [2, 3], hasMore: true)
pager.finish(page2, rows: [2, 3], hasMore: true)
assert(pager.ids == [1, 2, 3])

let oldPage = pager.start()!
pager.reset()
let newPage = pager.start()!
pager.finish(oldPage, rows: [4], hasMore: false)
assert(pager.ids.isEmpty)
pager.finish(newPage, rows: [7], hasMore: false)
assert(pager.ids == [7])
assert(pager.start() == nil)

Die erste Assertion prüft, dass wiederholtes Erreichen des Listenendes nur einen Ladevorgang startet. Die zweite Gruppe deckt sowohl eine seitenübergreifend doppelte ID als auch einen zweimal ausgeführten Callback ab: Eintrag 2 erscheint nur einmal, und eine bereits abgeschlossene Seite darf nicht erneut akzeptiert werden. Die letzte Gruppe simuliert einen Filterwechsel. Die alte Seite trifft mit einer alten generation ein und darf weder die neue Liste noch deren hasMore-Status verändern.

Bei echten Anfragen die Grenze für Schreibzugriffe beibehalten

Die Oberflächenschicht löst lediglich die Ereignisse „nächste Seite laden“ und „Abfrage zurücksetzen“ aus. Speichern Sie beim Start einer Anfrage das von start() zurückgegebene Ticket. Nach Abschluss übergeben Sie die Antwort an finish und lesen anschließend die Liste aus dem Modell. Beim Zurücksetzen können Sie zusätzlich die alte Netzwerkanfrage abbrechen. Ihr Abschluss-Callback darf das Modell aber nicht umgehen und direkt an das Array der Oberfläche anhängen.

Zwei Details müssen an die API angepasst werden. Erstens entfernt das Beispiel Dubletten anhand der ID. Wenn die API keine stabile ID liefert, legen Sie zunächst fest, wann zwei Datensätze als derselbe Eintrag gelten; die Position im Array ist kein Ersatz. Zweitens verwendet das Beispiel Seitenzahlen. Bei einer Cursor-API gehört der für die Anfrage verwendete Cursor in das Ticket. Erst nach Annahme der Antwort speichern Sie den von der Antwort zurückgegebenen neuen Cursor. Ist der neue Cursor gleich dem alten, obwohl die API weiterhin eine nächste Seite meldet, protokollieren Sie einen Protokollfehler und stoppen Sie das automatische Laden, damit nicht ständig dieselbe Seite angefragt wird.

Fehler und Wiederholungsversuche einordnen

Das Beispiel zeigt nur erfolgreiche Antworten. In einer echten Implementierung muss auch die Fehlerbehandlung das Ticket prüfen: Der Fehler einer alten Anfrage darf den Ladestatus einer neuen Abfrage nicht überschreiben. Schlägt die aktuelle Seite fehl, entfernen Sie ihre Markierung als laufende Anfrage, ohne die Seitenzahl zu erhöhen. Ein Wiederholungsversuch des Nutzers muss dieselbe Seite anfragen. Stellen Sie Laden, Fehler und die Möglichkeit zum erneuten Versuch in der Oberfläche getrennt dar, und löschen Sie vorhandene Einträge nicht wegen eines einzelnen Timeouts.

Tests in einer stabilen Umgebung ausführen

Führen Sie das obige Modell zuerst auf Ihrem Entwicklungsrechner aus, um die Zustandsregeln zu prüfen. Übernehmen Sie danach dieselben Abläufe in die Projekttests. Ein Test-Double sollte dem Testcode die Kontrolle darüber geben, wann die Antwort für jede Seite zugestellt wird, statt die Reihenfolge der Anfragen über kurze Wartezeiten zu erraten. Prüfen Sie anschließend die Oberfläche im iOS-Simulator: Bei wiederholtem Erreichen des Listenendes erscheint der Ladehinweis nur einmal, nach einem Filterwechsel blitzen alte Einträge nicht wieder auf, und die letzte Seite löst keine weitere Anfrage aus.

Die Latenz einer Remote-Desktop-Verbindung kann helfen, das Problem zu beobachten, darf aber keine Voraussetzung für funktionierende Tests sein. Ein Cloud-Mac ermöglicht dem Team, diese Assertions mit derselben Toolchain und Simulatorumgebung wiederholt auszuführen; die Antwortreihenfolge steuern weiterhin die Tests. Behalten Sie bei jeder Änderung der Listenimplementierung vier Testfälle bei: wiederholtes Erreichen des Listenendes, Dubletten über mehrere Seiten, eine verspätete alte Antwort und einen Wiederholungsversuch nach einem Fehler.

Vor dem Zusammenführen alle Schreibpfade prüfen

Verfolgen Sie zum Schluss die Aufrufkette und suchen Sie alle Stellen, die in das Array schreiben. Prüfen Sie Abschluss-Callbacks, Pull-to-Refresh, Filterwechsel und die Wiederherstellung der Ansicht. Alle müssen dieselbe Ticket-Prüfung durchlaufen. Sonst bestehen die Modelltests, während ein Callback auf einem anderen Pfad die Liste weiterhin verunreinigen kann. Stellen Sie außerdem sicher, dass das Entfernen von Dubletten nicht unbemerkt die vom Server vorgegebene Reihenfolge verändert und dass der Status der letzten Seite aus einer akzeptierten Antwort stammt – nicht aus irgendeiner Anfrage, die zufällig zuerst zurückkommt.

Um einen Wettlauf bei der Paginierung aufzudecken, müssen Sie nicht auf eine schlechte Netzwerkverbindung warten. Sobald „Welche Antwort darf die Liste verändern?“ als ausführbare Regel feststeht, werden Antworten in anderer Reihenfolge zu einem gewöhnlichen Regressionstest statt zu einem sporadischen Problem auf einem Screenshot aus dem Produktivbetrieb.

Häufig gestellte Fragen

Reicht es, die vorherige Anfrage abzubrechen?

Nein. Vor dem Schreiben in die Liste müssen zusätzlich die Abfragegeneration und die aktuell erwartete Seite übereinstimmen.

Wie behandelt man Datensätze, die auf mehreren Seiten erscheinen?

Einträge anhand einer stabilen fachlichen ID deduplizieren und unabhängig davon prüfen, ob Seite oder Cursor tatsächlich fortschreiten.

Dedizierter physischer Mac

Ein Mac für Ihre nächste Remote-Entwicklung

Wählen Sie Modell und Standort passend zu Ihrem Vorhaben und verbinden Sie sich per Bildschirmfreigabe, VNC oder SSH. Preise und Verfügbarkeit sehen Sie aktuell in der Konsole.

Remote-Mac konfigurieren