Привет! Меня зовут Евгений Прокопьев, я по-прежнему старший инженер-разработчик в СберМаркете. Пишу на 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-менеджеров. 

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