Guerra en los frenos. Optimizar el número de representaciones de componentes en React Native

Hola Habr! Mi nombre es Kamo Spertsyan, estoy involucrado en el desarrollo React Native en Profi.ru. Si decide utilizar la tecnología React Native para ofrecer rápidamente características del producto y centrarse en la velocidad de desarrollo, es probable que tenga problemas de rendimiento. Al menos eso es lo que nos pasó. Después de seis meses de desarrollo activo, el rendimiento de nuestra aplicación cayó por debajo de un nivel crítico: todo fue extremadamente lento. Por lo tanto, retomamos la optimización: eliminamos todos los "frenos" durante el inicio, las transiciones entre pantallas, las pantallas de representación, las reacciones a las acciones del usuario. Como resultado, en tres meses llevaron la experiencia del usuario al nivel nativo. En este artículo quiero hablar sobre cómo optimizamos la aplicación en React Native y resolvimos el problema de los renders de múltiples componentes.



Reuní recomendaciones que ayudarán a minimizar la cantidad de redibujos sin sentido de componentes. Para mayor claridad, en los ejemplos comparo las implementaciones "malas" y "buenas". El artículo será útil para aquellos que ya enfrentan un rendimiento deficiente de la aplicación y para aquellos que no desean permitirlo en el futuro.

Usamos React Native emparejado con Redux. Algunos de los consejos están relacionados con esta biblioteca. También en el ejemplo, uso la biblioteca Redux-thunk para simular el trabajo con la red.

¿Cuándo pensar en el rendimiento?


De hecho, vale la pena recordarlo desde el comienzo del trabajo en la aplicación. Pero si su aplicación ya se está ralentizando, no se desespere, todo se puede arreglar.

Todos lo saben, pero por si acaso, mencionaré: es mejor verificar el rendimiento en dispositivos débiles. Si está desarrollando en dispositivos potentes, es posible que no esté al tanto de los "frenos" de los usuarios finales. Decide por ti mismo los dispositivos que te guiarán. Mida el tiempo o FPS en los gráficos de control para compararlos con los resultados después de la optimización.

React Native de fábrica proporciona la capacidad de medir aplicaciones FPS a través de Developer Tools → Show perf monitor. El valor de referencia es 60 cuadros por segundo. Cuanto más bajo es este indicador, más fuerte se "ralentiza" la aplicación, no responde o reacciona con retraso a las acciones del usuario. Uno de los principales efectos en FPS es la cantidad de renders, cuya "gravedad" depende de la complejidad de los componentes.

Descripción de ejemplo


Muestro todas las recomendaciones sobre el ejemplo de una aplicación simple con una lista de noticias. La aplicación tiene una pantalla, que se encuentra FlatListcon las noticias. Una noticia es un componente NewsItemque consta de dos componentes más pequeños: el título ( NewsItemTitle) y el cuerpo ( NewsItemBody). El ejemplo completo se puede ver aquí . Además en el texto hay enlaces a varias ramas del repositorio para ejemplos específicos. El repositorio se utiliza para la comodidad de los lectores que desean explorar ejemplos más profundamente. El código en el repositorio y los ejemplos a continuación no dicen ser perfectos, se necesitan únicamente con fines de demostración.

A continuación, todos los componentes se muestran esquemáticamente con enlaces y accesorios.


En el método de representación de cada componente, agregué el resultado a la consola de información única sobre él:

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

donde se {no}encuentra el número de serie de la noticia para distinguir entre diferentes representaciones de noticias de múltiples representaciones de la misma.

Para probar en cada refreshlista de noticias, se agregan noticias adicionales a su comienzo. Al mismo tiempo, se muestra el siguiente mensaje en la consola:

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

Estos registros ayudarán a comprender si hay un problema en algún componente en particular y luego a determinar si fue posible optimizarlo.

Si se implementa correctamente, nuestro registro después del lanzamiento y varias actualizaciones deberían verse así:

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

En el primer inicio, se dibujan la pantalla y dos noticias iniciales. Al actualizar el tablero, la pantalla se vuelve a mostrar, porque sus datos realmente han cambiado. Más noticias surgen. Todas las noticias anteriores no se vuelven a dibujar, ya que no hubo cambios en sus datos.

¿Cuándo se representa un componente?


En React y React Native, hay dos condiciones para representar un componente:

  1. cambiando sus accesorios / estado,
  2. render del componente padre.

Una función se puede redefinir en un componente shouldComponentUpdate: recibe nuevos Props y State como entrada y le indica si el componente debe representarse. A menudo, para evitar representaciones innecesarias, es suficiente una comparación superficial de los objetos Props y State. Por ejemplo, esto elimina representaciones innecesarias cuando cambia el componente principal, si no afectan al componente secundario. Para no escribir una comparación de superficie manualmente cada vez, puede heredar un componente del React.PureComponentque encapsula esta verificación.

Cuando usamos la función de enlace de conexión, la biblioteca Redux crea un nuevo componente que está "conectado" al Estado global. Los cambios en este estado desencadenan un métodomapStateToPropsque devuelve nuevos accesorios. A continuación, se inicia una comparación de accesorios antiguos y nuevos, independientemente de si el componente se declaró PureComponento no.

Considere estos matices en nuestro ejemplo.

Dejaremos NewsItemque el componente pase connect, NewsItemTitleherede de React.Componenty NewsItemBody- de React.PureComponent.

Código de ejemplo completo

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

Así se verá el registro después de una actualización de la placa:

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

Puede ver que las noticias y los componentes de los titulares se vuelven a dibujar. Los consideraremos a su vez.

NewsItemdeclarado usando connect. Como accesorio, este componente recibe un identificador, por el cual posteriormente recibe noticias en mapStateToProps:

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

Dado que al actualizar el tablero, todas las noticias se descargan nuevamente, el objeto se itemactualizará y luego se referirá a varias celdas de memoria. En otras palabras, serán objetos diferentes, incluso si todos los campos contenidos son iguales. Por lo tanto, se obtiene una comparación del componente State'ov anterior y el nuevo false. El componente se volverá a representar, a pesar de que, de hecho, los datos no han cambiado.

NewsItemTitlese hereda de React.Component, por lo que se vuelve a representar cada vez que se representa el componente principal. Esto sucede independientemente de los valores de los accesorios antiguos y nuevos.

NewsItemBodyheredado de React.PureComponent, por lo que compara accesorios antiguos y nuevos. En las noticias 1 y 2, sus valores son equivalentes, por lo tanto, el componente se representa solo para las noticias 3.

Para optimizar las representacionesNewsItemTitlesolo declaralo como React.PureComponent. En el caso de, NewsItemdebe redefinir la función shouldComponentUpdate:

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

Código de ejemplo completo

Aquí shallowEqualhay una función para la comparación de superficie de objetos que proporciona Redux. Puedes escribir así:

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

Así es como se verá nuestro registro después de esto:

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 y componentes funcionales


No es shouldComponentUpdateposible anular en un componente funcional. Pero esto no significa que para optimizar un componente funcional, debe reescribirlo en uno de clase. Para tales casos, se proporciona la función de memorización React.memo . Acepta una entrada de componente y una función de comparación opcional areEqual. Cuando se le llama, areEqualobtiene accesorios viejos y nuevos y debería devolver el resultado de la comparación. La diferencia con shouldComponentUpdatelo que areEqualdebería regresar truesi los accesorios son iguales, y no al revés.

Por ejemplo, la NewsItemTitlememorización puede verse así:

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

Si usted no pasa areEqualen React.memo, a continuación, se hará una comparación superficial de accesorios, por lo que nuestro ejemplo se puede simplificar:

export OptimizedNewsItemTitle = React.memo(NewsItemTitle)

Lambda funciona en accesorios


Para procesar eventos de componentes, las funciones se pueden pasar a sus accesorios. El ejemplo más llamativo es la implementación onPress. A menudo se utilizan funciones lambda anónimas para esto. Digamos NewsItemBodyque queremos mostrar solo la vista previa, y si hace clic en ella, todo el texto. Para hacer esto, al renderizar NewsItem, NewsItemBodypasaremos el siguiente accesorio:

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

Así es como se ve el registro con esta implementación cuando shouldComponentUpdatese NewsItemelimina el método :

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

Los cuerpos de noticias 1 y 2 se representan, aunque sus datos no han cambiado, pero lo NewsItemBodyestán PureComponent. Esto se debe al hecho de que para cada render el NewsItemvalor de los accesorios onPressse crea de nuevo. Técnicamente, onPresscon cada render, apunta a una nueva área en la memoria, por lo que una comparación superficial de los apoyos en NewsItemBodydevuelve falso. El problema se soluciona con la siguiente entrada:

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

Iniciar sesión:

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 ejemplo completo

Desafortunadamente, una función anónima no puede reescribirse siempre como método o campo de clase para dicho registro. El caso más común es cuando dentro de la función lambda se utilizan las variables de alcance de la función en la que se declara.

Considere este caso en nuestro ejemplo. Para pasar de la lista general a la pantalla de una noticia, agregamos el procesamiento de hacer clic en el cuerpo de la noticia. El método del renderItemcomponente FlatListse verá así:

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

onBodyPressNo se puede declarar una función anónima en una clase, porque la variable itemque se necesita para ir a una noticia específica desaparecerá del alcance .

La solución más simple al problema es cambiar la firma de los accesorios del onBodyPresscomponente NewsItempara que el parámetro requerido se pase a la función cuando se llama. En este caso, este es el identificador de noticias.

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

En este caso, ya podemos eliminar la función anónima en el método de clase de componente.

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

Sin embargo, tal solución requerirá que cambiemos el componente NewsItem.

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

Y nuevamente volvemos al problema indicado: pasamos una nueva función lambda al componente hijo para cada render del padre. Solo que ahora hemos bajado un nivel. Iniciar sesión:

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 deshacerse de este problema en la raíz, puede usar el gancho useCallback . Permite memorizar una llamada de función con pasar un argumento. Si el argumento de la función no cambia, el resultado de la llamada useCallbackapuntará a la misma área de memoria. En nuestro ejemplo, esto significa que al volver a dibujar las mismas noticias, el onPresscomponente de utilería NewsItemBodyno cambiará. Los ganchos solo se pueden usar en componentes funcionales, por lo que el aspecto final del componente NewsItemserá el siguiente:

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

Y el 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 ejemplo completo

Matrices y objetos


En JavaScript, las funciones se representan como objetos, junto con las matrices. Por lo tanto, el ejemplo del bloque anterior es un caso especial de crear un nuevo objeto en accesorios. Es bastante común, así que lo puse en un párrafo separado.

Cualquier creación de nuevas funciones, matrices u objetos en accesorios lleva a un renderizador de componentes. Considere esta regla en el siguiente ejemplo. Pasemos en un NewsItemBodyestilo combinado de dos valores:

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

Y nuevamente, el registro muestra los procesadores de componentes adicionales:

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 este problema, puede seleccionar un estilo separado que combinará bodyy item, por ejemplo, moverá la declaración de la matriz [styles.body, styles.item]a una variable global.

Código de ejemplo completo

Reductores de matriz


Considere otra fuente popular de "frenos" asociados con el uso FlatList. Una aplicación clásica que contiene una larga lista de elementos del servidor implementa la paginación. Es decir, carga un conjunto limitado de elementos en forma de la primera página, cuando finaliza la lista de elementos actuales, carga la página siguiente, y así sucesivamente. Un reductor de lista de elementos podría verse así:

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;
};

Cuando cada página siguiente se carga al estilo de la aplicación, se crea una nueva matriz de identificadores. Si pasamos esta matriz a los accesorios más FlatListadelante, así es como se verán los registros de representación del 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 ejemplo, hice algunos cambios en la aplicación de prueba.

  • Establezca el tamaño de la página en 10 noticias.
  • item NewsItem FlatList-, connect. NewsItem React.Component .
  • .
  • . №1 .

El ejemplo muestra que cuando se carga cada página siguiente, todos los elementos antiguos se procesan nuevamente, luego los elementos antiguos y los elementos de la página nueva se procesan nuevamente. Para los amantes de las matemáticas: si el tamaño de la página es igual X, cuando ise carga la i-ésima página , en lugar de representar solo Xelementos nuevos, se representan los elementos (i - 1) * X + i * X.

“Ok”, dices, “entiendo por qué todos los elementos se dibujan después de agregar una nueva página: el reductor devolvió una nueva matriz, una nueva área de memoria, todo eso. Pero, ¿por qué necesitamos renderizar la lista anterior antes de agregar nuevos elementos? "Buena pregunta", te responderé. Esto es una consecuencia de trabajar con el estado del componente VirtualizedListsobre cuya baseFlatList. No entraré en detalles, ya que sacan un artículo separado. A quién le importa, le aconsejo que profundice en la documentación y la fuente.

¿Cómo deshacerse de tal falta de optimización? Reescribimos el reductor para que no devuelva una nueva matriz para cada página, sino que agrega elementos a la existente:

¡Atención! Antipatrón!
. , , , 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;
};

Después de eso, nuestro registro se verá así:

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

Eliminamos la representación de elementos antiguos antes de agregar elementos a una nueva página, pero los elementos antiguos aún se dibujan después de actualizar la lista. El número de representaciones para la página siguiente ahora es igual i * X. La fórmula se ha vuelto más simple, pero no nos detendremos allí. Solo tenemos Xnuevos elementos, y solo queremos Xnuevos renders. Utilizaremos los trucos ya familiares para eliminar los renders de noticias que no han cambiado los accesorios. Volver conectar a NewsItem:

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

¡Multa! Ahora podemos estar satisfechos con nosotros mismos. No hay ningún lugar para optimizar.

Código de ejemplo completo

Un lector atento indicará que después de aplicar connect al NewsItemregistro, se verá como en el último ejemplo, sin importar cómo implemente el reductor. Y será correcto: si el componente de noticias comprueba sus accesorios antes de renderizar, no importa si el reductor usa la matriz anterior o si crea una nueva. Solo se dibujan elementos nuevos y solo una vez. Sin embargo, cambiar la matriz anterior en lugar de crear una nueva nos salva de representaciones innecesarias del componente FlatListutilizado en ella VirtualizedListy de iteraciones innecesarias de comprobaciones de equivalencia de accesorios NewsItem. Con una gran cantidad de elementos, esto también aumenta el rendimiento.

El uso de matrices y objetos mutables en los reductores debe ser con extrema precaución. En este ejemplo, esto está justificado, pero si tiene, digamos, normal PureComponent, cuando agrega elementos a la matriz mutable, los componentes no se procesarán. De hecho, sus accesorios permanecen sin cambios, ya que antes y después de actualizar los puntos de la matriz a la misma área de memoria. Esto puede conducir a consecuencias inesperadas. No es de extrañar que el ejemplo descrito viole los principios de Redux .

Y algo más...


Si utiliza bibliotecas de nivel de presentación, le aconsejo que se asegure de comprender en detalle cómo se implementan. En nuestra aplicación, usamos un componente Swipeablede la biblioteca react-native-gesture-handler. Le permite implementar un bloque de acciones adicionales al deslizar una tarjeta de la lista.

En código, se ve así:

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

Método renderRightActionso renderLeftActionsdevuelve el componente que se muestra después del deslizamiento. Determinamos y cambiamos la altura del panel durante el cambio de componentes para ajustar el contenido necesario. Este es un proceso que requiere muchos recursos, pero si ocurre durante la animación de deslizamiento, el usuario no ve interferencia.


El problema es que el componente Swipeablellama al método renderRightActionsal momento de representar el componente principal. Todos los cálculos e incluso la representación de la barra de acción, que no es visible antes del deslizamiento, se realizan por adelantado. Por lo tanto, todas estas acciones se realizan para todas las tarjetas de la lista al mismo tiempo. Esto causó importantes "frenos" al desplazarse por el tablero.

El problema se resolvió de la siguiente manera. Si el panel de acción se dibuja junto con el componente principal, y no como resultado del deslizamiento, el método renderRightActionsdevuelve uno vacío del Viewtamaño del componente principal. De lo contrario, dibujamos el panel de acciones adicionales como antes.
Doy este ejemplo porque las bibliotecas de soporte no siempre funcionan como se espera. Y si se trata de bibliotecas de nivel de presentación, entonces es mejor asegurarse de que no están desperdiciando recursos innecesarios.

recomendaciones


Después de eliminar los problemas descritos en el artículo, aceleramos significativamente la aplicación en React Native. Ahora es difícil distinguirlo en rendimiento de uno similar, implementado de forma nativa. El exceso de renderizado ralentizó tanto la carga de pantallas individuales como la reacción a las acciones del usuario. Sobre todo, se notaba en las listas, donde se dibujan docenas de componentes a la vez. No hemos optimizado todo, pero las pantallas principales de la aplicación ya no se ralentizan.

Los puntos principales del artículo se enumeran brevemente a continuación.

  1. React Native : Props/State- .
  2. , React.PureComponent, , .
  3. , shouldComponentUpdate React.Memo .
  4. - . , (shallow compare). , .
  5. El soporte de bibliotecas de nivel de presentación puede conducir a un desperdicio inesperado de recursos. Vale la pena tener cuidado en su aplicación.

Eso es todo. Espero que encuentre útil la información. Estaré encantado de cualquier comentario!

Fuentes utiles


  1. Comprender la representación en React + Redux
  2. Comparar objetos en JavaScript
  3. Mejora del rendimiento en componentes funcionales React usando React.memo ()
  4. Cómo Discord logra el rendimiento nativo de iOS con React Native

All Articles