Привет! Меня зовут Евгений Прокопьев, я старший инженер-разработчик в СберМаркете. На React Native (далее просто RN) я пишу уже больше 6 лет и очень люблю создавать красивые анимации.

В интернете часто сталкиваюсь с мнением, что на RN невозможно сделать красивые и стабильные анимации: их трудно делать и они ужасно лагают. По моему опыту всё обстоит совсем не так — вопрос именно в том, как их готовить. Хочу поделиться опытом, что сделать, чтобы у вас анимации не лагали, и по возможности восстановить репутацию React Native в глазах комьюнити.

Я уже делал обзор на 6 инструментов для анимаций в RN, а в этот раз хочу углубиться в работу Animated и Reanimated под капотом. Этот материал выйдет в двух частях и я преследую цель объяснить, почему анимации в React Native работают именно так, как работают. Это поможет вам более осознанно выбирать инструмент под ваши задачи и умело лавировать грабли, на которые можно наступить, работая с RN.

Если вы плохо знакомы с историей RN или специализируетесь на Web, а сюда заглянули из любопытства, предлагаю начать с небольшой исторической вводной. Если нет, просто не раскрывайте спойлер.

Краткая история сотворения React Native

В 2012 году на одном из интервью Марк Цукерберг сказал, что самой большой ошибкой, которую Facebook допустила, была слишком большой упор на веб-разработку, а не на нативные технологии. Это было сказано в контексте самого приложения Facebook и Марк имел ввиду использование большого количества HTML кода внутри. Тогда же он пообещал разработчикам предоставить новый инструмент, который принесет новый опыт мобильной разработки.

Вскоре приложение полностью переписали на нативку, собрали восторженные отзывы, и где-то в недрах компании человек по имени Jordan Walke (да, это тот самый Jordan Walke, который написал React) думал, как выполнить обещание о новом опыт мобильной разработки, и в 2013 году придумал способ рендерить нативные iOS-элементы из JavaScript-потока. Затем компания организовала хакатон, чтобы улучшить эту технологию, и в 2015 году впервые вышел React Native. К этому времени Facebook частично использовал RN под капотом, а Ads Manager был полностью написан на нем.

Предложенная идея была простой — берём React и просто прикручиваем туда свой механизм рендера. Выглядит просто, что может пойти не так?

Я разрабатываю на RN 6 лет и постоянно вижу следующую картину. Есть продуктовая компания или студия, у которой веб-разработчики прекрасно знают React. Компания решает сделать мобильное приложение и выбирает React Native, потому что это кажется простым решением, сделаем все сами, тут же React ну и вот это вот все. В начале всё идет прекрасно, получается быстро добавлять функционал и даже где-то переиспользовать существующую на вебе логику. Но потом задачи усложняются, приложение разрастается, в нём появляются анимации, и процесс становится уже не таким безобидным, подкидывая всё новые сложности. И тут разработчики идут писать, что RN не оптимизирован, работает плохо, анимации нормально не сделаешь и вообще он до сих пор в бетте.

Я думаю, что в основном это происходит из-за того, что Facebook позиционирует его так, чтобы показать всем, что RN очень похож на React и писать на нём можно как под веб, не углубляясь что происходит внутри. Но на деле реальность заставляет писать код всё-таки учитывая что это больше Native, чем React.

Мифы об анимациях в React Native

Итак, неужели на RN реально незозможно сделать анимации, которые не лагают? Ловите контраргумент. Всё сделано на React Native и работает на частоте 60 FPS.

Пойдем разбираться как же создавать такие анимации и начнем с постановки задачи. Реализуем экран с двумя типами анимаций:

  1. Параллакс картинки (картинка немного смещается и масштабируется) — здесь мы посмотрим, как React Native работает с трансформ-свойствами стилей.

  2. Эффект аккордеона (раскрытие карточки в интерфейсе) — тут мы проверим, как React Native работает со свойствами, которые заставляют перерисовываться макет.

Чтобы было интереснее, давайте представим, что мы совсем не имеем опыта в создании анимаций на RN, и таким образом пощупаем различные варианты решения задачи.

Итак, погуглив, как вообще можно реализовать анимации на RN, первым делом мы натыкаемся на...

Animated API

Это библиотека, которая идет с React Native из коробки, и, позволяет строить «мощные плавные анимации» (это не мои слова, они сами написали так в readme). Звучит как то, что нам нужно!

Начнём с параллакса картинки. Сам эффект полностью завязан на позицию скролла. Поэтому нам надо определить переменную, где мы будем хранить эту позицию и с помощью Animated.event связать ее с самим положением реального скролла. Дальше с помощью математических функций (все из той же библиотеки Animated) нужно рассчитать масштаб картинки и её смещение. Ну и все стили, которые у нас получились передадим в компонент, импортируемый из Animated. Смотрим.

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

Запускаем. Тут тоже все работает хорошо. Но не просто так же все хейтят работу с анимациями. Давайте попробуем посмотреть загруженность потока в Perfomance monitor'е. На этом графике JS FPS показывает, сколько раз в секунду у нас вызывается requestAnimationFrame, ну и соответственно есть ли пропуск кадров. И у нас их нет!

Но! Приложение находится в несколько лабораторных условиях — у нас один экран и нет никакой логики, а весь поток занимается обработкой только нашей анимации. В реальных приложениях так не бывает: в них есть запросы к серверу, к базе данных, обработка различных нажатий, клиентской логики, вообще все что нам в голову взбредет... И это я еще не говорил про GC и сами расходы на работу React. В общем я хочу сказать, что в обычном RN-приложении невозможно добиться стабильных 60 JS FPS. Поэтому давайте нагрузим наш JS-поток и посмотрим, как справится наша анимация.

И тут появляются они...

Лаги

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

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

  1. JavaScript-поток, в котором реализована вся бизнес логика, React со своей магией и вообще всё, что мы пишем как JS-разработчики, включая анимацию. 

  2. Shadow thread (также известен как Background thread) — там работает Yoga. Это фреймворк, написанный все тем же Facebook на С++. Он берет наши flexbox-стили, которые ему отдает React, и преобразует их в Bounding Box, который дальше отдаёт в UI-поток для отрисовки элементов.

  3. UI-поток (или Main thread) написан уже на нативных языках. Для Android это будет Kotlin или Java, для iOS — Swift либо Obj C. И он реализует 2 интерфейса:

    • Нативные модули предоставляют доступ к нативному API платформы: доступ к геопозиции, фотографиям из галереи и др.

    • UI-модули отвечают за отрисовку всех компонентов.

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

  1. У нас возникает событие от пользователя в UI-thread, отправляется через bridge в JS.

  2. Ловим и обрабатываем событие от пользователя (event) в JS-thread.

  3. Начинается стадия render, где происходит обработка всего нашего кода.

  4. Затем разница между старым и новым состоянием передается в Shadow thread и отправляется оно конечно через bridge.

  5. В нём Yoga выполняет стадию layout, где вычисляется новый макет.

  6. На стадии commit в Shadow thread старый макет заменяется новым.

  7. Этот новый макет отправляется уже в UI-thread опять же через bridge

  8. Наконец стадия mount, тут UI-thread отрисовывает наши изменения.

И все это должно происходить за 1/60 секунды (если девайс работает с 60 fps). На первый взгляд все это выглядит не очень производительно, но на самом деле, пока мы не доходим до анимаций, всё работает достаточно хорошо.

Теперь давайте посмотрим, что пошло не так с анимацией в нашем примере.

Из-за тяжелых операций стадия рендера затягивается. В следствии отправка из JS thread нового состояния в Shadow thread откладывается, и на UI, естественно, тоже. Как итог мы видим пропуск в отрисовках нового состояния в UI-треде поэтому и появляются лаги.

Давайте немного подытожим, что мы узнали:

Animated использует под капотом requestAnimationFrame. Все взаимодействие между потоками работает через Bridge. Как итог: анимации полностью завязаны на JS-поток. Отсюда и возникает боль.

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

Animated с useNativeDriver: true

Снова немного погуглив, можно найти пост в блоге RN, о том, как они решили эту самую проблему. Они предлагают использовать для анимации так называемый нативный драйвер. Говорят, можно просто добавить useNativeDriver: true в конфиг анимаций и всё заработает.

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

Мы видим, что JS-поток немного загружен, показывает 30 fps, но анимация работает не замечая этого.

Второе — где мы вызываем изменение высоты наших карточек. Тоже добавим useNativeDriver: true. Запускаем пример и получаем ошибку.

В ней написано, что свойство maxHeight недоступно для анимации с помощью нативного драйвера. Чтобы разобраться, почему так, посмотрим на сообщения, которые ходят между UI и JS потоками.

Ниже подписываю, что происходит под каждым номером, указанным на картинке
Ниже подписываю, что происходит под каждым номером, указанным на картинке

Здесь мы видим, что на маунт компонента из JS в UI отправляется много сообщений. Вызывается NativeAnimatedModule, который, судя по названиям, создаёт и привязывает анимированные ноды друг к другу. Эти ноды создаются буквально на всё:

(1) Создаются переменные стиля для изменения масштаба, положения по оси Y и задаются для них сразу параметры интерполяции.

(2) Создаётся переменная, где хранится значение скрола с начальным значением 0.

(3) Здесь цифра 2 используется в вычислениях и анимированная нода для неё не создаётся нами напрямую, но под капотом NativeAnimatedModule всё-таки её генерирует. Это необходимо, потому что в графе зависимостей у анимированных нод могут находиться только анимированные ноды.

(4) Значение положения по оси Y, которое является результатом деления положения скрола на 2.

(...) Дальше все реализуется похожим образом, все достаточно комфортно читается и расшифровывается.

(n-1) Здесь создаётся обработчик анимированного события, который будет привязан к скроллу.

(n) Создаётся сам анимированный компонент.

Говоря простыми словами, React Native создает граф зависимостей на стороне JavaScript, в котором связана вся анимация, которая должна происходить с помощью useNativeDriver: true и за один раз весь граф отправляется в UI поток (после этого JS о состоянии анимации ничего не знает). 

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

JS-поток создает все значения и отправляет их в UI thread. Потом у нас возникает очень долгая стадия рендера, но при этом анимация отрабатывает хорошо, интерфейс плавно реагирует. Это происходит потому, что мы при монтировании один раз рассчитали весь граф и дальше передали управление в UI thread. В итоге стадия mount каждый раз вызывается и в ней есть все актуальные значения стилей, которые не зависят от расчетов в JS.

Остаётся вопрос: почему нативный реднер не работает со свойством maxHeight? Разработчики из команды RN в конце поста с анонсом новой фичи, написали и про ограничения. А конкретно, что она не работает со свойствами, которые перерисовывают макет. Давайте еще раз посмотрим на диаграмму, которая была выше.

Из-за долгой стадии рендера, у нас ничего не передается из JS thread в Shadow thread, а именно Yoga занимается тем, что рассчитывает новые положения и размеры элементов (по сути перерасчет нашего макета). Получается что мы до сих заблокированы JS-потоком для реализации анимаций, завязанных на свойства макета. Именно поэтому мы не можем анимировать высоту, ширину и подобные значения.

Хороша новость: мы научились анимировать наш UI и это работает, даже когда JS лагает.

Плохая новость: к сожалению, не все свойства.

Кажется, теперь можно не так сильно хейтить RN. Но анимировать высоту нам всё ещё нужно научиться. Гугл подсказывает, что есть библиотека Reanimated. Но об этом — во второй части статьи, которая выйдет примерно через неделю-две.

Tech-команда СберМаркета ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на  YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров. 

Комментарии (0)