Война с тормозами. Оптимизация количества рендеров компонентов в React Native

в 7:14, , рубрики: mobile, React, ReactJS, render, разработка мобильных приложений

Привет! Меня зовут Камо Сперцян, я занимаюсь React Native разработкой в Profi.ru. Если вы решили воспользоваться технологией React Native для быстрой доставки продуктовых фич и сосредоточились на скорости разработки, то, скорее всего, столкнётесь с проблемами производительности. По крайней мере так случилось с нами. Через полгода активной разработки производительность нашего приложения упала ниже критического уровня — всё дико тормозило. Поэтому мы взялись за оптимизацию — убирали все «тормоза» во время запуска, переходов между экранами, отрисовки экранов, реакций на действия пользователя. В результате за три месяца довели пользовательский опыт до нативного уровня. В этой статье хочу рассказать о том, как мы оптимизировали приложение на React Native и решали проблему многократных ререндеров компонентов.

Война с тормозами. Оптимизация количества рендеров компонентов в React Native - 1

Я собрал рекомендации, которые помогут минимизировать количество бессмысленных перерисовок компонентов. Для наглядности в примерах сравниваю «‎плохую» и «‎хорошую»‎ реализации. Статья будет полезна тем, кто уже столкнулся с низкой производительностью приложения, и тем, кто не хочет допустить этого в будущем.

Мы используем React Native в паре с Redux. Часть советов связана с этой библиотекой. Также в примере я использую библиотеку Redux-thunk — для имитации работы с сетью.

Когда стоит задуматься о производительности?

На самом деле о ней стоит помнить с самого начала работы над приложением. Но если ваше приложение уже тормозит — не отчаивайтесь, всё можно исправить.

Все знают, но на всякий случай упомяну: проверять производительность лучше на слабых девайсах. Если вы ведёте разработку на мощных устройствах, то можете и не подозревать о «‎‎тормозах» у конечных пользователей. Определите для себя устройства, на которые будете ориентироваться. Замерьте время или FPS на контрольных участках, чтобы сравнить с результатами после оптимизации.

React Native из коробки предоставляет возможность замерять FPS приложения через Developer Tools -> Show perf monitor. Эталонным значением является 60 кадров в секунду. Чем меньше этот показатель, тем сильнее приложение «‎тормозит» — не реагирует или реагирует с задержкой на действия пользователя. Одно из основных влияний на FPS оказывает количество рендеров, «‎тяжесть» которых зависит от сложности компонентов.

Описание примера

Все рекомендации я показываю на примере простого приложения со списком новостей. В приложении один экран, на котором располагается FlatList с новостями. Новость — это компонент NewsItem, состоящий из двух компонентов поменьше — заголовка (NewsItemTitle) и тела (NewsItemBody). Пример целиком можно посмотреть здесь. Дальше в тексте — ссылки на различные ветки репозитория под конкретные примеры. Репозиторий используется для удобства читателей, которые захотят исследовать примеры глубже. Код в репозитории и примерах ниже не претендует на звание совершенного — он нужен исключительно в демонстрационных целях.

Ниже схематично показаны все компоненты с указанием связей и пропсов.

Война с тормозами. Оптимизация количества рендеров компонентов в React Native - 2

В методе render каждого компонента я добавил вывод в консоль уникальной информации о нём:

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

где {no} — порядковый номер новости, чтобы различать рендеры различных новостей от многократных рендеров одной и той же.

Для тестирования на каждый refresh списка новостей в его начало добавляется дополнительная новость. При этом в консоль выводится следующее сообщение:

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

Эти записи помогут понять, есть ли проблема в каком-либо конкретном компоненте, а впоследствии — определить, удалось ли его оптимизировать.

При правильной реализации наш лог после запуска и нескольких обновлений должен выглядеть так:

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

При первом запуске отрисовывается сам экран и две начальные новости. При обновлении доски снова рендерится экран, потому что его данные действительно изменились. Появляется дополнительная новость. Все предыдущие новости повторно не отрисовываются, так как в их данных изменений не было.

Когда компонент рендерится?

В React и React Native есть два условия отрисовки компонента:

  1. изменение его Props/State,
  2. рендер родительского компонента.

В компоненте может быть переопределена функция shouldComponentUpdate — она получает на вход новые Props и State и сообщает, нужно ли рендерить компонент. Зачастую, чтобы избежать лишних ререндеров, достаточно поверхностного сравнения (shallow compare) объектов Props и State. Например, это избавляет от лишних рендеров при изменении родительского компонента, если они не затрагивают дочерний. Чтобы не писать каждый раз поверхностное сравнение вручную, можно унаследовать компонент от React.PureComponent, который инкапсулирует эту проверку.

Когда мы используем функцию связки connect, библиотека Redux создаёт новый, «связанный» с глобальным State'ом компонент. Изменения этого State'а запускают метод mapStateToProps, который возвращает новые пропсы. Далее запускается сравнение старых и новых пропсов, независимо от того, был ли компонент объявлен как PureComponent или нет.

Рассмотрим эти нюансы на нашем примере.

Компонент NewsItem «пропустим» через connect, NewsItemTitle унаследуем от React.Component, а NewsItemBody — от React.PureComponent.

Полный код примера

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

Вот как будет выглядеть лог после одного обновления доски:

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

Видно, что компоненты новости и заголовка отрисовываются повторно. Рассмотрим их по очереди.

NewsItem объявлен с использованием connect. В качестве пропса этот компонент получает идентификатор, по которому впоследствии получает новость в mapStateToProps:

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

Так как при обновлении доски все новости загружаются заново, то объект item до обновления и после будет ссылаться на различные ячейки памяти. Иными словами, это будут разные объекты, даже если все содержащиеся поля одинаковые. Поэтому сравнение предыдущего и нового State'ов компонента вернёт false. Компонент перерендерится, несмотря на то, что по факту данные не изменились.

NewsItemTitle унаследован от React.Component, поэтому он ререндерится каждый раз, когда рендерится родительский компонент. Это происходит независимо от значений старых и новых пропсов.

NewsItemBody унаследован от React.PureComponent, поэтому он сравнивает старые и новые пропсы. В новостях 1 и 2 их значения эквивалентны, поэтому компонент отрисовывается только для новости 3.

Чтобы оптимизировать рендеры NewsItemTitle, достаточно объявить его как React.PureComponent. В случае с NewsItem придётся переопределить функцию shouldComponentUpdate:

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

Полный код примера

Здесь shallowEqual — это функция для поверхностного сравнения объектов, которую предоставляет Redux. Можно написать и так:

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

Вот как будет выглядеть наш лог после этого:

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

Примечание

Реализации shouldComponentUpdate в NewsItem достаточно, чтобы NewsItemTitle перестал рендериться повторно. Тем не менее я советую оптимизировать его тоже. NewsItemTitle может быть использован где-то ещё или может появиться новая причина для рендера NewsItem, и тогда проблема снова всплывёт.

React.memo и функциональные компоненты

Переопределить shouldComponentUpdate в функциональном компоненте невозможно. Но это не означает, что для оптимизации функционального компонента придётся переписать его в классовый. Для таких случаев предусмотрена функция мемоизации React.memo. Она принимает на вход компонент и опциональную функцию сравнения areEqual. При вызове areEqual получает старые и новые пропсы и должна вернуть результат сравнения. Разница с shouldComponentUpdate в том, что areEqual должна вернуть true, если пропсы равны, а не наоборот.

На примере NewsItemTitle мемоизация может выглядеть так:

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

Если не передать areEqual в React.memo, то будет производиться поверхностное сравнение пропсов, поэтому наш пример можно упростить:

export OptimizedNewsItemTitle = React.memo(NewsItemTitle)

Lambda-функции в пропсах

Для обработки событий компонента в его пропсы могут передаваться функции. Самый яркий пример — реализация onPress. Часто для этого используются анонимные lambda-функции. Допустим, в NewsItemBody мы хотим показывать только превью, а если нажать на него — текст целиком. Для этого при рендере NewsItem в NewsItemBody передадим следующий проп:

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

Вот как при такой реализации выглядит лог, когда метод shouldComponentUpdate в NewsItem удалён:

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

Тела новостей 1 и 2 ререндерятся, хотя их данные не изменились, а NewsItemBody является PureComponent. Это связано с тем, что на каждый рендер NewsItem значение пропса onPress создаётся заново. Технически onPress при каждом рендере указывает на новую область в памяти, поэтому поверхностное сравнение пропсов в NewsItemBody возвращает false. Проблему устраняет такая запись:

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

Лог:

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

Полный код примера

К сожалению, анонимную функцию далеко не всегда можно переписать в виде метода или поля класса для такой записи. Самый частый случай — когда внутри lambda-функции используются переменные области видимости функции, в которой она объявляется.

Рассмотрим этот случай на нашем примере. Для перехода от общего списка на экран одной новости добавляем обработку нажатия на тело новости. Метод renderItem компонента FlatList будет выглядеть так:

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

Анонимную функцию onBodyPress нельзя объявить в классе, потому что тогда из области видимости пропадёт переменная item, которая нужна для перехода на конкретную новость.

Самое простое решение проблемы — изменить сигнатуру пропса onBodyPress компонента NewsItem так, чтобы при вызове в функцию передавался необходимый параметр. В данном случае это идентификатор новости.

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

В этом случае мы уже можем вынести анонимную функцию в метод класса компонента.

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

Однако такое решение потребует от нас изменения компонента NewsItem.

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

И вновь мы возвращаемся к обозначенной проблеме — передаём новую лямбда-функцию дочернему компоненту на каждый рендер родительского. Только теперь мы спустились на уровень ниже. Лог:

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

Чтобы на корню избавиться от этой проблемы, можно воспользоваться хуком useCallback. Он позволяет мемоизировать вызов функции с передачей аргумента. Если аргумент функции не меняется, то результат вызова useCallback будет указывать на ту же область памяти. В нашем примере это означает, что при перерисовке одной и той же новости проп onPress компонента NewsItemBody не изменится. Хуки можно использовать только в функциональных компонентах, поэтому окончательный вид компонента NewsItem будет следующим:

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

И лог:

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

Полный код примера

Массивы и объекты

В JavaScript функции представляются в виде объектов, наряду с массивами. Поэтому пример из предыдущего блока — частный случай создания нового объекта в пропсах. Он довольно распространённый, поэтому я и вынес его в отдельный пункт.

Любое создание новых функций, массивов или объектов в пропсах приводит к реререндеру компонента. Рассмотрим это правило на следующем примере. Передадим в NewsItemBody комбинированный стиль из двух значений:

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

И снова лог показывает лишние ререндеры компонента:

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

Чтобы решить эту проблему, можно выделить отдельный стиль, который объединит body и item, или, например, вынести объявление массива [styles.body, styles.item] в глобальную переменную.

Полный код примера

Редьюсеры массивов

Рассмотрим ещё один популярный источник «тормозов», связанный с использованием FlatList. Классическое приложение, которое содержит длинный список элементов с сервера, реализует пагинацию. То есть загружает ограниченный набор элементов в виде первой страницы, когда список текущих элементов заканчивается — подгружает следующую страницу, и так далее. Редьюсер списка элементов может выглядеть так:

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

При загрузке каждой следующей страницы в стейте приложения создаётся новый массив идентификаторов. Если далее мы передаём этот массив в пропсы FlatList'а, то вот как будут выглядеть логи рендеров компонентов:

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

Для данного примера в тестовом приложении я внёс несколько изменений.

  • Установил размер страницы — 10 новостей.
  • Передал проп новости item для компонента NewsItem из FlatList-а, а не доставал из стейта приложения через connect. NewsItem стал обычным наследником React.Component без проверок необходимости перерисовки.
  • Удалил логи из дочерних компонентов новости.
  • Пронумеровал новости в обратном порядке для упрощения восприятия. То есть список новостей начинается с №1 и далее идёт по возрастанию.

На примере видно, что при загрузке каждой следующей страницы заново рендерятся все старые элементы, потом снова рендерятся старые элементы и элементы новой страницы. Для любителей математики: если размер страницы равен X, то при загрузке i-й страницы вместо отрисовки только X новых элементов ререндерятся (i - 1) * X + i * X элементов.

«Ок, — скажете вы, — мне понятно, почему отрисовываются все элементы после добавления новой страницы: редьюсер вернул новый массив, новая область памяти, всё такое. Но зачем нужен рендер старого списка до добавления новых элементов?» «Хороший вопрос», — отвечу вам я. Это следствие работы со стейтом компонента VirtualizedList, на базе которого построен FlatList. Не буду вдаваться в детали, так как они тянут на отдельную статью. Кому интересно, советую покопаться в документации и исходниках.

Как избавиться от такой неоптимальности? Перепишем редьюсер так, чтобы он не возвращал новый массив для каждой страницы, а добавлял элементы в существующий:

Внимание! Антипаттерн!

Использовать мутабельные массивы и объекты в редьюсерах следует с особой осторожностью. В данном примере это оправданно, однако если у вас, скажем, обычный 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;
};

После этого наш лог станет выглядеть так:

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

Мы избавились от рендера старых элементов до добавления элементов новой страницы, но старые элементы по-прежнему отрисовываются после обновления списка. Количество рендеров для очередной страницы теперь равно i * X. Формула стала проще, но мы на этом не остановимся. У нас только X новых элементов, и мы хотим только X новых рендеров. Воспользуемся уже знакомыми приёмами, чтобы убрать рендеры новостей, у которых не изменились пропсы. Вернём connect в NewsItem:

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

Отлично! Теперь мы можем быть собой довольны. Дальше оптимизировать некуда.

Полный код примера

Внимательный читатель укажет, что после применения connect к NewsItem лог будет выглядеть как в последнем примере, каким бы образом вы ни реализовали редьюсер. И будет прав — если компонент новости проверяет свои пропсы перед рендером, то неважно, использует редьюсер старый массив или создаёт новый. Отрисуются только новые элементы и только по одному разу. Однако изменение старого массива вместо создания нового избавляет нас от лишних рендеров самого компонента FlatList, используемого в нём VirtualizedList и лишних итераций проверок эквивалентности пропсов NewsItem. При большом количестве элементов это тоже даёт прирост производительности.

Использовать мутабельные массивы и объекты в редьюсерах следует с особой осторожностью. В данном примере это оправданно, однако если у вас, скажем, обычный PureComponent, то при добавлении элементов в мутабельный массив компонент не перерендерится. Его пропсы по факту остаются неизменными, так как до и после обновления массив указывает на ту же область памяти. Это может привести к неожиданным последствиям. Недаром описанный пример нарушает принципы Redux.

И ещё кое-что...

Если вы используете библиотеки уровня представления, то советую убедиться в том, что вы понимаете в деталях, как они реализованы. В нашем приложении мы используем компонент Swipeable из библиотеки react-native-gesture-handler. Он позволяет реализовать блок дополнительных действий при свайпе карточки из списка.

В коде это выглядит так:

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

Метод renderRightActions или renderLeftActions возвращает компонент, который отображается после свайпа. Мы определяли и изменяли высоту панели во время смены компонентов, чтобы уместить необходимый контент. Это ресурсоёмкий процесс, но если он происходит во время анимации свайпа, пользователь не видит помех.

Война с тормозами. Оптимизация количества рендеров компонентов в React Native - 3

Проблема в том, что компонент Swipeable вызывает метод renderRightActions в момент отрисовки основного компонента. Все вычисления и даже отрисовка панели действий, которую не видно до свайпа, происходят заранее. А значит, все эти действия выполняются для всех карточек в списке одновременно. Это стало причиной значительных «тормозов» при скролле доски.

Проблему решили следующим способом. Если панель действий отрисовывается вместе с основным компонентом, а не в результате свайпа, то метод renderRightActions возвращает пустую View размером с основной компонент. В противном случае отрисовываем панель дополнительных действий как раньше.
Я привожу этот пример потому, что не всегда вспомогательные библиотеки работают так, как вы того ожидаете. И если это библиотеки уровня представления, то лучше убедиться в том, что они не расходуют лишних ресурсов впустую.

Выводы

После устранения описанных в статье проблем мы значительно ускорили работу приложения на React Native. Сейчас его трудно отличить по производительности от аналогичного, реализованного нативно. Лишние рендеры замедляли как загрузку отдельных экранов, так и реакцию на действия пользователя. Больше всего это было заметно на списках, где разом отрисовываются десятки компонентов. Мы не оптимизировали всё подряд, но основные экраны приложения больше не тормозят.

Ниже кратко перечислены основные тезисы статьи.

  1. В React Native рендер компонента происходит при двух событиях: изменении Props/State- компонента или рендере родительского компонента.
  2. Компонент, унаследованный от React.PureComponent, перерисовывается только в том случае, если его пропсы или стейт действительно изменились.
  3. Такой же эффект можно получить, если переопределить метод shouldComponentUpdate для классового компонента или применить React.Memo для функционального компонента.
  4. Лямбда-функции в пропсах создают новый объект при каждом рендере. Это приводит к рендеру дочернего компонента, даже если в нём производится поверхностное сравнение пропсов (shallow compare). К такому же результату приводит создание новых массивов и других объектов в пропсах и редьюсерах, значения которых передаются в пропсы.
  5. Вспомогательные библиотеки уровня представления могут приводить к неожиданным тратам ресурсов. Стоит быть аккуратными в их применении.

На этом всё. Надеюсь, информация окажется для вас полезной. Буду рад любой обратной связи!

Полезные источники

  1. Understanding Rendering in React + Redux
  2. Comparing Objects in JavaScript
  3. Improving Performance in React Functional Components using React.memo()
  4. How Discord achieves native iOS performance with React Native

Автор: Камо Сперцян

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js