Dia 14 — Desempenho com muitos dados
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
| Propriedade | O que faz | Valor típico |
|---|---|---|
initialNumToRender | quantas linhas desenha na primeira vez | 10 |
maxToRenderPerBatch | quantas desenha a cada lote de rolagem | 10 |
windowSize | quantas telas vale a janela montada | 21 |
removeClippedSubviews | descarta a view fora da tela | true |
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
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
| Medida | Aponta para | O que fazer |
|---|---|---|
SCAN tarefas no plano | falta índice | CREATE INDEX na coluna do WHERE |
SEARCH e mesmo assim lento | volume grande em memória | LIMIT e paginação |
| dezenas de desenhos na rolagem | referência que muda | useCallback e useMemo |
tempo em JSON.parse | coluna que a tela não usa | SELECT 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