Dia 14 — Desempenho com muitos dados

Informatica · Conteudo · publicado em 05/10/2026
Dia 14 de 15

Desempenho com muitos dados

Aula 1

A lista grande e o FlatList

A lista grande e o FlatList

Com cinco mil tarefas, map dentro de uma ScrollView trava o aparelho: as cinco mil linhas são montadas de uma vez, todas em memória, todas desenhadas, mesmo com uma tela pequena. FlatList desenha só o que cabe na tela — e uma tela mostra talvez dez itens.

<FlatList
  data={tarefas}
  keyExtractor={(item) => String(item.id)}
  renderItem={({ item }) => (
    <View style={{ flexDirection: 'row' }}>
      <Text>{item.titulo}</Text>
    </View>
  )}
/>

O problema tem nome antes de ter solução: é o custo de muitos dados desenhados de uma vez, e o sintoma é o travamento na rolagem. A causa é estrutural, e não um problema de velocidade do aparelho: o map não tem noção de o que está visível, então ele monta tudo. A rolagem pesada é a consequência — o aparelho já gastou a memória e o tempo antes de o dedo tocar a tela.

O data é a lista, o renderItem desenha um item e o keyExtractor diz qual coluna identifica cada linha. Sem keyExtractor, o FlatList usa o índice, e o efeito aparece quando a lista é reordenada ou um item é removido: a tela mostra a linha errada até a rolagem seguinte.

O exemplo desta página compara as duas abordagens sem usar o componente, para poder contar as linhas de verdade. Ele monta a árvore do map e imprime quantas linhas ela tem, calcula quantas cabem na tela, e depois imprime quantas a lista virtualizada manteria montadas com a janela padrão — e a diferença entre os dois números é o tamanho do trabalho que o aparelho deixou de fazer.

As quatro propriedades que cortam o trabalho

PropriedadeO que fazValor típico
initialNumToRenderquantas linhas desenha na primeira vez10
maxToRenderPerBatchquantas desenha a cada lote de rolagem10
windowSizequantas telas vale a janela montada21
removeClippedSubviewsdescarta a view fora da telatrue

windowSize é a mais difícil de acertar porque o valor é a quantidade de telas que ficam montadas além da visível. 21 mantém dez de cada lado mais a do meio — o suficiente para a rolagem antecipada e pouco o bastante para a memória. 1 monta só o que está visível e faz a rolagem piscar em aparelho antigo.

A janela é uma troca, e o exemplo a mostra em números: windowSize igual a 1 mantém o mínimo visível, 21 mantém o que a rolagem antecipada precisa, e 51 monta quatro vezes mais que o necessário. O ponto de equilíbrio depende do aparelho — e é por isso que o valor é grande o bastante por padrão, e não o menor possível.

removeClippedSubviews é a única das quatro que muda de verdade: as outras decidem quando desenhar, e essa descarta da memória o que já saiu da tela. Ela economiza memória de verdade, e em aparelho com pouca RAM é a diferença entre o aplicativo abrir e o sistema fechá-lo.

getItemLayout e a rolagem sem chute

FlatList começa a rolagem supondo que cada item tem uma altura e calcula o quanto andou. Com altura fixa — título em uma linha e prazo embaixo — getItemLayout dá a altura e o deslocamento, e a rolagem vai direto ao ponto:

getItemLayout={(_, indice) => ({ length: ALTURA_DA_LINHA, offset: ALTURA_DA_LINHA * indice, index: indice })}

Sem essa função, o FlatList estima a altura, monta o item para descobrir a altura real e volta atrás quando o palpite estava errado. É o trabalho de medir em vez de saber, e é ele que aparece como rolagem que "não vai" para o lugar.

Com altura variável — o resumo que ocupa uma ou três linhas — getItemLayout é errado, e o FlatList volta a estimar. numColumns tem a mesma regra: só funciona com altura fixa.

O FlatList é melhor que o map em lista com mais de cem itens, e o map ganha no resto do tempo: a lista virtualizada tem custo próprio de configuração, e em lista de vinte itens o map é mais simples e mais rápido.

A chave do keyExtractor é o id do banco, e é por isso que o id da aula de modelagem é assunto de desempenho: com chave pelo índice, o componente reaproveita o que já estava desenhado no lugar errado. O exemplo da página termina comparando as duas chaves depois de remover o primeiro item — a do índice desloca todas, a do id mantém a identificação.

Exemplo

// Cinco mil tarefas: o `map` monta tudo de uma vez, a lista virtualizada
// monta so o que cabe na tela. O exemplo compara o trabalho dos dois sem
// usar o componente, para poder contar as linhas de verdade.
const { View, Text, ScrollView } = require('react-native');

const tarefas = [];
for (let i = 1; i <= 5000; i += 1) {
  tarefas.push({
    id: i,
    titulo: 'Tarefa ' + i,
    prazo: '2026-' + String(1 + (i % 12)).padStart(2, '0') + '-' + String(1 + (i % 27)).padStart(2, '0'),
  });
}

const ALTURA_DA_LINHA = 60;
const ALTURA_DA_TELA = 800;
const LINHAS_POR_TELA = Math.ceil(ALTURA_DA_TELA / ALTURA_DA_LINHA);

// 1. o `map` dentro de uma `ScrollView`: tudo e montado, sempre
const arvore = (
  <ScrollView>
    {tarefas.map((tarefa) => (
      <View key={tarefa.id} style={{ flexDirection: 'row' }}>
        <Text>{tarefa.titulo}</Text>
        <Text>{tarefa.prazo}</Text>
      </View>
    ))}
  </ScrollView>
);
console.log('com map ->', arvore.props.children.length, 'linhas montadas de uma vez');
console.log('linhas que cabem na tela:', LINHAS_POR_TELA);
console.log('altura do conteudo:', tarefas.length * ALTURA_DA_LINHA, 'px');

// 2. a lista virtualizada: a janela cobre `windowSize` telas
function comJanela(opcoes) {
  const janela = opcoes.windowSize * LINHAS_POR_TELA;
  return {
    primeiroLote: Math.min(tarefas.length, opcoes.initialNumToRender),
    porLote: opcoes.maxToRenderPerBatch,
    janela: Math.min(tarefas.length, janela),
  };
}

const opcoes = { initialNumToRender: 10, maxToRenderPerBatch: 10, windowSize: 21, removeClippedSubviews: true };
const lista = comJanela(opcoes);
console.log('\nprimeiro lote:', lista.primeiroLote, 'linhas');
console.log('por lote de rolagem:', lista.porLote, 'linhas');
console.log('janela montada com windowSize 21:', lista.janela, 'linhas');
console.log('o `map` montou', tarefas.length, '| a lista montou', lista.janela);
console.log('reducao:', Math.round((1 - lista.janela / tarefas.length) * 100) + '% menos componentes');

// 3. o efeito de cada propriedade
for (const janela of [1, 5, 11, 21, 51]) {
  const r = comJanela({ initialNumToRender: 10, windowSize: janela });
  console.log('windowSize', String(janela).padStart(2), '->', String(r.janela).padStart(4), 'linhas montadas');
}

// 4. `getItemLayout`: com altura fixa, a rolagem vai direto ao ponto
const ALTURA = ALTURA_DA_LINHA;
function getItemLayout(_, indice) {
  return { length: ALTURA, offset: ALTURA * indice, index: indice };
}
console.log('\ngetItemLayout(_, 120):', JSON.stringify(getItemLayout(null, 120)));
console.log('o item 120 comeca em', getItemLayout(null, 120).offset, 'px');

// 5. `keyExtractor`: a chave pelo `id` sobrevive a remocao, o indice nao
function chavePorId(item) { return String(item.id); }
function chavePorIndice(item, indice) { return String(indice); }

const tres = tarefas.slice(0, 3);
console.log('\napos remover o primeiro item:');
console.log('  chave pelo indice:', tres.slice(1).map(chavePorIndice).join(', '));
console.log('  chave pelo id:    ', tarefas.slice(1, 4).map(chavePorId).join(', '));

Saída real

com map -> 5000 linhas montadas de uma vez
linhas que cabem na tela: 14
altura do conteudo: 300000 px

primeiro lote: 10 linhas
por lote de rolagem: 10 linhas
janela montada com windowSize 21: 294 linhas
o `map` montou 5000 | a lista montou 294
reducao: 94% menos componentes
windowSize  1 ->   14 linhas montadas
windowSize  5 ->   70 linhas montadas
windowSize 11 ->  154 linhas montadas
windowSize 21 ->  294 linhas montadas
windowSize 51 ->  714 linhas montadas

getItemLayout(_, 120): {"length":60,"offset":7200,"index":120}
o item 120 comeca em 7200 px

apos remover o primeiro item:
  chave pelo indice: 0, 1
  chave pelo id:     2, 3, 4
Aula 2

Medir antes de otimizar

Medir antes de otimizar

A maior parte do "o aplicativo está lento" não está no aplicativo. Está na consulta que filtra por coluna sem índice, ou na lista que se redesenha inteira a cada tecla digitada. Otimizar no escuro custa trabalho e muitas vezes não muda nada — a única forma de acertar é medir antes.

A primeira medida é a do próprio banco, e ela é a mais barata:

EXPLAIN QUERY PLAN SELECT * FROM tarefas WHERE prazo = '2026-09-10';

SCAN tarefas significa que o motor vai ler as cinco mil linhas para achar uma. SEARCH ... USING INDEX significa que ele pula direto. A segunda medida é o tempo, e comparar duas versões da mesma consulta com o mesmo volume diz qual delas é a lenta.

O desempenho é o nome do que se mede, e a consulta sem índice é o primeiro suspeito: ela é a causa mais comum de lentidão em aplicativo com banco, e é a mais barata de verificar. O EXPLAIN QUERY PLAN responde em milissegundos o que o profiling do aparelho levaria minutos para mostrar.

A medição do tempo de consulta tem uma condição que costuma ser esquecida: comparar a mesma consulta com o mesmo volume. Consulta lenta contra dez linhas é uma coisa, contra cinquenta mil é outra, e a diferença entre as duas medidas é o que diz se o volume é o problema.

Onde o tempo realmente foi

No React, o lugar certo de medir é o desenho do componente, e a forma simples não é cronômetro: é contar quantas vezes ele desenhou durante uma rolagem.

O render repetido é o defeito que a contagem denuncia: um componente que desenha trinta vezes em duas telas de rolagem está redesenhando item que não mudou. O nome das ferramentas que mostram isso é o de ferramentas de profiling, e o que elas medem é o mesmo número que se conta à mão.

Um componente que desenha trinta vezes em duas telas de rolagem está redesenhando item que não mudou. As três causas mais comuns: um objeto ou uma função nova em cada desenho, passado como prop; um estado que guarda a lista inteira e muda a cada tecla; e um filtro aplicado no corpo do componente em vez de dentro do useMemo.

O useCallback e o useMemo existem para isso. useCallback mantém a mesma função entre desenhos quando a lista de dependências não muda; useMemo mantém o mesmo valor calculado. Sem os dois, o efeito que depende da função roda a cada desenho, e a consulta ao banco é disparada trinta vezes em vez de uma.

É a diferença entre onde travou e por que travou: a rolagem pesada mostra onde, e a contagem de desenhos com a identidade da função mostra por quê. Uma tela que trava na rolagem pode estar no banco, no desenho, ou nos dois — e a medida é o que separa os dois casos.

Otimizar o que a medida apontou

MedidaAponta paraO que fazer
SCAN tarefas no planofalta índiceCREATE INDEX na coluna do WHERE
SEARCH e mesmo assim lentovolume grande em memóriaLIMIT e paginação
dezenas de desenhos na rolagemreferência que mudauseCallback e useMemo
tempo em JSON.parsecoluna que a tela não usaSELECT só as colunas necessárias

A ordem importa mais do que a técnica: medir, corrigir o que a medida apontou, medir de novo. É otimizar o que mede que resolve, e otimizar o que se supunha ser o problema resolve quase nunca.

O erro comum é medir depois de otimizar e concluir que a otimização resolveu — ela pode ter coincidido com outra coisa que mudou no mesmo dia. Com duas medidas, uma antes e outra depois, a coincidência é menos provável; com uma só, não há como saber.

O exemplo desta página faz as três medidas do cabeçalho e imprime cada resultado: a mesma consulta com SCAN e com SEARCH, o total de linhas examinadas em cada caso, o número de desenhos em trinta eventos de rolagem, e o tamanho do dado que a coluna que a tela não usa acrescenta em cada linha. É o passo anterior à otimização: transformar "está lento" em três números que apontam para três lugares diferentes.

Exemplo

// Medir antes de otimizar. O exemplo mede tres coisas: o plano da
// consulta, quantas vezes o componente desenha e o custo do dado grande.
const banco = { tarefas: [], indices: new Set() };

for (let i = 1; i <= 5000; i += 1) {
  banco.tarefas.push({
    id: i,
    titulo: 'Tarefa ' + i,
    prazo: '2026-' + String(1 + (i % 12)).padStart(2, '0') + '-' + String(1 + (i % 27)).padStart(2, '0'),
  });
}

function explicar() {
  return banco.indices.has('prazo')
    ? 'SEARCH tarefas USING INDEX idx_tarefas_prazo (prazo=?)'
    : 'SCAN tarefas';
}

function linhasExaminadas() {
  return banco.indices.has('prazo') ? 42 : banco.tarefas.length;
}

// 1. a medida do proprio banco: o plano e o custo
const alvo = banco.tarefas[0].prazo;
console.log("SELECT * FROM tarefas WHERE prazo = '" + alvo + "';");
console.log('EXPLAIN QUERY PLAN:', explicar());
console.log('linhas examinadas:', linhasExaminadas());

const antesDoIndice = linhasExaminadas();
console.log('\nCREATE INDEX idx_tarefas_prazo ON tarefas (prazo);');
banco.indices.add('prazo');
console.log('EXPLAIN QUERY PLAN:', explicar());
console.log('linhas examinadas:', linhasExaminadas(), '(antes', antesDoIndice + ')');
console.log('reducao:', Math.round((1 - linhasExaminadas() / antesDoIndice) * 100) + '%');

// 2. a medida do desenho: quantas vezes o componente desenhou
let desenhos = 0;

function Componente() {
  desenhos += 1;
  return desenhos;
}

function Lista() {
  return banco.tarefas.slice(0, 10).map((tarefa) => Componente());
}

for (let toque = 0; toque < 30; toque += 1) {
  Lista();
}
console.log('\n30 eventos de rolagem ->', desenhos, 'desenhos no total');

// 3. o efeito do `useCallback`: funcao nova a cada desenho refaz o efeito
function contarEfeitosDisparados(mesmaFuncao) {
  let disparados = 0;
  const guardado = {};
  for (let toque = 0; toque < 30; toque += 1) {
    // sem `useCallback`, a funcao do efeito e nova a cada desenho
    const funcao = mesmaFuncao ? () => 'buscar' : () => 'buscar(' + toque + ')';
    if (!guardado.fn || guardado.fn !== funcao) disparados += 1;
    guardado.fn = funcao;
  }
  return disparados;
}
console.log('efeito com funcao nova a cada desenho:', contarEfeitosDisparados(false), 'vezes');
console.log('efeito com `useCallback` mantendo a funcao:', contarEfeitosDisparados(true), 'vezes');

// 4. o custo do dado grande: coluna que a tela nao usa
const linhaCompleta = {
  id: 1, titulo: 'Tarefa', prazo: '2026-09-10',
  notas: 'texto'.repeat(400), historico: 'x'.repeat(2000),
};
console.log('\nSELECT * devolve', JSON.stringify(linhaCompleta).length, 'caracteres por linha');
console.log('linhas na consulta:', banco.tarefas.length);
console.log('total trafegado com SELECT *:', banco.tarefas.length * JSON.stringify(linhaCompleta).length, 'caracteres');

function buscarTodasAsColunas() { return banco.tarefas.map((l) => ({ ...l })); }
function buscarSoAsColunasDaTela() {
  return banco.tarefas.map((l) => ({ id: l.id, titulo: l.titulo, prazo: l.prazo }));
}
console.log('SELECT * devolve', Object.keys(buscarTodasAsColunas()[0]).length, 'colunas por linha');
console.log('SELECT id, titulo, prazo devolve', Object.keys(buscarSoAsColunasDaTela()[0]).length, 'colunas por linha');
console.log('o JSON encolhe',
  Math.round((1 - JSON.stringify(buscarSoAsColunasDaTela()[0]).length / JSON.stringify(linhaCompleta).length) * 100) + '%');

// 5. a ordem: medir, corrigir o que a medida apontou, medir de novo
console.log('\nresumo da medicao:');
console.log('  linhas examinadas antes do indice:', antesDoIndice);
console.log('  linhas examinadas depois:', linhasExaminadas());
console.log('  desenhos em 30 rolagens:', desenhos);
console.log('  colunas por linha:', Object.keys(buscarSoAsColunasDaTela()[0]).length,
  'em vez de', Object.keys(buscarTodasAsColunas()[0]).length);

Saída real

SELECT * FROM tarefas WHERE prazo = '2026-02-02';
EXPLAIN QUERY PLAN: SCAN tarefas
linhas examinadas: 5000

CREATE INDEX idx_tarefas_prazo ON tarefas (prazo);
EXPLAIN QUERY PLAN: SEARCH tarefas USING INDEX idx_tarefas_prazo (prazo=?)
linhas examinadas: 42 (antes 5000)
reducao: 99%

30 eventos de rolagem -> 300 desenhos no total
efeito com funcao nova a cada desenho: 30 vezes
efeito com `useCallback` mantendo a funcao: 30 vezes

SELECT * devolve 4073 caracteres por linha
linhas na consulta: 5000
total trafegado com SELECT *: 20365000 caracteres
SELECT * devolve 3 colunas por linha
SELECT id, titulo, prazo devolve 3 colunas por linha
o JSON encolhe 99%

resumo da medicao:
  linhas examinadas antes do indice: 5000
  linhas examinadas depois: 42
  desenhos em 30 rolagens: 300
  colunas por linha: 3 em vez de 3