Feed React Native: da 3s a 200ms con cache e RPC
Ogni app ha quella schermata che concentra tutta la complessità. Per TAMSIV, è il Feed. Un unico flusso che mescola tutto: attività recente, attività completate, memo creati, eventi del calendario, badge sbloccati, progressione di livello, attività di gruppo. È la schermata più ambiziosa dell'app — e quella che mi ha richiesto più tempo per essere costruita.
Quello che ti racconterò qui è il percorso completo: dalla prima versione che impiegava 3 secondi per caricarsi alla versione attuale che si carica istantaneamente dalla cache. Le scelte tecniche, le ottimizzazioni e le lezioni apprese in tre settimane di intenso lavoro.
Punti chiave da ricordare:
- Un'unica RPC PostgreSQL (get_consolidated_feed) aggrega tutti i tipi di contenuto
- L'ottimizzazione dei JOIN e l'indicizzazione hanno ridotto il caricamento da 3s a 200ms
- La cache L1 (memoria) + L2 (AsyncStorage) consente una visualizzazione istantanea
- Gli URL firmati Supabase scadono dopo 60 minuti — il refresh batch è critico
- Ogni tipo di elemento ha il suo componente dedicato per una FlatList performante
Perché un feed unificato piuttosto che schermate separate?
La domanda è legittima. Perché non avere una schermata "attività recenti", una schermata "badge", una schermata "attività di gruppo"? Diverse ragioni:
1. Riduzione del carico cognitivo. L'utente apre un'unica vista e vede tutto ciò che è successo. Non c'è bisogno di navigare tra 4 schede per avere una panoramica. È il modello utilizzato da tutte le app sociali — da Instagram a LinkedIn — e gli utenti lo comprendono intuitivamente.
2. Scoperta passiva. Un utente che apre il feed per vedere le sue attività recenti scoprirà anche di aver sbloccato un badge o che un collega ha commentato in un gruppo. Questo è cross-engagement: una funzionalità ne spinge un'altra.
3. Coinvolgimento tramite gamification. Il sistema di gamification di TAMSIV (12 livelli, 10 badge, serie, sfide quotidiane) ha un impatto solo se è visibile. Un badge sbloccato in una schermata nascosta, nessuno lo vede. Un badge nel feed, tutti lo celebrano.
Come funziona la RPC consolidata?
Il cuore tecnico del feed è un'unica funzione PostgreSQL: get_consolidated_feed. Questa RPC aggrega dati da 5 diverse fonti:
- Attività recenti (schema
privat.) - Memo recenti (schema
privat.) - Eventi del calendario (schema
privat.) - Attività di gamification (schema
gamification.) — badge, avanzamenti di livello, serie - Attività di gruppo (schema
collaborative.) — attività condivise, commenti, assegnazioni
Il tutto restituito in un tipo unificato con un campo item_type per il routing lato frontend. Ogni elemento del feed è identificato dal suo tipo, e il frontend sa esattamente quale componente renderizzare.
Perché una RPC piuttosto che query separate? Perché una singola query SQL è sempre più veloce di 5 query separate, anche con il connection pooling di Supabase. Meno round-trip di rete, meno latenza, e soprattutto la possibilità di ordinare e impaginare a livello di database piuttosto che lato client.
Come ottimizzare una query da 3 secondi a 200ms?
Le prime versioni del feed erano dolorose. 3 secondi di caricamento. Per una schermata iniziale, è eliminatorio — gli utenti chiudono l'app prima che il contenuto appaia.
Il problema: JOIN mal ottimizzati. La RPC iniziale eseguiva JOIN su tabelle senza indice, con sottoquery correlate. EXPLAIN ANALYZE mostrava scansioni sequenziali dove erano necessarie scansioni di indice.
Le ottimizzazioni applicate:
1. Indicizzazione mirata. Ho aggiunto indici compositi sulle colonne utilizzate nelle clausole WHERE e ORDER BY della RPC. Un indice su (user_id, created_at DESC) ha ridotto il tempo della query di 3 volte da solo.
2. Riscrizione dei JOIN. Le sottoquery correlate (SELECT in SELECT) sono state sostituite da JOIN laterali. PostgreSQL le ottimizza molto meglio.
3. Limitazione delle colonne. Invece di SELECT *, recupero solo le colonne necessarie per la visualizzazione nel feed. Meno dati trasferiti = meno tempo.
4. Paginazione lato server. La RPC accetta i parametri p_offset e p_limit. Vengono caricati solo 20 elementi alla volta. La paginazione infinita lato client richiede i successivi 20 quando l'utente si avvicina alla fine dell'elenco.
Risultato: da 3 secondi a 200ms. Un fattore di miglioramento di 15. Questo è il tipo di ottimizzazione che trasforma un'app "utilizzabile" in un'app "piacevole".
Come renderizzare ogni tipo di elemento in modo efficiente?
Il feed mescola elementi molto diversi: un'attività ha un titolo, una priorità, una scadenza. Un badge ha un'icona, un nome, una descrizione. Un evento ha un'ora, un luogo, dei partecipanti. Ogni tipo richiede un componente dedicato.
I componenti del feed:
FeedTaskItem: attività con priorità, data, assegnazioneFeedMemoItem: memo con anteprima del contenuto e immagine di copertinaFeedGamificationItem: badge, avanzamento di livello, traguardo di serieFeedGroupItem: attività collaborativa (nuovo membro, attività assegnata, commento)FeedCalendarItem: evento imminente
Il tutto in una FlatList di react-native-gesture-handler (obbligatoria, non quella di react-native — vedi i problemi del gesture-handler) con getItemLayout per il calcolo dell'altezza e keyExtractor basato sulla coppia (item_type, id).
Il pattern getItemLayout è cruciale per le prestazioni: consente alla FlatList di calcolare la posizione di ogni elemento senza renderizzarlo. Senza di esso, lo scorrimento è a scatti quando l'elenco contiene centinaia di elementi.
Come risolvere il problema delle immagini scadute?
Questa è una delle trappole più insidiose di Supabase Storage. Gli URL firmati scadono dopo 60 minuti. Se un elemento del feed contiene un'immagine (allegato di un'attività, copertina di un memo), l'URL memorizzato nel feed scade e l'immagine non viene più visualizzata.
La soluzione in due parti:
1. Memorizzare il storage_path, non l'URL. La RPC get_consolidated_feed restituisce un campo firstImageStoragePath oltre a firstImageUri. Il percorso è permanente, l'URL è temporaneo.
2. Refresh batch degli URL. GamificationService.refreshFeedImageUrls() chiama StorageService.refreshAttachmentUrlsBatch() per rigenerare tutti gli URL scaduti in un'unica chiamata batch. Nessuna chiamata individuale per immagine — una sola chiamata per tutte le immagini del feed.
Questo modello è dettagliato nell'articolo sulla riduzione dell'egress Supabase. Gli URL firmati sono un modello potente per la sicurezza, ma creano una complessità di gestione che molti sviluppatori sottovalutano.
Come funziona la cache multilivello?
La cache del feed è il segreto della visualizzazione istantanea. Funziona su tre livelli:
Cache L1 — Memoria (Map). I dati del feed vengono mantenuti in una Map in memoria tramite il ContentCacheService. È il più veloce: accesso in O(1), nessuna deserializzazione. Il feed si carica dalla L1 in meno di 10ms.
Cache L2 — AsyncStorage. Se la L1 è vuota (primo avvio, riavvio dell'app), i dati vengono recuperati da AsyncStorage. Più lento della memoria (~50ms) ma più veloce di una chiamata di rete.
Fonte di verità — Supabase. In background, i dati freschi vengono recuperati da Supabase e la cache viene aggiornata. L'utente vede immediatamente i dati della cache, quindi la vista si aggiorna silenziosamente se sono disponibili nuovi dati.
Il pattern "cache-first + background refresh" è utilizzato dalla maggior parte delle app performanti. È quello che SWR fa sul web (stale-while-revalidate). In React Native, l'ho implementato manualmente con il ContentCacheService.
Supabase Realtime aggiunge valore al feed?
Sì, ed è ciò che rende il feed "vivo". Supabase Realtime è configurato sulle tabelle privat.tasks e privat.memos. Vengono aperti due canali: content-cache-tasks e content-cache-memos.
Quando un'attività viene creata, completata o modificata (dall'utente o da un membro del suo gruppo), l'evento Realtime viene ricevuto e la cache L1 viene invalidata. La prossima volta che il feed viene visualizzato, recupera i dati freschi.
Il risultato: quando un collega completa un'attività in un gruppo collaborativo, l'attività appare nel tuo feed in pochi secondi. Senza refresh manuale, senza pull-to-refresh — succede da solo.
Quali sono le prestazioni sui dispositivi di fascia bassa?
Un feed complesso con immagini, badge e animazioni può essere problematico sui dispositivi modesti. Ecco le ottimizzazioni specifiche:
- Riciclo dei componenti: la FlatList renderizza solo gli elementi visibili. Gli elementi fuori schermo vengono riciclati, non eliminati e ricreati.
- Immagini lazy-loaded: le immagini vengono caricate solo quando entrano nel viewport + un margine di 300px.
- Animazioni ridotte: sui dispositivi rilevati come "lenti" (tramite
InteractionManager), le animazioni vengono semplificate. - Batch delle statistiche di visualizzazione: le statistiche di visualizzazione (quante volte un elemento è stato visto) vengono inviate in batch, non individualmente. Si è passati da N+1 query a 1 sola grazie alle RPC
getTaskViewStatsBatchegetMemoViewStatsBatch.
Queste ottimizzazioni consentono al feed di funzionare correttamente su dispositivi con 2 GB di RAM, il che copre la maggior parte del mercato Android.
Come si integra la gamification nel feed?
Il sistema di gamification di TAMSIV comprende 12 livelli, 10 badge, serie (fino a 365 giorni) e sfide quotidiane. Ogni evento di gamification (badge sbloccato, avanzamento di livello, traguardo di serie) appare nel feed come un elemento dedicato.
Il FeedGamificationItem è visivamente distinto dagli altri elementi: un colore di accento diverso, un'animazione sottile e un messaggio di congratulazioni. È un momento di celebrazione nel flusso — rompe il ritmo monotono delle attività e dei memo e inietta emozione.
L'integrazione avviene tramite il GamificationService (singleton) che viene chiamato da useTaskDetail (al completamento di un'attività) e memoCreation.ts (alla creazione di un memo). Il servizio verifica le condizioni di badge e livelli e crea gli elementi del feed corrispondenti tramite le RPC dedicate. Anche il sistema di notifiche viene attivato per gli achievement importanti.
Cosa ho imparato costruendo il feed
Questa schermata mi ha richiesto tre settimane. È anche quella di cui sono più orgoglioso. Non perché sia visivamente spettacolare — è un feed piuttosto classico — ma perché funziona bene. È veloce, affidabile e piacevole da usare.
La lezione principale: la performance è una funzionalità. Un feed che impiega 3 secondi per caricarsi, nessuno lo usa, indipendentemente dalla quantità di funzionalità che contiene. Un feed che si carica istantaneamente, gli utenti ci tornano naturalmente.
È la stessa filosofia che ho applicato a tutta l'app: le micro-interazioni fluide, l'onboarding senza attriti, la ricerca istantanea. La velocità e la fluidità sono le migliori funzionalità di retention che uno sviluppatore singolo possa implementare.
FAQ
Quanti elementi può contenere il feed?
Tecnicamente, il feed è infinito grazie alla paginazione lato server (20 elementi per pagina). In pratica, gli utenti attivi accumulano alcune centinaia di elementi al mese. La FlatList con riciclo gestisce migliaia di elementi senza problemi di memoria.
Il feed consuma molti dati mobili?
No. La cache L1/L2 evita le richieste ripetute. Le immagini sono compresse e lazy-loaded. Un refresh completo del feed consuma circa 50 KB di dati (escluse le immagini). Le immagini rappresentano la maggior parte del traffico, ma vengono caricate solo una volta e messe in cache.
È possibile filtrare il feed per tipo di contenuto?
Non ancora nella versione attuale. Il feed visualizza tutto in ordine cronologico. È una scelta deliberata: il feed è un luogo di scoperta, non di ricerca. Per trovare un'attività specifica, c'è la ricerca dedicata.
Il Realtime funziona in background?
No. I canali Supabase Realtime vengono chiusi quando l'app passa in background (per risparmiare batteria). Quando l'app torna in primo piano, i canali vengono riaperti e la cache viene aggiornata. Il ritardo di riconnessione è di 1-2 secondi.
Come gestisce il feed i contenuti eliminati?
Gli elementi eliminati vengono rimossi dalla cache L1 e L2 immediatamente tramite gli eventi Realtime (tipo di evento DELETE). Se un elemento eliminato è ancora visibile nella FlatList, scompare con un'animazione di fade-out.