Guerra no freio. Otimizando o número de renderizações de componentes no React Native

Olá Habr! Meu nome é Kamo Spertsyan, estou envolvido no desenvolvimento do React Native no Profi.ru. Se você decidir usar a tecnologia React Native para fornecer rapidamente recursos do produto e se concentrar na velocidade de desenvolvimento, é provável que encontre problemas de desempenho. Pelo menos foi o que aconteceu conosco. Após seis meses de desenvolvimento ativo, o desempenho de nosso aplicativo caiu abaixo de um nível crítico - tudo ficou muito lento. Portanto, adotamos a otimização - removemos todos os "freios" durante a inicialização, transições entre telas, renderizando telas, reações às ações do usuário. Como resultado, em três meses eles trouxeram a experiência do usuário para o nível nativo. Neste artigo, quero falar sobre como otimizamos o aplicativo no React Native e resolvemos o problema de renderizações de vários componentes.



Reuni recomendações que ajudarão a minimizar o número de redesenhos inúteis de componentes. Para maior clareza, nos exemplos eu comparo as implementações "ruim" e "boa". O artigo será útil para aqueles que já enfrentam um desempenho ruim do aplicativo e para aqueles que não desejam permitir isso no futuro.

Usamos React Native emparelhado com Redux. Algumas dicas estão relacionadas a esta biblioteca. Também no exemplo, eu uso a biblioteca Redux-thunk - para simular o trabalho com a rede.

Quando pensar em desempenho?


De fato, vale lembrar desde o início do trabalho no aplicativo. Mas se o seu aplicativo já estiver diminuindo a velocidade - não se desespere, tudo pode ser corrigido.

Todo mundo sabe, mas só para garantir, vou mencionar: é melhor verificar o desempenho em dispositivos fracos. Se você estiver desenvolvendo em dispositivos avançados, talvez não conheça os "freios" dos usuários finais. Decida por si mesmo os dispositivos pelos quais você será guiado. Meça o tempo ou o FPS nos gráficos de controle para comparar com os resultados após a otimização.

Reagir nativo imediatamente fornece a capacidade de medir aplicativos FPS através das Ferramentas do desenvolvedor → Mostrar monitor de desempenho. O valor de referência é 60 quadros por segundo. Quanto menor esse indicador, mais forte o aplicativo "fica mais lento" - não responde ou reage com um atraso às ações do usuário. Um dos principais efeitos no FPS é o número de renderizações, cuja “gravidade” depende da complexidade dos componentes.

Descrição do exemplo


Eu mostro todas as recomendações no exemplo de um aplicativo simples com uma lista de notícias. O aplicativo possui uma tela, localizada FlatListcom as notícias. Um item de notícia é um componente NewsItemque consiste em dois componentes menores - o título ( NewsItemTitle) e o corpo ( NewsItemBody). O exemplo inteiro pode ser visto aqui . Além disso, no texto há links para várias ramificações do repositório para exemplos específicos. O repositório é usado para a conveniência dos leitores que desejam explorar exemplos mais profundamente. O código no repositório e nos exemplos abaixo não afirma ser perfeito - é necessário apenas para fins de demonstração.

Abaixo, todos os componentes são mostrados esquematicamente com links e acessórios.


No método de renderização de cada componente, adicionei a saída ao console de informações exclusivas sobre ele:

SCREEN
ITEM_{no}
ITEM_TITLE_{no}
ITEM_BODY_{no}

onde {no}é o número de série do noticiário para distinguir entre diferentes renderizações de notícias e várias renderizações do mesmo.

Para testar em cada refreshlista de notícias, são adicionadas notícias adicionais ao seu início. Ao mesmo tempo, a seguinte mensagem é exibida no console:

--------------[ REFRESHING ]--------------

Esses registros ajudarão a entender se há um problema em algum componente específico e, posteriormente, a determinar se foi possível otimizá-lo.

Se implementado corretamente, nosso log após o lançamento e várias atualizações devem ficar assim:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
--------------[ REFRESHING ]--------------
SCREEN
ITEM_4
ITEM_TITLE_4
ITEM_BODY_4

No primeiro início, a tela em si e duas notícias iniciais são sorteadas. Ao atualizar o quadro, a tela é renderizada novamente, porque seus dados realmente mudaram. Mais notícias aparecem. Todas as notícias anteriores não são redesenhadas, pois não houve alterações nos dados.

Quando um componente é renderizado?


Em React e React Native, há duas condições para renderizar um componente:

  1. mudando seus adereços / estado,
  2. render do componente pai.

Uma função pode ser redefinida em um componente shouldComponentUpdate- ela recebe novos Props e State como uma entrada e informa se o componente deve ser renderizado. Freqüentemente, para evitar re-renderizações desnecessárias, basta uma comparação superficial dos objetos Props e State. Por exemplo, isso elimina renderizações desnecessárias quando o componente pai é alterado, se eles não afetam o componente filho. Para não escrever uma comparação de superfície manualmente a cada vez, você pode herdar um componente React.PureComponentque encapsula essa verificação.

Quando usamos a função link de conexão, a biblioteca Redux cria um novo componente que é "conectado" ao estado global. Alterações nesse estado acionam um métodomapStateToPropsque retorna novos adereços. Em seguida, é iniciada uma comparação de adereços antigos e novos, independentemente de o componente ter sido declarado PureComponentou não.

Considere essas nuances em nosso exemplo.

Vamos NewsItemdeixar o componente passar connect, NewsItemTitleherdar de React.Componente NewsItemBody- de React.PureComponent.

Código de exemplo completo

export class NewsItemTitle extends React.Component
export class NewsItemBody extends React.PureComponent

A seguir, como será o registro após uma atualização de quadro:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1

Você pode ver que os componentes de notícias e títulos são redesenhados. Vamos considerá-los por sua vez.

NewsItemdeclarado usando connect. Como acessório, esse componente recebe um identificador, pelo qual recebe posteriormente notícias em mapStateToProps:

const mapStateToProps = (state, ownProps) => ({
  item: state.newsMap[ownProps.itemKey],
});

Como ao atualizar o painel, todas as notícias são baixadas novamente, o objeto será itematualizado e depois se referirá a várias células de memória. Em outras palavras, eles serão objetos diferentes, mesmo que todos os campos contidos sejam os mesmos. Portanto, uma comparação do componente State'ov anterior e novo retorna false. O componente será renderizado novamente, apesar do fato de que os dados não foram alterados.

NewsItemTitleé herdado de React.Component, portanto, é renderizado novamente sempre que o componente pai é renderizado. Isso acontece independentemente dos valores dos adereços antigos e novos.

NewsItemBodyherdado de React.PureComponent, para comparar adereços antigos e novos. Nas notícias 1 e 2, seus valores são equivalentes; portanto, o componente é renderizado apenas para as notícias 3.

Para otimizar as renderizaçõesNewsItemTitleApenas declare como React.PureComponent. No caso de, você NewsItemdeve redefinir a função shouldComponentUpdate:

shouldComponentUpdate(nextProps) {
  return !shallowEqual(this.props.item, nextProps.item);
}

Código de exemplo completo

Aqui shallowEqualestá um recurso para comparação de superfície de objetos que o Redux fornece. Você pode escrever assim:

shouldComponentUpdate(nextProps) {
  return (
    this.props.item.title !== nextProps.item.title ||
    this.props.item.body !== nextProps.item.body
  );
}

Aqui está a aparência do nosso log depois disso:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3

Nota
shouldComponentUpdate NewsItem , NewsItemTitle . . NewsItemTitle - NewsItem, .

React.memo e componentes funcionais


Não é shouldComponentUpdatepossível substituir em um componente funcional. Mas isso não significa que, para otimizar um componente funcional, você tenha que reescrevê-lo em uma classe. Para esses casos, a função de memorização React.memo é fornecida . Ele aceita uma entrada de componente e uma função de comparação opcional areEqual. Quando chamado, ele areEqualadquire adereços antigos e novos e deve retornar o resultado da comparação. A diferença com o shouldComponentUpdateque areEqualdeve retornar truese os adereços forem iguais, e não vice-versa.

Por exemplo, a NewsItemTitlememorização pode ser assim:

areEqual(prevProps, nextProps) {
  return shallowEqual(prevProps, nextProps);
}
export OptimizedNewsItemTitle = React.memo(NewsItemTitle, areEqual)

Se você não passar areEqualem React.memo, em seguida, uma comparação superficial de adereços será feita, então o nosso exemplo pode ser simplificada:

export OptimizedNewsItemTitle = React.memo(NewsItemTitle)

Funções Lambda em adereços


Para processar eventos de componentes, as funções podem ser passadas para seus props. O exemplo mais impressionante é a implementação onPress. Frequentemente, funções lambda anônimas são usadas para isso. Digamos NewsItemBodyque queremos mostrar apenas a visualização e, se você clicar nela - o texto inteiro. Para fazer isso, ao renderizar NewsItem, NewsItemBodypassaremos o seguinte suporte:

<NewsItemBody
  ...
  onPress={() => this.props.expandBody()}
  ...
/>

Aqui está a aparência do log nesta implementação quando o método shouldComponentUpdateé NewsItemexcluído:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1

Os corpos de notícias 1 e 2 são renderizados, embora seus dados não tenham sido alterados, mas NewsItemBodysão PureComponent. Isso se deve ao fato de que para cada renderização, o NewsItemvalor de adereços onPressé criado novamente. Tecnicamente, onPresscom cada renderização, ele aponta para uma nova área na memória, portanto, uma comparação superficial de adereços em NewsItemBodyretornos falsos. O problema foi corrigido pela seguinte entrada:

<NewsItemBody
  ...
  onPress={this.props.expandBody}
  ...
/>

Registro:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1

Código de exemplo completo

Infelizmente, uma função anônima nunca pode ser reescrita como método ou campo de classe para esse registro. O caso mais comum é quando, dentro da função lambda, são utilizadas as variáveis ​​de escopo da função na qual ela é declarada.

Considere este caso em nosso exemplo. Para alternar da lista geral para a tela de uma notícia, adicionamos o processamento de clicar no corpo da notícia. O método renderItemcomponent FlatListterá a seguinte aparência:

const renderItem = ({item}) => (
  <NewsItem
    itemKey={item}
    onBodyPress={() => this.onItemBodyPress(item)}
  />
);

Uma função anônima onBodyPressnão pode ser declarada em uma classe, porque a variável itemnecessária para acessar uma notícia específica desaparecerá do escopo .

A solução mais simples para o problema é alterar a assinatura dos acessórios do onBodyPresscomponente NewsItempara que o parâmetro necessário seja passado para a função quando chamado. Nesse caso, esse é o identificador de notícias.

const renderItem = ({item}) => (
  <NewsItem
    itemKey={item}
    onBodyPress={item => this.onItemBodyPress(item)}
  />
);

Nesse caso, já podemos remover a função anônima no método da classe component.

const renderItem = ({item}) => (
  <NewsItem
    itemKey={item}
    onBodyPress={this.onItemBodyPress}
  />
);

No entanto, essa solução exigirá que alteremos o componente NewsItem.

class NewsItemComponent extends React.Component {
render() {
  ...
  return (
      ...
      <NewsItemBody
        ...
        onPress={() => this.props.onBodyPress(this.props.item)}
        ...
      />
      ...
  );
}

E, novamente, voltamos ao problema indicado - passamos uma nova função lambda para o componente filho para cada renderização do pai. Só agora descemos um nível. Registro:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1

Para se livrar desse problema na raiz, você pode usar o gancho useCallback . Permite memorizar uma chamada de função passando um argumento. Se o argumento da função não for alterado, o resultado da chamada useCallbackapontará para a mesma área de memória. No nosso exemplo, isso significa que, ao redesenhar as mesmas notícias, o onPresscomponente prop NewsItemBodynão será alterado. Os ganchos só podem ser usados ​​em componentes funcionais; portanto, a aparência final do componente NewsItemserá a seguinte:

function NewsItemComponent(props) {
  ...
  const {itemKey, onBodyPress} = props.item;
  const onPressBody = useCallback(() => onBodyPress(itemKey), [itemKey, onBodyPress]);
  return (
    <View>
      ...
      <NewsItemBody
        ...
        onPress={onPressBody}
        ...
      />
    </View>
  );
}

E o log:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1

Código de exemplo completo

Matrizes e objetos


Em JavaScript, funções são representadas como objetos, juntamente com matrizes. Portanto, o exemplo do bloco anterior é um caso especial de criação de um novo objeto em props. É bastante comum, então coloquei em um parágrafo separado.

Qualquer criação de novas funções, matrizes ou objetos em adereços leva a um re-renderizador de componentes. Considere esta regra no exemplo a seguir. Vamos passar em um NewsItemBodyestilo combinado de dois valores:

<NewsItemBody
  ...
  style={[styles.body, styles.item]}
  ...
/>

E, novamente, o log mostra os renderizadores de componentes extras:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1

Para resolver esse problema, você pode selecionar um estilo separado que irá combinar bodye item, ou, por exemplo, mover a declaração da matriz [styles.body, styles.item]para uma variável global.

Código de exemplo completo

Redutores de matriz


Considere outra fonte popular de "freios" associados ao uso FlatList. Um aplicativo clássico que contém uma longa lista de itens do servidor implementa a paginação. Ou seja, ele carrega um conjunto limitado de elementos na forma da primeira página, quando a lista de elementos atuais termina, carrega a página seguinte e assim por diante. Um redutor da lista de itens pode ficar assim:

const newsIdList = (state = [], action) => {
  if (action.type === 'GOT_NEWS') {
    return action.news.map(item => item.key);
  } else if (action.type === 'GOT_OLDER_NEWS') {
    return [...state, ...action.news.map(item => item.key)];
  }
  return state;
};

Quando cada página seguinte é carregada no estilo do aplicativo, uma nova matriz de identificadores é criada. Se passarmos essa matriz para props mais tarde FlatList, eis a aparência do log de renderização do componente:

SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..10>
ITEM_<1..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..20>
ITEM_<1..30>

Para este exemplo, fiz algumas alterações no aplicativo de teste.

  • Defina o tamanho da página como 10 notícias.
  • item NewsItem FlatList-, connect. NewsItem React.Component .
  • .
  • . №1 .

O exemplo mostra que, quando cada página seguinte é carregada, todos os elementos antigos são renderizados novamente, então os elementos antigos e os elementos da nova página são renderizados novamente. Para os amantes da matemática: se o tamanho da página for igual X, quando a ii - ésima página for carregada, em vez de renderizar apenas Xnovos elementos, os elementos serão renderizados (i - 1) * X + i * X.

“Ok”, você diz, “eu entendo por que todos os elementos são desenhados após adicionar uma nova página: o redutor retornou uma nova matriz, uma nova área de memória, tudo isso. Mas por que precisamos renderizar a lista antiga antes de adicionar novos elementos? ” "Boa pergunta", eu vou responder. Isso é consequência do trabalho com o estado do componente VirtualizedListem cuja baseFlatList. Não vou entrar em detalhes, pois eles publicam um artigo separado. Quem se importa, eu aconselho você a se aprofundar na documentação e na fonte.

Como se livrar de tal não otimização? Reescrevemos o redutor para que ele não retorne uma nova matriz para cada página, mas adicione elementos à existente:

Atenção! Antipattern!
. , , , PureComponent, . , . . Redux.

const newsIdList = (state = [], action) => {
  if (action.type === 'GOT_NEWS') {
    return action.news.map(item => item.key);
  } else if (action.type === 'GOT_OLDER_NEWS') {
    action.news.forEach(item => state.push(item.key));
    return state;
    // return [...state, ...action.news.map(item => item.key)];
  }
  return state;
};

Depois disso, nosso log ficará assim:

SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..30>

Nos livramos da renderização de elementos antigos antes de adicionar elementos a uma nova página, mas os elementos antigos ainda são desenhados após a atualização da lista. O número de renderizações para a próxima página agora é igual i * X. A fórmula ficou mais simples, mas não vamos parar por aí. Temos apenas Xnovos elementos e queremos apenas Xnovas renderizações. Usaremos os truques já conhecidos para remover as renderizações de notícias que não mudaram os adereços. Voltar conectar a NewsItem:

SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<11..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<21..30>

Bem! Agora podemos ficar satisfeitos conosco. Não há lugar para otimizar.

Código de exemplo completo

Um leitor atento indicará que, após aplicar a conexão ao NewsItemlog, será semelhante ao último exemplo, não importa como você implemente o redutor. E estará certo - se o componente de notícias verificar seus acessórios antes da renderização, não importa se a matriz antiga é usada pelo redutor ou se ela cria uma nova. Apenas novos elementos são desenhados e apenas uma vez. No entanto, alterar a matriz antiga em vez de criar uma nova nos salva de renderizações desnecessárias do componente FlatListusado nela VirtualizedListe de iterações desnecessárias de verificações de equivalência de props NewsItem. Com um grande número de elementos, isso também aumenta o desempenho.

O uso de matrizes e objetos mutáveis ​​nos redutores deve ser extremamente cauteloso. Neste exemplo, isso é justificado, mas se você tiver, digamos, normal PureComponent, quando adicionar elementos à matriz mutável, os componentes não serão renderizados. De fato, seus objetos permanecem inalterados, pois antes e depois da atualização dos pontos da matriz para a mesma área de memória. Isso pode levar a consequências inesperadas. Não é de admirar que o exemplo descrito viole os princípios do Redux .

E algo mais...


Se você usar bibliotecas no nível da apresentação, aconselho que você entenda em detalhes como elas são implementadas. Em nosso aplicativo, usamos um componente Swipeableda biblioteca react-native-gesture-handler. Ele permite implementar um bloco de ações adicionais ao trocar cartões da lista.

No código, fica assim:

<Swipeable
  ...
  renderRightActions={this.renderRightActions}
  ...
>

Método renderRightActionsou renderLeftActionsretorna o componente que é exibido após o furto. Determinamos e alteramos a altura do painel durante a troca de componentes para ajustar o conteúdo necessário. Esse é um processo que consome muitos recursos, mas se ocorrer durante a animação de furto, o usuário não verá interferência.


O problema é que o componente Swipeablechama o método renderRightActionsno momento da renderização do componente principal. Todos os cálculos e até a renderização da barra de ação, que não é visível antes do furto, ocorrem com antecedência. Portanto, todas essas ações são executadas para todos os cartões da lista ao mesmo tempo. Isso causou "freios" significativos ao rolar a prancha.

O problema foi resolvido da seguinte maneira. Se o painel de ação for desenhado junto com o componente principal, e não como resultado do furto, o método renderRightActionsretornará um vazio do Viewtamanho do componente principal. Caso contrário, desenharemos o painel de ações adicionais como antes.
Dou este exemplo porque as bibliotecas de suporte nem sempre funcionam como o esperado. E se essas são bibliotecas no nível da apresentação, é melhor garantir que elas não estejam desperdiçando recursos desnecessários.

achados


Após eliminar os problemas descritos no artigo, aceleramos significativamente o aplicativo no React Native. Agora é difícil diferenciá-lo no desempenho de um similar, implementado nativamente. O excesso de renderizações diminuiu o carregamento de telas individuais e a reação às ações do usuário. Acima de tudo, era perceptível nas listas, onde dezenas de componentes são desenhados ao mesmo tempo. Não otimizamos tudo, mas as telas principais do aplicativo não ficam mais lentas.

Os principais pontos do artigo estão listados abaixo a seguir.

  1. React Native : Props/State- .
  2. , React.PureComponent, , .
  3. , shouldComponentUpdate React.Memo .
  4. - . , (shallow compare). , .
  5. O suporte a bibliotecas no nível da apresentação pode levar ao desperdício inesperado de recursos. Vale a pena ter cuidado em sua aplicação.

Isso é tudo. Espero que você encontre as informações úteis. Ficarei feliz em qualquer feedback!

Fontes úteis


  1. Compreendendo a renderização no React + Redux
  2. Comparando objetos em JavaScript
  3. Melhorando o desempenho em reagir componentes funcionais usando React.memo ()
  4. Como o Discord atinge o desempenho nativo do iOS com o React Native

All Articles