Как я ускорил свое мобильное приложение BookDesk на React Native в 3 раза?

Увелчение производительности React Native приложения
Увелчение производительности React Native приложения

Всем привет! В этом материале я поделюсь с вами практическим опытом по оптимизации своего мобильного приложения на React Native. Расскажу как ускорил свое приложение в 3 раза.

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

Я разрабатываю свое мобильное приложение BookDesk для хранения прочитанных книг. Предыдущие серии: 1, 2, 3

Я делаю проиложение для Android, но эти советы будут также полезны и для iOS версии, так как разработку веду на React Native.

React Native базируется на библиотеке react для построения пользовательских интерфейсов и все работает идентично как в веб версии так и в мобильных приложениях.

Итак, начнем!

1. Оптимизация перерисовок интерфейса (re-rendering)

Схема работы определения изменений и перерисовки в react
Схема работы определения изменений и перерисовки в react

Итак, перерисовки или re-rendering компонентов это самая главная проблема многих приложений на React Native.

Для начала, давайте разберемся что же такое эти перерисовки и почему они являются проблемой.

В React Native приложениях самые "тяжелые" операции это перерисовки элементов/компонентов интерфейса. Почему? Каждое приложение имеет некую объектную модель либо иерархическую структуру пользовательских элементов (другими словами то что мы видим на экране). Например в веб-приложения[ это DOM (document object model) и она представляет собой глубоковложенную модель элементов со всеми свойствами, атрибутами, тегами и т.д.

DOM - document object model
DOM - document object model

Все зависит от сложности интерфейса и количества элементов. Схожая модель и в мобильных приложениях.

Так вот, чтобы отобразить любое изменение на экране пользователя, необходимо вначале найти нужный элемент, внести в него изменения и вызвать перерисовку интерфейса для отображения обновленного элемента. React умеет работать с этим и оптимизирует такие манипуляции путем создания вирутальной модели и с помощью сравнений определяет сам что было изменено и перерисовывает те элементы которые изменелись с наименьшими потерями в производительности.

В приложениях с малым количеством элементов или простым интерфейсом этого может быть достаточно и, возможно, не надо проводить дополнительных работ по оптимизации (но я советую применять оптимизацию не зависимо от размера или сложности приложения). Любое приложение имеет свойство разрастаться и увеличиваться в масштабе и здесь без оптимизации будет тяжело получить качественный продукт. В какой-то момент времени придется столкнуться с проблемами в виде тормозов и фризов. Такие элементы как списки, сетки (grids) для отображения данных, при этом списки могут содержать большое количество динамических элементов с картинками, формами, контролами, кнопками и т.д. все это создает определенную нагрузку на интерфейс и на саму модель. В этом случае без оптимизации можно получить множество проблем в виде задержек, лагов при работе с интерфейсом и виной тому все те же перерисовки. А причина этому кроется в неправильной структуре приложения, поэтому грамотная структура это уже полдела.

Почему срабатывают перерисовки?

Давайте теперь разберемся почему срабатывают перерисовки. Здесь все очень просто.

Вот основные причины перерисовки компонентов:

1. Изменение props компонента приведет его к перерисовке.

2. Изменение значений локального состояния компонента. Например, когда мы объявляем локальное состояние через хук useState.

3. Изменение значений глобального состояния любого из менеджеров состояний (redux, mobx, zustand и т.д.)

4. Изменение значений контекста через хук useContext

Все эти изменения повлияют на переривоку как самого компонента так и всех вложенных в него компонентов. Это очень важно.

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

const Book = () => { const [description, setDescription] = useState(''); const [title, setTitle] = useState(''); const [rating, setRating] = useState(''); const [id, setId] = useState(''); return <View> <Text>{title}</Text> <Text>{rating}</Text> <Text>{description}</Text> <Button onPress={() -> setDescription('some description')} /> <Button onPress={() -> setTitle('some title')} /> <Button onPress={() -> setRating('some rating')} /> <Button onPress={() -> setId('some id')} /> <HeavyWeightComponent bookId={id} /> </View> }

У нас есть компонент Book который содержит локальное состояние description, title, rating и id и также есть вложенный тяжелый компонент HeavyWeightComponent. При изменении любого из состояний произойдет перерисовка компонента Book и вложенных компонентов и дальше по цепочке, если у вложенных компонентов есть вложенные они также будут перерисованы. Другими словами, если мы нажмем на кнопку которая меняет рейтинг - произойдет перерисовка компонента Book и компонента HeavyWeightComponent, хотя данный компонент принимает в качестве пропса только id и нам не хотелось бы его перерисовывать. Как предотвратить его перерисовку?

Решение

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

1. Использовать HOC (Higher order component) memo из библиотеки react. Он позволяет избегать перерисовок компонента если пропсы не изменились. Для этого компонент HeavyWeightComponent необходимо переделать:

import { memo } from 'react'; const MemoizedHeavyWeightComponent = memo(HeavyWeightComponent) const Book = () => { const [description, setDescription] = useState(''); const [title, setTitle] = useState(''); const [rating, setRating] = useState(''); const [id, setId] = useState(''); return <View> <Text>{title}</Text> <Text>{rating}</Text> <Text>{description}</Text> <Button onPress={() -> setDescription('some description')} /> <Button onPress={() -> setTitle('some title')} /> <Button onPress={() -> setRating('some rating')} /> <Button onPress={() -> setId('some id')} /> <MemoizedHeavyWeightComponent bookId={id} /> </View> }

Необходимо импортировать memo из библиотеки react и далее создать мемоизрованную копию компонента и использовать ее. В данном случае мы избавимся от лишних перерисовок и получим ожидаемое поведение нашего тяжелого компонента. HeavyWeightComponent будет перерисовываться только при изменении свойства bookId куда мы прокидываем id из локального состояния компонента Book. Это хорошее, наглядное и понятное решение, но если переделать немного структуру компонента Book, мы можем избавиться и от использования memo.

const BookHeader = ({ id, title, rating }) => { const [description, setDescription] = useState(''); const [title, setTitle] = useState(''); const [rating, setRating] = useState(''); return <View> <Text>{title}</Text> <Text>{rating}</Text> <Text>{description}</Text> <Button onPress={() -> setDescription('some description')} /> <Button onPress={() -> setTitle('some title')} /> <Button onPress={() -> setRating('some rating')} /> </View> } const Book = () => { const [id, setId] = useState(''); return <View> <BookHeader /> <Button onPress={() -> setId('some id')} /> <HeavyWeightComponent bookId={id} /> </View> }

Мы можем разбить наш компонент Book на несколько более мелких компонентов, которые будут отвечать за свои области. В данном случае, мы можем создать компонент BookHeader и вынести туда все локальные состояния кроме id.

Теперь у нас есть 2 соседних компонента BookHeader и наш тяжелый компонент. При изменении любого из локальных состояний в BookHeader перерисовка будет происходить только в внутри самого BookHeader. А наш тяжелый компонент не будет перерисовываться и зависеть от BookHeader.

Вывод

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

Придерживайтесь правила: одная функция - один компонент

2. Кэширование и мемоизация

Принцип работы кэширования
Принцип работы кэширования

В react существует несколько хуков для добавления кэширования. Ранее мы немного познакомились с этим понятием на примере использовании HOC memo для кэширования компонентов.

useMemo

Хук для кеширования результатов вычислений, массивов данных. Если Ваши компоненты используют какие-то тяжелые вычисления, перебор массивов и изменения с помощью методов map, filter, reduce - то необходимо обязательно использовать useMemo. Таким образом, при перерисовке компонента, react будет брать закешированное значение результата вычислений и отдавать его мгновенно (если зависимые значения не изменились) вместо постоянного пересчета на каждой перерисовке компонента.

Все вышеописанные сущности которые я использую в компонентах всегда пропускю через useMemo.

useCallback

Хук для кэширования функций. При каждой перерисовке компонентов, react пересоздает все сущности которые находятся внутри компонента. Сюда относятся переменные, объекты, функции, массивы, элементы и т.д. Для того, чтобы избежать пересоздания функций мы должны использовать useCallback. Я рекомендую его использовать для всех функций которые Вы создаете внутри компонентов.

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

memo

HOC для кэширования компонентов. Помогает избежать ненужных перерисовок если значения пропсов компонента не изменились. В качестве второго аргумента он может принимать условие для перерисовки компонента. Например, если в качестве пропса приходит объект с некоторой вложенностью, мы можем задать условие, что при изменении определнных свойство этого объекта перерисовывать компонент, а во всех остальных случаях нет. Очень полезно, особенно, для использования в сетках и списках. Я активно использую memo в большинстве компонентов которые имеют пропсы.

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

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

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

Эти 3 сущности кэширования и мемоизации помогут в значительной степени увеличить производительность Вашего приложения и избавиться от ненужных перерисовок. Мобильные приложения, в отличие от веб или десктопных приложений, более чувствительны к перерисовкам т.к. существует большое количество слабых мобильных устройств, а это значит мы не можем себе позволить впустую использовать лишние перерисовки и потреблять ресурсы устройств. В моем случае, после внедрения вышеописанных методик я увеличил отлик некоторых функций в разы. Если раньше открытие меню со статусом книги составляло ~3 секунды (при худшем сценарии, когда было загружено большое количество книг и запускалась перерисовка всех элементов на досках), то теперь это время менее чем ~0.5 секунды, разница в ~6 раз.

3. Оптимизация гридов/списков

Пример грида в BookDesk
Пример грида в BookDesk

Сегодня сложно себе представить мобильное приложение, которое не использует гриды, списки в том или ином виде. Мое приложение BookDesk построено и базируется на основе гридов и списокв. У нас есть несколько досок с которыми можно работать одновременно, и все эти доски это гриды. А это значит, что у нас всегда подгружено некоторое количество книг на каждой из этих досок. Оптимизации гридов следует уделять особое внимание.

Для работы с гридами React Native предоставляет компоненты FlatList и SectionList. Они работают на базе VirtualizedList и позволяют подгружать сущности по мере необходимости, имеют неплохую оптимизацию и функциональность. Однако, их необходимо дополнительно оптимизировать. React Native дает рекомендации по оптимизиации своих гридов. Я не буду подробно останавливаться на каждом пункте из этой статьи, т.к. эта тема стоит отдельного материала.

Скажу лишь самое важное - необходимо обязательно использовать кэширование в виде memo для item компонента и useCallback для всех функций типа renderItem, keyExtractor и т.д. чтобы избежать лишних перерисовок. Ну и конечно же использовать "keyExtractor" для оптимизации, это свойство добавляет уникальный ключ для каэждого элемента и при изменении/удалении элементов из грида React Native легко сможет определить с каким элементом произошли изменения и быстро все перерисовать.

Использование функции map

В react веб-приложениях используется JSX для возможности использовать html внутри JS и привычным является использование функции map для отрисовки списков или чего-то из массивов. Но в React Native этого необходимо избегать, потому как все это дело надо оборачивать дополнительно в компонент ScrollView чтобы создать скролл. В этом подходе есть ряд минусов и сам React Native рекомендует использовать FlatList вместо map + ScrollView.

В своем приложении я использую все виды списков включая FlatList и SectionList. Их я предпочитаю использовать для небольших списков, где не нужно использовать виртульную подгрузку по скроллу.

FlashList

Для основных больших виртуальных списков с книгами я использую компонент FlashList от компании Shopify. Она использует React Native для построения своего мобильного приложения. Их приложение базируется на огромных списках с картинками и прочими элементами. Им необходима максимальная производительность и поэтому они разработали свой компонент для списков. Shopify сумели добиться лучшей производительности в сравнении с FlatList.

Сравнение производительности FlashList и FlatList
Сравнение производительности FlashList и FlatList

Я в этом лично убедился. Рендеринг, перерисовка и скроллинг списков работает безупречно и молниеносно, без фризов и пустых областей, все работает мгновенно. У Shopify, также, есть рекомендации по оптимизации. В отличие от FlatList во FlashList есть свойство "estimatedItemSize" обязательное к использованию и на котором базируется оптимизация всего списка. Это горизонтальная или вертикальная величина элемента списка (в зависимости от типа списка горизонтальный или вертикальный) которую можно получить в инспекторе. Также есть свойство "getItemType"которое необходимо для того чтобы определить тип элемента, может быть текст, картинка, объект. Я не буду все перечислять, сможете ознакомиться по ссылке выше, но во FlashList таких техник меньше, т.к. из коробки он уже хорошо оптимизирован.

Как и во FlatList не забывайте про использование useCallback для всех функций которые прокидываете в компонент FlashList а также memo для компонента элемента списка.

Совет! Не перегружайте компонет элемента списка локальными состояниями, тяжелыми вложенными компонентами или сложными вычислениями.

Если необходимо добавить локальное состояние, лучше создать маленький компонент и в нем определить состояние а его в свою очередь добавить в компонент элемента списка. И все дочерние компоненты необходимо оптимизировать с использование memo, useCallback и useMemo, не забывайте об этом важном правиле.

4. Библиотека Reselect для Redux

Принцип работы reselect
Принцип работы reselect

Как и хук useMemo, библиотеку Reselect можно использовать для создания мемоизированных селекторов для оптимизации дорогостоящих вычислений. Однако, в отличие от useMemo, это нужно использовать с Redux.

В своем приложении я использую Reselect для получения переработанных данных из redux, как и в случае с useMemo, где используются методы массивов map, filter, reduce и т.д. или какие-то сложные вычисления.

Reselect для кэширования результатов из redux.

5. Lazy Loading

Принцип работы Lazy Loading
Принцип работы Lazy Loading

Из коробки react позволяет загружать компоненты по требованию. Пока пользователь не перешел на определенный экран, react не будет загружать компоненты. Это отличная возможность, чтобы снизить время начальной загрузки приложения. Например, зачем нам сразу грузить все экраны если новый пользователь попадает на страницу Входа? Мы загружаем только страницу Входа а все остальные по мере необходимости. Точно также, если пользователь уже залогинен и при запуске попадает на страницу с досками книг, зачем ему загружать экраны Входа, Регистрации, Статистики, Профиля и т.д. которые он сейчас не использует. Это мощный инструмент оптимизации, который я советую использовать.

Мы можем импортировать функцию lazy из react и обернуть все наши компоненты в нее.

import { lazy, Suspense } from 'react'; const Auth = lazy(() => import('~screens/Auth')); const BookNote = lazy(() => import('~screens/Home/BookNote')); const Filtering = lazy(() => import('~screens/Home/Filtering')); ... <Suspense fallback={<Text>Loading...</Text>}> <Search /> </Suspense> ...

Используйте lazy как обертку для импорта компонента и в том месте где отображается компонент оборачивайте его в Suspense. Таким образом мы создаем оптимизацию для загрузки только нужных, в данный момент времени, компонентов.

6. Оптимизация картинок

Тяжело представить мобильное приложение без картинок. В своем приложении я использую в качестве формата для картинок - формат WebP.

WebP — это современный формат изображений, который обеспечивает превосходное сжатие без потерь. Используя WebP, веб-мастера и веб-разработчики могут создавать более мелкие и насыщенные изображения, которые ускоряют работу Интернета. Изображения WebP без потерь на 26% меньше по размеру по сравнению с PNG. Изображения WebP с потерями на 25–34% меньше сопоставимых изображений JPEG при эквивалентном индексе качества SSIM.

С официального сайта (Google)

Ко всему прочему я использую компонент FastImg для отрисовки и кэширования картинок. Официальная рекомендация этого компонента есть от команды React Native.

7. Оптимизация production сборки

Схема сборки
Схема сборки

Во-первых, я использую настройку Proguard для уменьшения размера сборки. Proguard — это инструмент, который может немного уменьшить размер APK. Он делает это, удаляя части байт-кода React Native Java (и его зависимости), которые ваше приложение не использует. Работает это только с Android приложениями. Как настроить.

Во-вторых я использую удаление всех логов из кода при помощи настройки. Вызовы конослей также кушают память и от них следует избавляться в production сборке.

8. Использование useNativeDriver в анимациях

useNativeDriver
useNativeDriver

React Native использует 2 потока: JS Thread и UI thread, подробнее можно почитать здесь. Запуск анимации в потоке JavaScript — плохая идея. Поток JS можно легко заблокировать, и это может замедлить работу анимации или не запустить ее вообще. Поскольку API Animated является сериализуемым, можно передать детали анимации в нативный код до начала анимации. Таким образом, нативные коды будут выполнять анимацию в потоке пользовательского интерфейса. Это гарантирует, что анимация будет работать плавно, даже если поток JavaScript заблокирован.

Animated.timing(opacity, { toValue: 1, duration: 500, useNativeDriver: true, }).start();

9. Использование Hermes движка

Hermes - это движок JavaScript с открытым исходным кодом, оптимизированный для React Native. Для многих приложений использование Hermes приведет к улучшению времени запуска, уменьшению использования памяти и уменьшению размера приложения по сравнению с JavaScriptCore. Hermes используется React Native по умолчанию, и для его включения не требуется дополнительной настройки.

10. Версии пакетов

Старайтесь периодичски проводить ревизии версий библиотек и зависимостей в файле package.json, которые Вы используете. Старайтесь использовать последние версии библиотек, особенно это касается react-native, react т.к. в новых версих улучшают оптимизацию и производительность. Старайтесь находить альтернативы использования функций из некоторых библиотек. В данный момент JavaScript предоставляет широкие возможности для работы с массивами, объектами, поэтому можете легко найти замену lodash, ramda библиотекам в виде нативного использования js функций. Прежде чем искать решения с помощью сторонних библиотек всегда старайтесь найти решение из коробки React Native или на JS, ведь использование дополнительных библиотек увеличивает размер Вашего приложения а также скорость его загрузки. Но если Вам необходимо установить пакет, смотрите на количество звезд, дату последнего релиза и количество недельных скачиваний и это даст понимание о популярности пакета и о целесообразности его использования.

Я придерживаюсь принципа: меньше пакетов - лучше!

И еще одна рекомендация, периодически проводить ревизию пакетов на предмет размера при помощи react-native-bundle-visualizer он поможет проанализировать все пакеты и выявить самые тяжеловесные.

11. Debugger

Используйте дебаггеры для анализа производительности приложения. Анализируйте перерисовки приложения, смотрите средние значение кадров в UI и JS thread. С помощью этого Вы сможете найти утечки памяти, ненужные перерисовки. В некоторых случаях могут происходить повторные и ненужные отправки запросов. Все это надо анализировать и исправлять чтобы добиться хорошей производительности.

Итог

Как я уже говорил, мне удалось улучшить производительность моего приложения в разы с помощью техник, описанных в этом материале.

Но работа над оптимизацией на этом не заканчивается, я постоянно в поиске различных вариантов и улучшений. Развиваю приложение методом проб и ошибок. Ведь, самый ценный опыт это опыт полученный собственным путем. Оптимизация приложения это очень кропотливая и важная работа. Если Вы примените все советы из данного материала, Ваше приложение станет работать быстрее, а в некоторых случаях - в разы быстрее.

Буду очень благодарен если Вы установите мое приложение для хранения прочитанных книг BookDesk и оставите отзыв и рейтинг!

Давайте делать оптимизированные приложения!

Всем спасибо!

44
11
1 комментарий

молодцом!

1
Ответить