React Native Feed: von 3s auf 200ms mit Cache und RPC
Jede App hat diesen Bildschirm, der die gesamte Komplexität konzentriert. Für TAMSIV ist das der Feed. Ein einzigartiger Feed, der alles mischt: aktuelle Aktivitäten, erledigte Aufgaben, erstellte Memos, Kalenderereignisse, freigeschaltete Abzeichen, Level-Fortschritt, Gruppenaktivitäten. Es ist der ehrgeizigste Bildschirm der App – und der, dessen Erstellung mich am längsten gekostet hat.
Was ich dir hier erzählen werde, ist die komplette Reise: von der ersten Version, die 3 Sekunden zum Laden brauchte, bis zur aktuellen Version, die sofort aus dem Cache geladen wird. Die technischen Entscheidungen, die Optimierungen und die Lehren, die ich in drei Wochen intensiver Arbeit gelernt habe.
Wichtige Punkte zum Merken:
- Eine einzige PostgreSQL-RPC (get_consolidated_feed) aggregiert alle Inhaltstypen
- Die Optimierung von JOINs und die Indexierung haben die Ladezeit von 3s auf 200ms reduziert
- Der L1 (Speicher) + L2 (AsyncStorage) Cache ermöglicht eine sofortige Anzeige
- Supabase Signed URLs laufen nach 60 Minuten ab – der Batch-Refresh ist entscheidend
- Jeder Elementtyp hat seine eigene Komponente für eine performante FlatList
Warum ein einheitlicher Feed statt separater Bildschirme?
Die Frage ist berechtigt. Warum nicht einen Bildschirm "aktuelle Aufgaben", einen Bildschirm "Abzeichen", einen Bildschirm "Gruppenaktivität"? Dafür gibt es mehrere Gründe:
1. Reduzierung der kognitiven Belastung. Der Benutzer öffnet eine einzige Ansicht und sieht alles, was passiert ist. Es ist nicht nötig, zwischen 4 Tabs zu navigieren, um einen Überblick zu erhalten. Dies ist das Muster, das alle sozialen Apps – von Instagram bis LinkedIn – verwenden, und die Benutzer verstehen es intuitiv.
2. Passive Entdeckung. Ein Benutzer, der den Feed öffnet, um seine aktuellen Aufgaben zu sehen, wird auch entdecken, dass er ein Abzeichen freigeschaltet hat oder dass ein Kollege in einer Gruppe kommentiert hat. Das ist Cross-Engagement: Eine Funktion treibt eine andere an.
3. Engagement durch Gamification. Das Gamification-System von TAMSIV (12 Level, 10 Abzeichen, Streaks, tägliche Herausforderungen) hat nur dann eine Wirkung, wenn es sichtbar ist. Ein in einem versteckten Bildschirm freigeschaltetes Abzeichen sieht niemand. Ein Abzeichen im Feed feiern alle.
Wie funktioniert die konsolidierte RPC?
Der technische Kern des Feeds ist eine einzige PostgreSQL-Funktion: get_consolidated_feed. Diese RPC aggregiert Daten aus 5 verschiedenen Quellen:
- Aktuelle Aufgaben (Schema
privat.) - Aktuelle Memos (Schema
privat.) - Kalenderereignisse (Schema
privat.) - Gamification-Aktivität (Schema
gamification.) – Abzeichen, Level-Ups, Streaks - Gruppenaktivität (Schema
collaborative.) – geteilte Aufgaben, Kommentare, Zuweisungen
Alles wird in einem vereinheitlichten Typ mit einem Feld item_type für das Frontend-Routing zurückgegeben. Jedes Element des Feeds wird durch seinen Typ identifiziert, und das Frontend weiß genau, welche Komponente gerendert werden muss.
Warum eine RPC statt separater Anfragen? Weil eine einzige SQL-Anfrage immer schneller ist als 5 separate Anfragen, selbst mit dem Connection Pooling von Supabase. Weniger Netzwerk-Roundtrips, weniger Latenz und vor allem die Möglichkeit, auf Datenbankebene statt clientseitig zu sortieren und zu paginieren.
Wie optimiert man eine 3-Sekunden-Abfrage auf 200ms?
Die ersten Versionen des Feeds waren mühsam. 3 Sekunden Ladezeit. Für einen Startbildschirm ist das ein Ausschlusskriterium – Benutzer schließen die App, bevor der Inhalt erscheint.
Das Problem: schlecht optimierte JOINs. Die anfängliche RPC führte JOINs auf Tabellen ohne Index durch, mit korrelierten Unterabfragen. EXPLAIN ANALYZE zeigte sequentielle Scans, wo Index-Scans erforderlich gewesen wären.
Die angewandten Optimierungen:
1. Gezielte Indexierung. Ich habe zusammengesetzte Indizes auf den Spalten hinzugefügt, die in den WHERE- und ORDER BY-Klauseln der RPC verwendet werden. Ein Index auf (user_id, created_at DESC) hat die Abfragezeit allein um das Dreifache verkürzt.
2. Umschreiben von JOINs. Korrelierte Unterabfragen (SELECT in SELECT) wurden durch laterale JOINs ersetzt. PostgreSQL optimiert diese viel besser.
3. Begrenzung der Spalten. Statt SELECT * rufe ich nur die Spalten ab, die für die Anzeige im Feed notwendig sind. Weniger übertragene Daten = weniger Zeit.
4. Serverseitige Paginierung. Die RPC akzeptiert die Parameter p_offset und p_limit. Es werden jeweils nur 20 Elemente geladen. Die clientseitige unendliche Paginierung fordert die nächsten 20 an, wenn der Benutzer sich dem Ende der Liste nähert.
Ergebnis: von 3 Sekunden auf 200ms. Eine Verbesserung um den Faktor 15. Das ist die Art von Optimierung, die eine "brauchbare" App in eine "angenehme" App verwandelt.
Wie rendert man jeden Elementtyp effizient?
Der Feed mischt sehr unterschiedliche Elemente: Eine Aufgabe hat einen Titel, eine Priorität, ein Fälligkeitsdatum. Ein Abzeichen hat ein Symbol, einen Namen, eine Beschreibung. Ein Ereignis hat eine Uhrzeit, einen Ort, Teilnehmer. Jeder Typ erfordert eine eigene Komponente.
Die Feed-Komponenten:
FeedTaskItem: Aufgabe mit Priorität, Datum, ZuweisungFeedMemoItem: Memo mit Inhaltsvorschau und TitelbildFeedGamificationItem: Abzeichen, Level-Up, Streak-MeilensteinFeedGroupItem: Kollaborative Aktivität (neues Mitglied, zugewiesene Aufgabe, Kommentar)FeedCalendarItem: bevorstehendes Ereignis
Alles in einer FlatList von react-native-gesture-handler (obligatorisch, nicht die von react-native – siehe die Tücken des Gesture-Handlers) mit getItemLayout für die Höhenberechnung und keyExtractor basierend auf dem Paar (item_type, id).
Das getItemLayout-Muster ist entscheidend für die Leistung: Es ermöglicht der FlatList, die Position jedes Elements zu berechnen, ohne es zu rendern. Ohne dies ruckelt das Scrollen, wenn die Liste Hunderte von Elementen enthält.
Wie löst man das Problem abgelaufener Bilder?
Dies ist eine der tückischsten Fallen von Supabase Storage. Die Signed URLs laufen nach 60 Minuten ab. Wenn ein Element im Feed ein Bild enthält (Anhang einer Aufgabe, Cover eines Memos), läuft die im Feed gespeicherte URL ab und das Bild wird nicht mehr angezeigt.
Die Lösung in zwei Teilen:
1. Speichern des storage_path, nicht der URL. Die RPC get_consolidated_feed gibt zusätzlich zu firstImageUri ein Feld firstImageStoragePath zurück. Der Pfad ist permanent, die URL ist temporär.
2. Batch-Refresh der URLs. GamificationService.refreshFeedImageUrls() ruft StorageService.refreshAttachmentUrlsBatch() auf, um alle abgelaufenen URLs in einem einzigen Batch-Aufruf neu zu generieren. Keine individuellen Aufrufe pro Bild – ein einziger Aufruf für alle Bilder des Feeds.
Dieses Muster wird im Artikel über die Reduzierung des Supabase-Egress detailliert beschrieben. Signed URLs sind ein leistungsstarkes Muster für die Sicherheit, aber sie schaffen eine Komplexität in der Verwaltung, die viele Entwickler unterschätzen.
Wie funktioniert der mehrstufige Cache?
Der Feed-Cache ist das Geheimnis der sofortigen Anzeige. Er funktioniert auf drei Ebenen:
L1-Cache – Speicher (Map). Die Feed-Daten werden in einer Map im Speicher über den ContentCacheService gehalten. Das ist der schnellste Weg: Zugriff in O(1), keine Deserialisierung. Der Feed lädt aus dem L1 in weniger als 10ms.
L2-Cache – AsyncStorage. Wenn der L1 leer ist (erster Start, Neustart der App), werden die Daten aus AsyncStorage abgerufen. Langsamer als der Speicher (~50ms), aber schneller als ein Netzwerkaufruf.
Wahrheitsquelle – Supabase. Im Hintergrund werden die frischen Daten von Supabase abgerufen und der Cache aktualisiert. Der Benutzer sieht die Cache-Daten sofort, dann wird die Ansicht stillschweigend aktualisiert, wenn neue Daten verfügbar sind.
Das Muster "Cache-First + Hintergrund-Refresh" wird von den meisten performanten Apps verwendet. Das ist es, was SWR im Web macht (stale-while-revalidate). In React Native habe ich es manuell mit dem ContentCacheService implementiert.
Fügt Supabase Realtime dem Feed einen Mehrwert hinzu?
Ja, und das macht den Feed "lebendig". Supabase Realtime ist auf den Tabellen privat.tasks und privat.memos konfiguriert. Zwei Kanäle sind geöffnet: content-cache-tasks und content-cache-memos.
Wenn eine Aufgabe erstellt, abgeschlossen oder geändert wird (vom Benutzer oder einem Mitglied seiner Gruppe), wird das Realtime-Ereignis empfangen und der L1-Cache wird invalidiert. Beim nächsten Anzeigen des Feeds werden die frischen Daten abgerufen.
Das Ergebnis: Wenn ein Kollege eine Aufgabe in einer kollaborativen Gruppe erledigt, erscheint die Aktivität innerhalb weniger Sekunden in deinem Feed. Ohne manuelles Aktualisieren, ohne Pull-to-Refresh – es passiert von selbst.
Wie ist die Leistung auf Low-End-Geräten?
Ein komplexer Feed mit Bildern, Abzeichen und Animationen kann auf bescheideneren Geräten problematisch sein. Hier sind die spezifischen Optimierungen:
- Komponenten-Recycling: Die FlatList rendert nur die sichtbaren Elemente. Elemente außerhalb des Bildschirms werden recycelt, nicht gelöscht und neu erstellt.
- Lazy-Loaded Bilder: Bilder werden nur geladen, wenn sie in den Viewport + einen Rand von 300px gelangen.
- Reduzierte Animationen: Auf Geräten, die als "langsam" erkannt werden (über
InteractionManager), werden die Animationen vereinfacht. - Batch-Verarbeitung von View-Statistiken: Die View-Statistiken (wie oft ein Element angesehen wurde) werden im Batch gesendet, nicht einzeln. Dies wurde von N+1 Anfragen auf 1 einzige reduziert, dank der RPCs
getTaskViewStatsBatchundgetMemoViewStatsBatch.
Diese Optimierungen ermöglichen es dem Feed, auch auf Geräten mit 2 GB RAM ordnungsgemäß zu funktionieren, was den Großteil des Android-Marktes abdeckt.
Wie integriert sich Gamification in den Feed?
Das Gamification-System von TAMSIV umfasst 12 Level, 10 Abzeichen, Streaks (bis zu 365 Tage) und tägliche Herausforderungen. Jedes Gamification-Ereignis (freigeschaltetes Abzeichen, Level-Up, Streak-Meilenstein) erscheint im Feed als eigenes Element.
Das FeedGamificationItem unterscheidet sich visuell von den anderen Elementen: eine andere Akzentfarbe, eine subtile Animation und eine Glückwunschbotschaft. Es ist ein Moment der Feier im Feed – es durchbricht den monotonen Rhythmus von Aufgaben und Memos und injiziert Emotionen.
Die Integration erfolgt über den GamificationService (Singleton), der von useTaskDetail (bei Abschluss einer Aufgabe) und memoCreation.ts (bei Erstellung eines Memos) aufgerufen wird. Der Dienst überprüft die Bedingungen für Abzeichen und Level und erstellt die entsprechenden Feed-Elemente über die dedizierten RPCs. Das Benachrichtigungssystem wird auch für wichtige Erfolge ausgelöst.
Was ich beim Aufbau des Feeds gelernt habe
Dieser Bildschirm hat mich drei Wochen gekostet. Es ist auch der, auf den ich am stolzesten bin. Nicht, weil er visuell spektakulär ist – es ist ein ziemlich klassischer Feed – sondern weil er gut funktioniert. Er ist schnell, zuverlässig und angenehm zu bedienen.
Die wichtigste Lektion: Performance ist ein Feature. Einen Feed, der 3 Sekunden zum Laden braucht, nutzt niemand, egal wie viele Funktionen er enthält. Einen Feed, der sofort lädt, kehren die Benutzer natürlich zurück.
Das ist dieselbe Philosophie, die ich auf die gesamte App angewendet habe: die flüssigen Mikro-Interaktionen, das reibungslose Onboarding, die sofortige Suche. Geschwindigkeit und Flüssigkeit sind die besten Retention-Features, die ein Solo-Entwickler implementieren kann.
FAQ
Wie viele Elemente kann der Feed enthalten?
Technisch gesehen ist der Feed dank der serverseitigen Paginierung (20 Elemente pro Seite) unendlich. In der Praxis sammeln aktive Benutzer einige hundert Elemente pro Monat an. Die FlatList mit Recycling verwaltet Tausende von Elementen ohne Speicherprobleme.
Verbraucht der Feed viele mobile Daten?
Nein. Der L1/L2-Cache vermeidet wiederholte Anfragen. Bilder werden komprimiert und lazy-loaded. Ein vollständiger Feed-Refresh verbraucht etwa 50 KB Daten (ohne Bilder). Bilder machen den Großteil des Datenverkehrs aus, werden aber nur einmal geladen und zwischengespeichert.
Kann man den Feed nach Inhaltstyp filtern?
Noch nicht in der aktuellen Version. Der Feed zeigt alles chronologisch an. Das ist eine bewusste Entscheidung: Der Feed ist ein Ort der Entdeckung, nicht der Suche. Um eine bestimmte Aufgabe zu finden, gibt es die dedizierte Suche.
Funktioniert Realtime im Hintergrund?
Nein. Die Supabase Realtime-Kanäle werden geschlossen, wenn die App in den Hintergrund wechselt (um Batterie zu sparen). Wenn die App wieder in den Vordergrund kommt, werden die Kanäle wieder geöffnet und der Cache wird aktualisiert. Die Wiederverbindungszeit beträgt 1 bis 2 Sekunden.
Wie geht der Feed mit gelöschten Inhalten um?
Gelöschte Elemente werden sofort über Realtime-Ereignisse (Ereignistyp DELETE) aus dem L1- und L2-Cache entfernt. Wenn ein gelöschtes Element noch in der FlatList sichtbar ist, verschwindet es mit einer Fade-Out-Animation.