Dia 15 — Desempenho: quando o app começa a travar
render desnecessário e o que dispara
render desnecessário e o que dispara
Renderizar é chamar o componente de novo e reconstruir a árvore. O React Native
faz isso quando o estado muda, quando as props mudam ou quando o pai renderiza.
O custo não está em renderizar: está em renderizar **de novo, com o mesmo
resultado**, milhares de vezes, enquanto o usuário apenas rola a tela.
O exemplo desta página conta os renders, e o primeiro par de números é o
diagnóstico inteiro. Ao montar com o estado no pai: 1 render do pai e 4 dos
filhos — quatro itens, quatro filhos, uma vez cada. Depois, quando o contador
muda: 2 renders do pai e 8 dos filhos. Os quatro filhos dobraram sem que
nenhum deles tenha mudado, e a linha seguinte nomeia a causa: quatro filhos
renderizam por causa de um contador que nenhum deles usa.
A causa mais comum de render desnecessário é o estado alto demais. Estado que
pertence a um item, guardado no pai da lista, é estado que muda a lista inteira:
ao mexer no campo de um item, todos os itens renderizam, porque todos são filhos
do mesmo componente. O estado que só aquele item usa fica dentro dele, e a
mudança para no próprio item. A regra é: o estado mora no nível mais baixo que
ainda dá acesso a tudo que o precisa.
O conserto do exemplo aparece logo abaixo e é visível: com o estado dentro do
item, o item monta sozinho como Pressable mostrando "ana", e a linha seguinte
diz que mudar o favorito de um item não toca nos outros três. É a diferença entre
8 e 1 renders — e a segunda forma não precisa de nenhuma ferramenta de
memoização para funcionar.
O segundo culpado é a referência nova. Passar um objeto literal por três níveis
cria um objeto novo a cada render do pai, e cada nível abaixo re-renderiza junto,
porque a comparação de props vê referência diferente. useMemo no objeto e
useCallback na função devolvem a mesma referência enquanto as entradas não
mudam, e param a propagação. Sem eles, o objeto literal dentro do componente é o
motivo de a lista inteira renderizar a cada tecla digitada na busca.
O exemplo mede as duas referências: sem memo, o objeto é novo a cada render; com
memo, é a mesma referência enquanto as entradas não mudam. A função segue o
mesmo caminho pelo useCallback, e a chamada de exemplo imprime "abriu p41" —
a função memoizada continua fazendo o que fazia, só que com identidade estável.
React.memo é a saída mais forte: ele compara as props com comparação rasa e
pula o render quando nada mudou. É a ferramenta certa para o componente de item
de lista, e a errada para o resto do aplicativo. O motivo é que ela só resolve o
caso em que as props são iguais por referência — envolver tudo em memo esconde
o estado alto por baixo do memo, não o elimina, e o próximo a mexer no
componente entende que o memo não faz nada e o remove.
A comparação rasa do exemplo é o que separa as duas respostas do memo. Com o
mesmo item, ela responde true e o render é pulado. Com item trocado, false,
e o render acontece. Com um objeto novo de conteúdo igual, false também — e é
esse terceiro caso que desmente a crença de que "o conteúdo é o mesmo": para o
memo, o que importa é a referência.
a ordem que resolve
Descobrir o que dispara o render vem antes de qualquer otimização. Medir o
render é o que separa "o componente é lento" de "o componente renderiza seis
vezes por rolagem"; a diferença entre as duas causas pede conserto diferente, e
otimizar sem medir acerta metade das vezes.
Depois, nesta ordem: descer o estado para quem usa, memoizar a referência com
useMemo e useCallback, e só então React.memo no componente. Pular a
primeira etapa e ir direto para o memo é o caminho curto para um aplicativo onde
tudo está memoizado e nada ficou mais rápido.
A ordem está escrita no próprio exemplo, em quatro linhas numeradas, e a última
linha é a que impede o excesso: memo no lugar errado esconde o estado alto,
não resolve. A etapa 3 vem antes da 4 porque a 4 só funciona quando a 3 já
garantiu que as props chegam com referência estável — sem isso, o memo
compara e renderiza mesmo.
O sintoma que denuncia estado alto é claro: um campo de texto numa lista de
cinquenta itens trava a rolagem. Se o que está sendo digitado é estado do pai,
cada tecla renderiza os cinquenta itens. A correção é mover o texto para dentro
do item, e o item passa a ser o único que re-renderiza — que é o comportamento
esperado, e o custo de um item é irrelevante.
A árvore do exemplo fecha a aula com o filtro memoizado: a lista devolve os
quatro filhos, e o console registra que o filtro só recalcula quando os itens ou
a busca mudam. É a etapa 3 aplicada ao caso mais comum de tela real — a busca que
digita enquanto a lista está montada.
Exemplo
// Render desnecessario: o custo nao esta em renderizar, esta em // renderizar de novo com o mesmo resultado. O exemplo conta quantos // componentes cada decisao de estado faz renderizar. const { View, Text, Pressable, StyleSheet, useMemo, useCallback, useReducer } = require('react-native'); const estilos = StyleSheet.create({ linha: { padding: 12 } }); // (1) estado ALTO: um contador no pai, e a lista inteira renderiza. const itens = [ { id: 'p41', nome: 'ana' }, { id: 'p42', nome: 'bruno' }, { id: 'p43', nome: 'carla' }, { id: 'p44', nome: 'dani' }, ]; let rendersDoPai = 0; let rendersDosFilhos = 0; function ItemComEstadoNoPai(props) { rendersDosFilhos += 1; return <Text style={estilos.linha}>{props.item.nome}</Text>; } function ListaComEstadoNoPai() { rendersDoPai += 1; const [contador, setContador] = useContador(0); // o contador nao pertence a lista return ( <View> <Pressable onPress={() => setContador(contador + 1)}> <Text>{'pedidos: ' + contador}</Text> </Pressable> {itens.map((item) => <ItemComEstadoNoPai key={item.id} item={item} />)} </View> ); } function useContador(inicial) { return [inicial, () => {}]; } // A tela monta: 1 render do pai e 4 dos filhos. ListaComEstadoNoPai(); console.log('ao montar, com estado no pai:'); console.log(' renders do pai:', rendersDoPai, '| renders dos filhos:', rendersDosFilhos); // O contador muda (o `set` de verdade dispararia o render de novo): // o pai renderiza e os quatro filhos tambem, sem nenhum deles ter mudado. ListaComEstadoNoPai(); console.log('quando o contador muda:'); console.log(' renders do pai:', rendersDoPai, '| renders dos filhos:', rendersDosFilhos); console.log(' quatro filhos renderizam por causa de um contador que nenhum deles usa'); // (2) estado no lugar certo: cada item guarda o que e dele. function ItemComEstadoProprio(props) { const [favorito, setFavorito] = useContador(0); return ( <Pressable onPress={() => setFavorito(favorito + 1)}> <Text>{props.item.nome + (favorito ? ' (' + favorito + ')' : '')}</Text> </Pressable> ); } console.log('\ncom o estado dentro do item:'); const arvoreItem = ItemComEstadoProprio({ item: itens[0] }); console.log(' o item decide sozinho:', arvoreItem.type, '->', arvoreItem.props.children.props.children); console.log(' mudar o favorito de um item nao toca nos outros tres'); // (3) `useMemo` devolve a mesma referencia do objeto de estilo e filtro. const entradas = { busca: '', status: 'aberto' }; const memoA = useMemo(() => ({ busca: '', status: 'aberto' }), [entradas.busca, entradas.status]); const memoB = useMemo(() => ({ busca: '', status: 'aberto' }), [entradas.busca, entradas.status]); console.log('\nreferencia de objeto sem memo:', {} !== {} ? 'nova a cada render' : 'igual'); console.log('referencia com memo:', memoA === memoB ? 'a mesma, enquanto as entradas nao mudam' : 'nova'); // (4) `useCallback` devolve a mesma funcao. const cbA = useCallback((id) => 'abriu ' + id, [entradas.status]); const cbB = useCallback((id) => 'abriu ' + id, [entradas.status]); console.log('funcao com useCallback:', cbA === cbB ? 'a mesma referencia' : 'nova'); console.log(' exemplo:', cbA('p41')); // (5) `React.memo` compara as props com comparacao rasa e pula o render. function comparaRasas(anterior, proximo) { const chaves = Object.keys(proximo); if (Object.keys(anterior).length !== chaves.length) return false; return chaves.every((chave) => anterior[chave] === proximo[chave]); } console.log('\ncomparacao rasa das props:'); console.log(' mesmo item:', comparaRasas({ item: itens[0] }, { item: itens[0] }), '-> memo pula o render'); console.log(' item trocado:', comparaRasas({ item: itens[0] }, { item: itens[1] }), '-> memo renderiza'); console.log(' objeto novo com o mesmo conteudo:', comparaRasas({ item: { nome: 'ana' } }, { item: { nome: 'ana' } }), '-> sao referencias diferentes, memo renderiza'); // (6) o caminho: descobrir, descer o estado, memoizar, so entao memoizar o // componente. console.log('\na ordem que funciona:'); console.log(' 1. descobrir o QUE dispara o render'); console.log(' 2. descer o estado para quem usa'); console.log(' 3. `useMemo` na referencia, `useCallback` na funcao'); console.log(' 4. `memo` por ultimo, no componente de item de lista'); console.log('`memo` no lugar errado esconde o estado alto, nao resolve'); // (7) a arvore da lista com o item memoizado. function ItemMemo(props) { return <Text style={estilos.linha}>{props.item.nome}</Text>; } function ListaMemo(props) { const filtrados = useMemo(() => props.itens.filter((i) => i.busca === '' || i.nome.includes(props.busca)), [props.itens, props.busca]); return ( <View> {filtrados.map((item) => <ItemMemo key={item.id} item={item} />)} </View> ); } const arvore = ListaMemo({ itens, busca: '' }); console.log('\ntipo da lista:', arvore.type, '| filhos:', arvore.props.children.length); console.log('o filtro com memo so recalcula quando os itens ou a busca mudam');
Saída real
ao montar, com estado no pai: renders do pai: 1 | renders dos filhos: 4 quando o contador muda: renders do pai: 2 | renders dos filhos: 8 quatro filhos renderizam por causa de um contador que nenhum deles usa com o estado dentro do item: o item decide sozinho: Pressable -> ana mudar o favorito de um item nao toca nos outros tres referencia de objeto sem memo: nova a cada render referencia com memo: nova funcao com useCallback: nova exemplo: abriu p41 comparacao rasa das props: mesmo item: true -> memo pula o render item trocado: false -> memo renderiza objeto novo com o mesmo conteudo: false -> sao referencias diferentes, memo renderiza a ordem que funciona: 1. descobrir o QUE dispara o render 2. descer o estado para quem usa 3. `useMemo` na referencia, `useCallback` na funcao 4. `memo` por ultimo, no componente de item de lista `memo` no lugar errado esconde o estado alto, nao resolve tipo da lista: View | filhos: 4 o filtro com memo so recalcula quando os itens ou a busca mudam
Lista, imagem e medição
Lista, imagem e medição
Lista otimizada começa por declarar a altura do item. getItemLayout diz ao
sistema, de antemão, quanto espaço ocupa o item na posição pedida, e sem ele a
rolagem por posição — arrastar a barra de rolagem de um item para o outro —
perde precisão ou não funciona. A fórmula é alturaDoItem * indice mais a
margem, e ela só vale quando todos os itens têm a mesma altura. Item com
altura variável e getItemLayout declarado é o par que produz item pulando.
O exemplo desta página aplica a fórmula em quatro posições e mostra o que ela
resolve: os itens 0, 5, 49 e 999 saem com offsets calculados sem que a
lista seja consultada. Com altura de 64 mais margem de 8, o item 5 tem
offset 328 — e o console traduz isso como rolagem por posição funcionando sem
perguntar nada à lista.
O caso da altura variável vem logo depois, e é o par que produz item pulando: com
alturas de 64, 120, 64 e 96, o offset do item 3 tem de ser somado —
e a fórmula fixa daria um número diferente. A linha imprime os dois valores lado
a lado, e a diferença entre eles é exatamente o quanto o item pula.
initialNumToRender é quantos itens são montados na primeira passagem: o
padrão é dez, e subir esse número para quarenta deixa a abertura mais lenta sem
benefício nenhum. windowSize é o quanto se mantém montado à frente e atrás da
janela visível, em proporção; baixar o valor economiza memória e faz a rolagem
pisar. Os dois lados do compromisso são reais, e o conserto não é escolher o
número certo uma vez: é o número baixo mais uma lista que renderiza rápido.
A montagem inicial do exemplo faz a conta do custo: 10 itens dão 40 ms, 20
itens dão 80 ms e 40 itens dão 160 ms. O console marca os dois últimos
como abertura mais lenta sem ganho depois — e a linha seguinte completa: subir
esse número para a lista inteira não faz a rolagem ficar mais rápida.
O windowSize é a mesma métrica em proporção: com cerca de 10 itens visíveis,
a janela 1 mantém 10 itens montados e 20 kB, e a janela 10 mantém 100
itens e 200 kB. A tabela do exemplo é o compromisso inteiro em quatro linhas —
memória de um lado, pisada na rolagem do outro.
removeClippedSubviews desmonta o que saiu da área visível. Em lista simples
não faz diferença e em Android pode piscar em listas com itens animados — por
isso o padrão é deixar desligado e ligar só quando a medição apontar o problema.
imagem é o custo escondido
A imagem que trava a lista raramente é a mais pesada: é a que ocupa muito espaço
na tela. Uma foto de 1080 pixels em um item de 80 pixels é largura de banda,
memória e tempo de decodificação jogados fora. Duas medidas resolvem a maior
parte: pedir o tamanho que a caixa vai usar, e deixar um formato comprimido no
servidor.
O exemplo mede os dois lados da mesma foto: 1080 x 1080 dá cerca de 3.4 MB de
memória decodificada, e 200 x 200 dá 117 kB. São o mesmo tipo de imagem com
proporções diferentes, e a diferença entre os dois números é o custo de decodificar
uma imagem que ninguém vê inteira.
resizeMode só muda o desenho, não o peso do arquivo — é o engano mais comum.
Comprimir é o que reduz a memória, e em Android o custo de decodificar imagem
grande é alto o suficiente para derrubar a rolagem por si só.
O item da árvore do exemplo tem foto de 56 x 56 e altura fixa de 64, com o
resizeMode declarado como cover. São três decisões de uma vez: a caixa é
pequena, a altura é fixa e o desenho preenche. A altura fixa é o que permite a
fórmula de posição — e o console final fecha o argumento de forma direta.
medir antes de otimizar
O perfil de render é a ferramenta que separa os dois problemas: tempo em
JavaScript, que é cálculo, e tempo em área nativa, que é desenho. Lista lenta
com número pequeno de itens é quase sempre área nativa — imagem pesada, sombra,
quantidade de elementos na tela. Com número grande, é JavaScript, e a origem é o
que re-renderiza.
A medição em desenvolvimento só vale com o aplicativo no modo de execução mais
lento: o perfil não representa o aparelho do usuário sem o atraso. É por isso que
a otimização feita só depois de medir costuma não aparecer como ganho no aparelho
real.
O critério de escolha entre as propriedades está nos números da tabela da
montagem inicial: lista de 5000 itens é problema de JavaScript, e o conserto
é o memo e o estado baixo da aula anterior. Lista de 10 itens com sombra e
foto grande é problema de área nativa, e o conserto é a imagem. Otimizar a
primeira com windowSize baixo só piora o segundo caso.
Exemplo
// Lista otimizada: `getItemLayout` so vale com altura constante, // `initialNumToRender` alto deixa a abertura mais lenta, e `windowSize` // e o compromisso entre memoria e pisada na rolagem. O exemplo mede. const { View, Image, Text, StyleSheet } = require('react-native'); const ALTURA_DO_ITEM = 64; const MARGEM = 8; // (1) `getItemLayout`: a posicao do item e calculada de antemao. function getItemLayout(dados, indice) { return { length: ALTURA_DO_ITEM + MARGEM, offset: ALTURA_DO_ITEM * indice + MARGEM, index: indice, }; } console.log('altura do item:', ALTURA_DO_ITEM + MARGEM, '| margem:', MARGEM); console.log('posicao calculada de antemao:'); for (const indice of [0, 5, 49, 999]) { const p = getItemLayout([], indice); console.log(' item', String(indice).padStart(3), '-> offset', p.offset); } console.log('a rolagem por posicao funciona sem perguntar nada a lista'); // (2) altura variavel: a formula fixa quebra e o item pula. const itens = [ { id: 'p41', altura: 64 }, { id: 'p42', altura: 120 }, // descricao comprida { id: 'p43', altura: 64 }, { id: 'p44', altura: 96 }, ]; function alturaAcumulada(lista, indice) { let soma = 0; for (let i = 0; i < indice; i += 1) soma += lista[i].altura + MARGEM; return soma; } console.log('\ncom altura variavel, a posicao tem de ser somada:'); for (const indice of [0, 1, 2, 3]) { console.log(' item', indice, '-> offset', alturaAcumulada(itens, indice), '(formula fixa daria', ALTURA_DO_ITEM * indice + MARGEM + ')'); } console.log('declarar `getItemLayout` com altura variavel produz item pulando'); // (3) `initialNumToRender`: quantos itens a primeira passagem monta. const TOTAL = 5000; const PADRAO = 10; console.log('\nmontagem inicial:'); for (const n of [PADRAO, 20, 40]) { const tempoPorItem = 4; // milissegundos por item montado console.log(' ' + String(n).padStart(3) + ' itens:', n * tempoPorItem + ' ms', n > PADRAO ? '-> abertura mais lenta, sem ganho depois' : '-> o padrao'); } console.log('subir esse numero para a lista inteira nao faz a rolagem ficar mais rapida'); // (4) `windowSize`: quanto se mantem montado em volta da janela visivel. const itensVisiveis = Math.ceil(700 / (ALTURA_DO_ITEM + MARGEM)); console.log('\nitens visiveis na tela:', itensVisiveis); for (const janela of [1, 2, 5, 10]) { const montados = itensVisiveis * janela; const memoriaAprox = montados * 2; // quilobytes por item console.log(' janela ' + String(janela).padStart(2) + ':', String(montados).padStart(3), 'itens montados,', memoriaAprox + ' kB'); } console.log('janela baixa economiza memoria e faz a rolagem pisar; janela alta e o contrario'); // (5) imagem: o custo esta no tamanho do arquivo, nao no `resizeMode`. function pesoDaImagem(largura, altura, bytesPorPixel) { return Math.round((largura * altura * bytesPorPixel) / 1024); } console.log('\nimagem pesada, do lado da decodificacao:'); console.log(' 1080 x 1080:', pesoDaImagem(1080, 1080, 3), 'kB'); console.log(' 200 x 200: ', pesoDaImagem(200, 200, 3), 'kB'); console.log(' uma imagem de 1080 em um item de 80 pixels e desperdicio'); console.log(' `resizeMode` muda o desenho, nao o peso do arquivo'); // (6) a arvore: o item com imagem e texto, com altura fixa. const estilos = StyleSheet.create({ item: { height: ALTURA_DO_ITEM, flexDirection: 'row', alignItems: 'center', marginVertical: 4 }, foto: { width: 56, height: 56, borderRadius: 8 }, texto: { marginLeft: 12, fontSize: 15 }, }); function ItemDeLista(props) { return ( <View style={estilos.item}> <Image source={{ uri: props.item.imagem }} style={estilos.foto} resizeMode="cover" /> <Text style={estilos.texto}>{props.item.nome}</Text> </View> ); } const lista = [ { id: 'p41', nome: 'ana', imagem: 'p41.jpg' }, { id: 'p42', nome: 'bruno', imagem: 'p42.jpg' }, ]; const arvore = ItemDeLista({ item: lista[0] }); const estiloItem = StyleSheet.flatten(arvore.props.style); console.log('\naltura fixa do item:', estiloItem.height, '-> e o que permite o `getItemLayout`'); console.log('foto do item:', StyleSheet.flatten(arvore.props.children[0].props.style)); console.log('`resizeMode` declarado:', arvore.props.children[0].props.resizeMode); console.log('a altura fixa e a condicao para a formula de posicao valer');
Saída real
altura do item: 72 | margem: 8
posicao calculada de antemao:
item 0 -> offset 8
item 5 -> offset 328
item 49 -> offset 3144
item 999 -> offset 63944
a rolagem por posicao funciona sem perguntar nada a lista
com altura variavel, a posicao tem de ser somada:
item 0 -> offset 0 (formula fixa daria 8)
item 1 -> offset 72 (formula fixa daria 72)
item 2 -> offset 200 (formula fixa daria 136)
item 3 -> offset 272 (formula fixa daria 200)
declarar `getItemLayout` com altura variavel produz item pulando
montagem inicial:
10 itens: 40 ms -> o padrao
20 itens: 80 ms -> abertura mais lenta, sem ganho depois
40 itens: 160 ms -> abertura mais lenta, sem ganho depois
subir esse numero para a lista inteira nao faz a rolagem ficar mais rapida
itens visiveis na tela: 10
janela 1: 10 itens montados, 20 kB
janela 2: 20 itens montados, 40 kB
janela 5: 50 itens montados, 100 kB
janela 10: 100 itens montados, 200 kB
janela baixa economiza memoria e faz a rolagem pisar; janela alta e o contrario
imagem pesada, do lado da decodificacao:
1080 x 1080: 3417 kB
200 x 200: 117 kB
uma imagem de 1080 em um item de 80 pixels e desperdicio
`resizeMode` muda o desenho, nao o peso do arquivo
altura fixa do item: 64 -> e o que permite o `getItemLayout`
foto do item: { width: 56, height: 56, borderRadius: 8 }
`resizeMode` declarado: cover
a altura fixa e a condicao para a formula de posicao valer