Blog
Feature
5 de janeiro de 20269 min

Feed React Native: de 3s para 200ms com cache e RPC

Todo aplicativo tem aquela tela que concentra toda a complexidade. Para o TAMSIV, é o Feed. Um fluxo único que mistura tudo: atividade recente, tarefas concluídas, memorandos criados, eventos de calendário, distintivos desbloqueados, progresso de nível, atividade de grupo. É a tela mais ambiciosa do aplicativo — e a que me levou mais tempo para construir.

O que vou te contar aqui é a jornada completa: da primeira versão que levava 3 segundos para carregar à versão atual que carrega instantaneamente do cache. As escolhas técnicas, as otimizações e as lições aprendidas em três semanas de trabalho intenso.

Pontos chave a reter:
- Uma única RPC PostgreSQL (get_consolidated_feed) agrega todos os tipos de conteúdo
- A otimização de JOINs e a indexação reduziram o carregamento de 3s para 200ms
- O cache L1 (memória) + L2 (AsyncStorage) permite uma exibição instantânea
- As URLs assinadas do Supabase expiram após 60 minutos — a atualização em lote é crítica
- Cada tipo de elemento tem seu componente dedicado para uma FlatList performática

Por que um feed unificado em vez de telas separadas?

A pergunta é legítima. Por que não ter uma tela "tarefas recentes", uma tela "distintivos", uma tela "atividade de grupo"? Várias razões:

1. Redução da carga cognitiva. O usuário abre uma única visualização e vê tudo o que aconteceu. Não há necessidade de navegar entre 4 abas para ter uma visão geral. É o padrão que todos os aplicativos sociais usam — do Instagram ao LinkedIn — e os usuários o entendem intuitivamente.

2. Descoberta passiva. Um usuário que abre o feed para ver suas tarefas recentes também descobrirá que desbloqueou um distintivo ou que um colega comentou em um grupo. Isso é engajamento cruzado: uma funcionalidade impulsiona a outra.

3. Engajamento pela gamificação. O sistema de gamificação do TAMSIV (12 níveis, 10 distintivos, sequências, desafios diários) só tem impacto se for visível. Um distintivo desbloqueado em uma tela oculta, ninguém o vê. Um distintivo no feed, todos o celebram.

Tela de smartphone mostrando um feed de atividade com distintivos de sucesso coloridos, barras de progresso e cartões de notificação
O Feed TAMSIV: tarefas, memorandos, distintivos e atividade de grupo em um fluxo unificado.

Como funciona a RPC consolidada?

O coração técnico do feed é uma única função PostgreSQL: get_consolidated_feed. Esta RPC agrega dados de 5 fontes diferentes:

  • Tarefas recentes (esquema privat.)
  • Memorandos recentes (esquema privat.)
  • Eventos do calendário (esquema privat.)
  • Atividade de gamificação (esquema gamification.) — distintivos, subidas de nível, sequências
  • Atividade de grupo (esquema collaborative.) — tarefas compartilhadas, comentários, atribuições

Tudo é retornado em um tipo unificado com um campo item_type para o roteamento no frontend. Cada elemento do feed é identificado por seu tipo, e o frontend sabe exatamente qual componente renderizar.

Por que uma RPC em vez de requisições separadas? Porque uma única consulta SQL é sempre mais rápida do que 5 requisições separadas, mesmo com o connection pooling do Supabase. Menos idas e vindas na rede, menos latência, e especialmente a possibilidade de ordenar e paginar no nível do banco de dados em vez de no lado do cliente.

Como otimizar uma requisição de 3 segundos para 200ms?

As primeiras versões do feed eram dolorosas. 3 segundos de carregamento. Para uma tela inicial, isso é eliminatório — os usuários fecham o aplicativo antes que o conteúdo apareça.

O problema: JOINs mal otimizados. A RPC inicial fazia JOINs em tabelas sem índice, com subconsultas correlacionadas. EXPLAIN ANALYZE mostrava varreduras sequenciais onde eram necessárias varreduras de índice.

Tela de computador mostrando um painel de monitoramento de desempenho de banco de dados com gráficos coloridos
Monitoramento de desempenho: cada milissegundo conta em um feed.

As otimizações aplicadas:

1. Indexação direcionada. Adicionei índices compostos nas colunas usadas nas cláusulas WHERE e ORDER BY da RPC. Um índice em (user_id, created_at DESC) sozinho dividiu o tempo da consulta por 3.

2. Reescrita de JOINs. As subconsultas correlacionadas (SELECT dentro de SELECT) foram substituídas por JOINs laterais. O PostgreSQL as otimiza muito melhor.

3. Limitação de colunas. Em vez de SELECT *, recupero apenas as colunas necessárias para a exibição no feed. Menos dados transferidos = menos tempo.

4. Paginação no lado do servidor. A RPC aceita os parâmetros p_offset e p_limit. Apenas 20 elementos são carregados por vez. A paginação infinita no lado do cliente solicita os próximos 20 quando o usuário se aproxima do final da lista.

Resultado: de 3 segundos para 200ms. Um fator de melhoria de 15. É o tipo de otimização que transforma um aplicativo "utilizável" em um aplicativo "agradável".

Como renderizar cada tipo de elemento de forma eficiente?

O feed mistura elementos muito diferentes: uma tarefa tem um título, uma prioridade, uma data limite. Um distintivo tem um ícone, um nome, uma descrição. Um evento tem uma hora, um local, participantes. Cada tipo requer um componente dedicado.

Os componentes do feed:

  • FeedTaskItem: tarefa com prioridade, data, atribuição
  • FeedMemoItem: memorando com pré-visualização do conteúdo e imagem de capa
  • FeedGamificationItem: distintivo, subida de nível, marco de sequência
  • FeedGroupItem: atividade colaborativa (novo membro, tarefa atribuída, comentário)
  • FeedCalendarItem: evento futuro

Tudo em uma FlatList de react-native-gesture-handler (obrigatório, não a do react-native — veja os gotchas do gesture-handler) com getItemLayout para o cálculo de altura e keyExtractor baseado no par (item_type, id).

O padrão getItemLayout é crucial para o desempenho: ele permite que a FlatList calcule a posição de cada elemento sem renderizá-lo. Sem isso, a rolagem fica travada quando a lista contém centenas de elementos.

Como resolver o problema das imagens expiradas?

Esta é uma das armadilhas mais traiçoeiras do Supabase Storage. As URLs assinadas expiram após 60 minutos. Se um elemento do feed contém uma imagem (anexo de uma tarefa, capa de um memorando), a URL armazenada no feed expira e a imagem não é mais exibida.

A solução em duas partes:

1. Armazenar o storage_path, não a URL. A RPC get_consolidated_feed retorna um campo firstImageStoragePath além de firstImageUri. O caminho é permanente, a URL é temporária.

2. Atualização em lote das URLs. GamificationService.refreshFeedImageUrls() chama StorageService.refreshAttachmentUrlsBatch() para regenerar todas as URLs expiradas em uma única chamada em lote. Nenhuma chamada individual por imagem — uma única chamada para todas as imagens do feed.

Este padrão é detalhado no artigo sobre a redução do egress do Supabase. As URLs assinadas são um padrão poderoso para segurança, mas criam uma complexidade de gerenciamento que muitos desenvolvedores subestimam.

Como funciona o cache multinível?

O cache do feed é o segredo da exibição instantânea. Ele funciona em três níveis:

Cache L1 — Memória (Map). Os dados do feed são mantidos em um Map na memória através do ContentCacheService. É o mais rápido: acesso em O(1), sem desserialização. O feed carrega do L1 em menos de 10ms.

Cache L2 — AsyncStorage. Se o L1 estiver vazio (primeiro lançamento, reinício do aplicativo), os dados são recuperados do AsyncStorage. Mais lento que a memória (~50ms), mas mais rápido que uma chamada de rede.

Fonte da verdade — Supabase. Em segundo plano, os dados frescos são recuperados do Supabase e o cache é atualizado. O usuário vê os dados do cache imediatamente, e então a visualização é atualizada silenciosamente se novos dados estiverem disponíveis.

Pessoa relaxada em um sofá rolando um feed de aplicativo móvel com conteúdo que carrega suavemente
O feed carrega instantaneamente do cache e depois é atualizado em segundo plano.

O padrão "cache-first + background refresh" é usado pela maioria dos aplicativos de alto desempenho. É o que o SWR faz na web (stale-while-revalidate). No React Native, implementei-o manualmente com o ContentCacheService.

O Supabase Realtime agrega valor ao feed?

Sim, e é isso que torna o feed "vivo". O Supabase Realtime está configurado nas tabelas privat.tasks e privat.memos. Dois canais são abertos: content-cache-tasks e content-cache-memos.

Quando uma tarefa é criada, concluída ou modificada (pelo usuário ou por um membro do seu grupo), o evento Realtime é recebido e o cache L1 é invalidado. Na próxima vez que o feed for exibido, ele recupera os dados frescos.

O resultado: quando um colega conclui uma tarefa em um grupo colaborativo, a atividade aparece no seu feed em poucos segundos. Sem atualização manual, sem pull-to-refresh — acontece automaticamente.

Quais são os desempenhos em dispositivos de entrada?

Um feed complexo com imagens, distintivos e animações pode ser problemático em dispositivos modestos. Aqui estão as otimizações específicas:

  • Reciclagem de componentes: a FlatList renderiza apenas os elementos visíveis. Os elementos fora da tela são reciclados, não excluídos e recriados.
  • Imagens lazy-loaded: as imagens são carregadas apenas quando entram na viewport + uma margem de 300px.
  • Animações reduzidas: em dispositivos detectados como "lentos" (via InteractionManager), as animações são simplificadas.
  • Lote de estatísticas de visualização: as estatísticas de visualização (quantas vezes um elemento foi visto) são enviadas em lote, não individualmente. Isso passou de N+1 requisições para apenas 1 graças às RPCs getTaskViewStatsBatch e getMemoViewStatsBatch.

Essas otimizações permitem que o feed funcione corretamente em dispositivos com 2 GB de RAM, o que cobre a maioria do mercado Android.

Como a gamificação se integra ao feed?

O sistema de gamificação do TAMSIV inclui 12 níveis, 10 distintivos, sequências (até 365 dias) e desafios diários. Cada evento de gamificação (distintivo desbloqueado, subida de nível, marco de sequência) aparece no feed como um elemento dedicado.

O FeedGamificationItem é visualmente distinto dos outros elementos: uma cor de destaque diferente, uma animação sutil e uma mensagem de felicitação. É um momento de celebração no fluxo — ele quebra o ritmo monótono das tarefas e memorandos e injeta emoção.

A integração é feita através do GamificationService (singleton) que é chamado de useTaskDetail (na conclusão de uma tarefa) e memoCreation.ts (na criação de um memorando). O serviço verifica as condições de distintivos e níveis e cria os elementos de feed correspondentes através das RPCs dedicadas. O sistema de notificações também é acionado para conquistas importantes.

O que aprendi construindo o feed

Esta tela me levou três semanas. É também a que mais me orgulho. Não porque seja visualmente espetacular — é um feed bastante clássico — mas porque funciona bem. É rápido, confiável e agradável de usar.

A principal lição: o desempenho é uma funcionalidade. Um feed que leva 3 segundos para carregar, ninguém o usa, não importa a quantidade de funcionalidades que ele contenha. Um feed que carrega instantaneamente, os usuários voltam a ele naturalmente.

É a mesma filosofia que apliquei a todo o aplicativo: as micro-interações fluidas, o onboarding sem atrito, a pesquisa instantânea. A velocidade e a fluidez são as melhores funcionalidades de retenção que um desenvolvedor solo pode implementar.

FAQ

Quantos elementos o feed pode conter?

Tecnicamente, o feed é infinito graças à paginação no lado do servidor (20 elementos por página). Na prática, usuários ativos acumulam algumas centenas de elementos por mês. A FlatList com reciclagem gerencia milhares de elementos sem problemas de memória.

O feed consome muitos dados móveis?

Não. O cache L1/L2 evita requisições repetidas. As imagens são compactadas e carregadas sob demanda (lazy-loaded). Uma atualização completa do feed consome cerca de 50 KB de dados (excluindo imagens). As imagens representam a maior parte do tráfego, mas são carregadas apenas uma vez e armazenadas em cache.

É possível filtrar o feed por tipo de conteúdo?

Ainda não na versão atual. O feed exibe tudo em ordem cronológica. É uma escolha deliberada: o feed é um lugar de descoberta, não de pesquisa. Para encontrar uma tarefa específica, há a pesquisa dedicada.

O Realtime funciona em segundo plano?

Não. Os canais do Supabase Realtime são fechados quando o aplicativo vai para o segundo plano (para economizar bateria). Quando o aplicativo volta para o primeiro plano, os canais são reabertos e o cache é atualizado. O atraso de reconexão é de 1 a 2 segundos.

Como o feed gerencia conteúdos excluídos?

Os elementos excluídos são removidos do cache L1 e L2 imediatamente via eventos Realtime (tipo de evento DELETE). Se um elemento excluído ainda estiver visível na FlatList, ele desaparece com uma animação de fade-out.