Отвечая на вопросы в треде по React Native на StackOverflow, я заметил что в той или иной форме люди очень часто интересуются производительностью компонентов списков и в частности FlatList. В этом гайде рассмотрим способы оптимизации производительности на примере приложения для отображения списка вопросов с StackOverflow, а во второй половине статья расскажу о новом компоненте ⚡️FlashList который драматически ускорит работу списков.
Тестовое приложение
В качестве экспериментального создадим приложение которое загружает, и выводит список из 20 вопросов со StackOverflow по нажатию на кнопку. Я умышлено допущу несколько очень популярных ошибок и на графике Flamegraph покажу к каким проблемам во время рендеринга они приводя и как их исправить.
Главный экран App.js
import React, { useState } from 'react';
import {
SafeAreaView,
FlatList,
Button,
ActivityIndicator,
StyleSheet,
} from 'react-native';
ъ
import QuestionCard from './src/components/QuestionCard';
const API_URL =
'https://api.stackexchange.com/2.3/questions?site=stackoverflow&order=desc&sort=activity&tagged=react&filter=default';
const App = () => {
const [isLoading, setLoading] = useState(false);
const [data, setData] = useState([]);
const getQuestions = async () => {
try {
setLoading(true);
const response = await fetch(API_URL);
const json = await response.json();
setData(json.items);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return (
<SafeAreaView style={styles.container}>
<Button title="update" onPress={getQuestions} />
{isLoading && <ActivityIndicator />}
<FlatList
data={data}
keyExtractor={(item, index) => item?.index}
renderItem={({ item }) => <QuestionCard item={item} />}
/>
</SafeAreaView>
);
};
Карточка вопроса QuestionCard.js
import React, { memo } from 'react';
import { View, StyleSheet } from 'react-native';
import { decode } from 'html-entities';
import User from './User';
import Question from './Question';
import Tags from './Tags';
import Statistics from './Statistics';
const QuestionCard = ({ item }) => {
const { owner, title, tags, view_count, answer_count } = item;
return (
<View style={styles.container}>
<User
avatarUrl={owner?.profile_image}
name={owner?.display_name}
reputation={owner?.reputation}
/>
<Question text={decode(title)} />
<Tags tags={tags} />
<View style={styles.divider} />
<Statistics views={view_count} answers={answer_count} />
</View>
);
};
export default QuestionCard;
Нажав на кнопку update 2 раза и записав Flamegraph получим следующую картину:
Несмотря на то, что наш список содержит keyExtractor
при получении массива таких же данный он полностью перерисовывает список.
keyExtractor и анонимные функции
Первое, что мы исправим это вынесем наши renderItem
и keyExtractor
из метода рендеринга, а так же перепишем keyExtractor
на использование идентификатора вопроса. Анонимные функции вызывают re-render каждый раз даже если значения возвращаемые keyExtractor
одинаковые.
const keyExtractor = item => item?.question_id;
const renderItem = ({ item }) => <QuestionCard item={item} />;
const App = () => {
//.....
return (
<SafeAreaView style={styles.container}>
<Button title="update" onPress={getQuestions} />
{isLoading && <ActivityIndicator />}
<FlatList
data={data}
keyExtractor={keyExtractor}
renderItem={renderItem}
/>
</SafeAreaView>
);
}
Ситуация улучшилась но все еще видим повторный render элементов при том что данные не изменились.
Memoization
Компонент, который используется в renderItem
должен быть легковесным и поддерживать мемоизацию для сложных объектов. В нашем случаем данные это сложный объект поэтому добавляем функцию мемоизации которая проверяет, что наш объект не поменялся на основании id вопроса.
export default memo(QuestionCard, (prevProps, nextProps) => {
return prevProps.question_id === nextProps.question_id;
});
повторный рендеринг с теми же данными стал значительно быстрее
Легковестные копмопненты в renderItem
Для оптимизации скорости рендеринга элемента списка нужно делать компоненты в renderItem
как можно проще и избегать тяжелых операций и преобразований внутри них. В нашем примере можем вынести преобразование HTML символов с помощью decode
из компонента в код где получаем данные.
//...
const response = await fetch(API_URL);
const json = await response.json();
const result = json.items.map(item => {
item.title = decode(item.title);
return item;
});
setData(result);
//...
Так же можно оптимизировать работу с изображениями, заменив стандартный Image на более производительный fast-image поддерживающий кэширование.
Свойства initialNumToRender и maxToRenderPerBatch
В зависимости от поставленных задач можно настроить поведение списка. В нашем примере на первом экране появляется от 3 до 5 вопросов и поэтому если хотим чтобы пользователь быстрее увидел эту информацию можем изменить свойство initialNumToRender
с его значения по умолчанию в 10 например на 5. Это ускорит появления первых элементов списка в 2 раза.
Как видим скорость отрисовки списка осталась одинаковой однако пользователь увидит данные быстрее. Это полезно когда элементы списка представляют собой сложные компоненты.
Из диаграммы выше можно заметить что элементы отрисовываются не сразу, а в несколько итераций. Это сделано для того чтобы разгрузить JS поток и давать выполняться другим задачам. По умолчанию, за одну итерацию, отрисовывается максимум 10 элементов, однако можно поменять это значение с помощью параметра maxToRenderPerBatch
. Увеличивая количество, уменьшается вероятность появления пустых областей при прокрути списка однако если рендеринг элемента списка занимает много времени то можно заблокировать JS поток. Для того чтобы разблокировать поток между итерациями рендеринга существует свойство updateCellsBatchingPeriod
задаваемое в миллисекундах. По умолчанию это 50 миллисекунд, но если мы знаем что нам нужно больше времени на какие то задачи, можно увеличить это время.
windowSize
По умолчанию список рендерит 10 экранов вверх и 10 вверх. С помощью свойства windowSize
можно изменять эти параметры. Значение по умолчанию 21 может быть уменьшено для сокращения потребления памяти или наоборот увеличено если список состоит из простых компонент и есть кейс, где пользователь быстро его прокручивает.
getItemLayout
Отдельно стоит упомянуть про возможность указать размеры элемента списка если вы уверены, что они одинаковые. Это сильно упрощает расчеты так как ненужно асинхронно пересчитывать размер списка.
getItemLayout={(data, index) => (
{length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index}
)}
Переход на FlashList
Если хотите добиться производительности близкой к нативной забудьте что было выше и переходите на https://shopify.github.io/flash-list/
Этот компонент имеет такое же API, как и FlatList, но использует другой подход к отрисовке. Вместо уничтожения компоненты после того как он уходит за пределы viewport, FlashList перерисовывает его с другими свойствами.
Для того чтобы мигрировать наше приложение на FlashList, нужно выполнить несколько простых, но важных условий.
Удалить все свойства
key
из иерархии компонентов которые используются внутри renderItem. Если где то используетсяmap
, то использовать в качествеkey
индекс.Для свойства
estimatedItemSize
указать средний размер высоты или ширины если список вертикальный. Эти данные можно получить например с помощью Flipper плагина LayoutЕсли внутри компонентов из
renderItem
используетсяuseState
, то можно получить состояние от предыдущего компонента. Чтобы избежать этого, нужно сбросить состояниеuseState
или в идеале не использоватьuseState
const MyItem = ({ item }) => {
const lastItemId = useRef(item.someId);
const [liked, setLiked] = useState(item.liked);
if (item.someId !== lastItemId.current) {
lastItemId.current = item.someId;
setLiked(item.liked);
}
return (
<Pressable onPress={() => setLiked(true)}>
<Text>{liked}</Text>
</Pressable>
);
};
Давайте обновим наш компонент и посмотрим как будет выглядеть график.
<FlashList
data={data}
keyExtractor={keyExtractor}
renderItem={renderItem}
estimatedItemSize={250}
/>
Такие свойства как initialNumToRender
, maxToRenderPerBatch
, getItemLayout
и тд не изменят ничего в поведении FlashList.
На графике видно как изменилось поведение списка. Однако это не совсем правильный способ измерять его производительность. Вместе с FlashList поставляется несколько функций, которые помогают собрать реальные метрики. Подробнее об их использовании можно почитать https://shopify.github.io/flash-list/docs/metrics
Итого
Если у вас есть приложение использующее списки и по каким-то причинам вы не можете перейти на FlashList попробуйте воспользоваться советами и поиграть с параметрами отрисовки. Моя рекомендация как можно скорее мигрировать на этот компонент ну или хотя бы попробовать его чтобы как говориться почувствовать разницу.
Также подписывайтесь на мой Телеграм-канал React Native World — там я размещаю последние новости, обзоры и статьи из мира React Native.