Под капотом анимаций в React Native. Часть 2/2: Reanimated and JSI

от автора

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

Это вторая и последняя статья из серии как работает React Native (RN) под капотом, на примере анимаций. Призываю сначала ознакомиться с первой: Под капотом анимаций в React Native. Часть 1/2: Animated and Bridge

В предыдущих сериях

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

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

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

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

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

Нативный рендер (useNativeDriver: true) частично решает этот вопрос. С его помощью можно красиво анимировать UI, даже когда JS лагает. Но, к сожалению, это не распространяется на свойства, которые вызывают перерисовку макета.

Мы остановились на том, что с помощью Animated API нельзя полностью реализовать нашу задачу, а именно изменять высоту карточек плавно, несмотря на высокую нагрузку в JS-потоке. Сегодня будем разбираться, может ли Reanimated помочь нам решить эту проблему. Поехали!

Reanimated

Это второй инструмент для создания анимаций в RN, который можно найти, немного погуглив. Изначально он написан коммьюнити, но сейчас его в основном поддерживают инженеры из Software mansion.

Разработчики Reanimated в качестве своей мотивации приводят некоторую ограниченность работы анимаций на основе Animated и Bridge:

  • Если использовать useNativeDriver:false, наши анимации могут лагать, т.к. мы полностью завязаны на JS thread.

  • Взаимодействия с юзером через встроенную систему жестов может лагать по тем же причинам.

  • Если же мы используем useNativeDriver:true, то не можем анимировать никакие свойства макета.

Если как-то обобщить, то управлять анимациями из JS очень удобно, т.к. у нас контроль над каждым кадром на этой стороне. Но реализовав это через Bridge, мы каждый раз стреляем себе же в ногу.

Команда Reanimated предлагает следующее решение — вынести все анимации из основного JS-потока (далее RN JS) в новый JS-поток (Rea JS), который будет связан с UI-потоком синхронно. Пока ничего не понятно, но звучит очень интересно.

Давайте вернемся к задаче и попробуем реализовать ее на Reanimated. Начнем с параллакса картинки.

  1. C помощью хука useSharedValue создадим переменную, в которой будет храниться положение скрола. К нему можно относиться как к аналогии Animated Value. И с помощью useAnimatedScrollHandler мы будем обновлять наши значения скролла и свяжем значение скрола с нашей переменной

  2. Потом с помощью хука useDerivedValue мы будем вычислять значение масштабирования и положения картинки.

  3. Анимировать мы всё это будем с помощью useAnimatedStyle, в который передадим наши анимированные значения.

  4. Затем этот объект стиля передадим в компонент картинки и посмотрим что получилось.

const scrollY = useSharedValue(0) const onScroll = useAnimatedScrollHandler((event) => {   scrollY.value = event.contentOffset.y }) const scale = useDerivedValue(() => {   let diff = scrollY.value / heightTop   if (diff > 0) {     diff = 0   }   return 1 - diff }) const translateY = useDerivedValue(() => {   let diff = scrollY.value / 2   if (diff > 0) {     diff = 0   }   return diff }) const style = useAnimatedStyle(() => {   return {     transform: [       {         translateY: translateY.value,       },       {         scale: scale.value,       },     ],   } })  return (   <Animated.FlatList     data={data}     renderItem={renderItem}     keyExtractor={(item) => item.id}     onScroll={onScroll}     scrollEventThrottle={16}     contentContainerStyle={styles.contentContainer}     ListHeaderComponent={       <Animated.Image         style={[styles.view, style]}         source={{           uri: 'https://media.photoparad.com/uploads/0/medium/571671a5-535d-5700-beb3-c0b30427485f.jpg',         }}       />     }   /> )

Идем дальше: реализуем скрытие и открытие карточки, так называемый эффект аккордеона. Похожим образом создаем значение, где будем хранить высоту карточки, и анимируем его стили компонента с помощью useAnimatedStyle. Чтобы изменение было плавным, используем хук withTiming.

  const isHide = useSharedValue(false)   const toggleText = useSharedValue(isHide.value ? 'open' : 'close')   const maxHeight = useSharedValue(isHide.value ? StartSize : MaxSize)   const abooutStyle = useAnimatedStyle(() => ({     maxHeight: withTiming(maxHeight.value, {       duration: 500,     }),   }))   const toggleMore = () => {     'worklet'     const newIsHeight = !isHide.value     isHide.value = newIsHeight     maxHeight.value = newIsHeight ? StartSize : MaxSize     toggleText.value = newIsHeight ? 'open' : 'close'   }

У внимательного читателя тут будет вопрос: зачем я создал переменные isHide и toggleText с помощью useSharedValue? Это не случайно и попозже мы сюда вернемся. Нам также надо пометить функции волшебным словом worklet, которые должны участвовать в анимации.

Смотрим, как все это работает и заранее слегка нагрузим JS-поток.

Выглядит хорошо: видим что UI thread не тормозит, при том, что JS FPS держится на уровне 28 кадров. Давайте попробуем пойти дальше и сильне нагрузим JS-поток.

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

Заглядываем под капот

Чтобы разобраться, вспомним архитектуру React Native. Основная проблема в том, что он работает асинхронно. Это очень важно, потому что большое количество сообщений проходит через Bridge.

Все они требуют серилизации, десерилизации, парсинга… и это сопровождается накладными расходами на вычисление этих операций. Также асинхронная природа Bridge не позволяет вызывать нативные методы платформы, например, запрос на геопозицию в синхронном режиме. Когда мы вызываем любой нативный метод из JS, любой ответ является промисом. А так как у нас коммуникация асинхронная, мы не можем перерисовывать наш UI синхронно.

JSI

Давайте посмотрим, как с аналогичной ситуацией у нас справляется браузер. Браузер предоставляет Web API, который реализует методы, не являющиеся частью движка JS (fetch, console.log и т.д). Если попробовать написать в консоле разработчика любой из этих методов, например console.log, то мы увидим:

ƒ log() { [native code] }

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

Вот было бы здорово иметь что-то такое в рамках React Native… На самом деле Facebook это сделали еще в 2018 году. Они анонсировали JSI(JavaScript Interface), который должен прийти на замену Bridge и решить главные его проблемы.

Давайте посмотрим на новую архитектуру:

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

Все взаимодействия между потоками синхронные в отличие от старого бриджа
  1. Начнем с Codegen. Он решает проблему взаимодействия между JS и остальными потоками. Чтобы нам сделать это взаимодействие синхронным, нужно, чтобы мы могли предоставить надежный и предсказуемый интерфейс со стороны JS. По сути нам нужен статически типизированный JS, на типы которого мы сможем опираться.

    Codegen — это средство автоматизации, которое создает эти типы для нашего кода. Он парсит JS, ищет определенные шаблоны и создает для них интерфейсы на C++. Благодаря этому RN может связать наш JavaScript и UI-потоки синхронно, потому что теперь они опираются на одни и те же типы. Мы можем их не перепроверять в ходе передачи данных между потоками.

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

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

  4. И сам JSI. Именно он обеспечивает синхронное взаимодействие между всеми потоками — UI, Yoga, Fabric, Turbo Modules. Это реализуется за счет создания так называемых Host-объектов. Они создаются на одной стороне и к ним есть доступ на стороне JavaScript, поэтому мы можем синхронно их читать, писать, а также вызывать нативные методы.

Теперь, когда мы хотим что-то изменить из JavaScript, мы просто вызываем нужный нам метод, пересчитывается макет и все происходит в том же кадре. Ура!

Но, есть нюанс…

Выше мы разобрали то, как архитектура должна выглядеть. И я бы не заводил разговор про Bridge, если бы он перестал быть актуальным. На самом деле сейчас, когда мы используем Reanimated, архитектура выглядит примерно так:

  • У нас есть Bridge, через который React Native в основном работает. Это касается и встроенных RN-компонентов, а также тех компонентов, которые написаны сообществом. И RN JS где крутится все что мы пишем на JS.

  • И у нас есть Reanimated, который полностью написан используя JSI. Он частично написан на плюсах, и благодаря всему этому все анимации обновляют UI-поток синхронно. Во время инициализации модуля создается Rea JS-поток, в котором происходит расчет всех анмиаций.

Вернёмся к нашему примеру. Мы остановились на экране, когда у нас JS-поток был полностью мертвым, но все работало прекрасно.

Работая с Reanimated, все, что касается анимаций мы создаем используя хуки из библиотеки. Внутри, в основном, все построено на sharedValue и worklet‘ах. Мы уже выяснили что Reanimated написан на JSI. Но как вообще что-то попадает из RN JS в Rea JS, ведь мы пишем код не указывая поток и ничего напрямую не передаем?

Объясняю: при запуске нашего кода на маунт все отправляется через Bridge в UI, там создаются анимированные значения, и уже они через JSI отправляются в Rea JS. Дальше все управление анимацией осуществляется как бы из JS, но из второго потока, который не занят ни бизнес логикой, ни расчетами реакта, а сосредоточен только на наших анимациях и делает это синхронно, через JSI. При этом мы можем управлять значениями анимаций и из основного RN JS потока, но тогда все будет проходить через Bridge.

Но как Reanimated понимает что копировать в Rea JS? И каким образом нам поместить туда какой-то код? В начале функции toggleMore мы указывали это волшебное слово — worklet. Им Reanimated просит начинать все функции, которые нам нужно переместить из нашего основного RN JS-потока в Rea JS-поток. Согласно документации во второй поток копируется функция и лексическое окружение, в котором она объявлена. Именно так мы полностью перенесли всё управление во второй поток и добились максимальной плавности в нашем примере.

Еще я говорил, что расскажу, зачем я объявил isHigh и toggleText через useSharedValue. Они нужны для того чтобы продублировать эти значения во втором потоке, и чтобы мы могли менять текст Open и Close на кнопках.

Исследуем границы Reanimated

Но давайте попробуем узнать, на что вообще способен Reanimated?

Возьмем очередной абстрактный пример, где будем просто анимировать и изменять ноду. В фокусе: свойства transform, width и height — чтобы задеть все аспекты нашего layout.

const endAngle = useSharedValue(generateEndAngle()) const size = useSharedValue(40) const translateX = useSharedValue(startValue) const opacity = useSharedValue(0)  const animStyle = useAnimatedStyle(() => {   return {     transform: [       {         rotateZ: `${endAngle.value}deg`,       },       {         translateX: translateX.value,       },     ],     width: size.value,     height: size.value,     opacity: opacity.value,   } })  size.value = withRepeat(withTiming(20, {duration: 6000}), -1) opacity.value = withDelay(   500,   withRepeat(     withSequence(       withTiming(1, {duration: 1500}),       withTiming(0, {duration: 4500}),     ),     -1,   ), ) translateX.value = withRepeat(   withTiming(     startValue + Math.abs(Math.sin(endAngle.value) * diffValue),     {duration: 6000},   ),   -1, )

Будем увеличивать количество анимированных нод и смотреть как введет себя приложение. Начнем с 10. Запустим и выведем рядом UI-монитор с fps.

10 нод

10 нод

Увеличим до 100.

100 нод

100 нод

Пока полет нормальный, 100 элементов анимируются хорошо. И у каждого из них изменяется ширина, высота, его положение и opacity.

300 нод

300 нод

При количестве в 300 нод видно, что общая картина немного проседает и уже заметны небольшие лаги. Но поднимем до 1000.

1 000 нод

1 000 нод

Да, у нас начало все откровенно лагать — но такое количество анимированных элементов на реальном проекте представить сложно.

Reanimated: выводы

В итоге Reanimated работает синхронно, под капотом использует JSI. C его помощью можно реализовать 99,9% всех анимаций, которые вам получится придумать. В нашем примере мы вынесли все анимации из главного потока в Rea JS, чтобы не забивать главный поток и избежать лагов. Получается, главный поток не мешает делать анимации, а анимации не мешают исполняться бизнес-логике — все счастливы.

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

Ну и я оставлю небольшую табличку, которая может помочь вам выбрать инструмент.

Какой инструмент выбрать?

Animated

Reanimated

Для простых анимаций

Когда надо сделать анимацию, которая не перерисовывает макет: transform, opacity, borderRadius…

(!) Всегда надо использовать useNativeDriver: true

Если свойства, которые надо анимировать перерисовывают макет

Если надо реализовать сложную анимацию, где разные свойства зависят друг от друга

Когда используем жесты

Немного мыслей после: Reanimated делает все расчеты на стороне JS и имеет прямой и быстрый способ управлять значениями макета в UI-потоке. Animated с useNativeDriver же просто отправляет граф, описывающий анимацию, для расчета на UI и дальше он обрабатывается там. У меня есть теориия: возможно, из-за меньшего количества накладных расходов Animated будет иметь лучшую производительность и наверняка меньшее потребление памяти (речь только про кейсы, которые можно написать с useNativeDriver:true). Как-нибудь доберутся руки, я проведу эксперимент и вернусь с результатами.

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/750000/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *