Всем привет! Меня зовут Евгений Прокопьев, я мобильный разработчик в СберМаркете. Более пяти лет я работаю с React Native и моя любовь — это красивые анимации: нравится встречать их в приложениях в роли юзера и люблю их делать с позиции разработчика (особенно когда всё сразу получается и ничего не лагает). В этой статье хочу рассказать, с помощью каких инструментов можно добиваться качественных анимаций на React Native.

Начну с Animated и Reanimated, посмотрим, как они работают внутри. Затем расскажу, что можно делать с библиотеками SVG, Lotte, OpenGL и Skia, для каких задач каждая из них подходит лучше всего. Статья не является обзором, а исследованием работы каждого инструмента под капотом, но в некоторые инструменты я постарался копнуть достаточно глубоко. Поехали!

Почему анимации — это важно?

Если коротко, то ради перфоманса. Юзеры ожидают от приложения определённого поведения. Например, когда появляется модалка, что её можно будет смахнуть вниз. Если поведение приложения не соответствует ожиданиям, мы можем расстроить пользователя и он может уйти и не вернуться. А ещё бывает, что красиво нарисованные дизайнерами самобытные интерфейсы могут запасть в душу, вызвать вау-эффект и повысить лояльность. Так что анимации — далеко не наименее важная часть приложения :)

Animated

Начнем со встроенной в React Native библиотеки Animated. Она представляет из себя набор методов для работы с анимациями. К примеру, мы хотим изменить прозрачность блока с нуля до единицы — это можно сделать с помощью следующего кода.

const MyComponent = () => {
  const animationValue = useRef(new Animated.Value(0)).current
  const run = () => {
    Animated.timing(animationValue, {
      toValue: 1,
      duration: 2000,
    }).start()
  }
  
  render (
    ...
  )
}

На видео видно, что, как только JS-поток фризится (в сером квадратике -2fps), анимация тоже останавливается — это не дело. Давайте посмотрим, почему так происходит.

Как это работает?

React Native изначально проектировался по такому принципу: есть JavaScript тред, который управляет всем, что находится в нативке. При старте анимации JS-движок ждёт следующего вызова requestAnimationFrame, потом рассчитывает значение, которое должно быть в следующем кадре, ищет ноду, которой надо его присвоить и через метод setNativeProps прокидывает в UI тред. Здесь самой дорогой операцией является сериализация и парсинг значений, чтобы передать параметры из одного треда в другой. Помимо этого JavaScript должен исполнять всю нашу логику, обрабатывать входящие и исходящие запросы. Чтобы анимация выглядела хорошо, надо чтобы JS работал на частоте 60fps, а на это полагаться нельзя.

Изначальная схема работы animated API
Изначальная схема работы animated API

Ребята из React Native это понимали, со временем они доработали стандартную библиотеку и добавили возможность запускать анимации в UI-потоке. Попробуем уже знакомый пример: запускаем анимацию, JavaScript-тред полностью фризится (все те же -2fps), но анимация работает как надо.

const MyComponent = () => {
  const animationValue = useRef(new Animated.Value(0)).current
  const run = () => {
    Animated.timing(animationValue, {
      toValue: 1,
      duration: 2000,
      useNativeDriver: true,
    }).start()
  }
  
  render (
    ...
  )
}

Происходит это потому что они убрали ненужную работу в JavaScript-треде и перекинули часть логики в UI-тред. Подробнее: теперь при старте анимации  JS рассчитывает граф описывающий всю анимацию (как между собой соотносятся все значения и какие интерполяции необходимо произвести) и всё это отправляет за один раз в UI-тред. После этого совершенно неважно, что будет происходить в JavaScript, анимация пройдёт без лагов.

Новый вариант работы animated API
Новый вариант работы animated API

Если всё так хорошо, зачем нам другие библиотеки? Всё просто: если мы попробуем анимировать (например, свойство width), то получим ошибку в dev-режиме, а если это будет прод, мы получим краш.

Посмотрим, что случилось, пойдем в исходники и увидим, что в файле react-native/Libraries/Animated/NativeAnimatedHelper.js указаны определенные свойства, которые поддерживаются для нативной анимации, а также что useNativeDriver не поддерживает любые свойства, которые заставляют макет перерисовываться. Т.е. Не меняют ширину/высоту/отступы.

Дословно в исходниках

Styles allowed by the native animated implementation.

 In general native animated implementation should support any numeric or color property that doesn't need to be updated through the shadow view hierarchy (all non-layout properties).

В итоге

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

Плюсы и минусы

Плюсы:

  • Работает из коробки

  • В некоторых случаях нативный перфоменс

  • Работает с событием скролла

Минусы:

  • Очень плохой перфоменс без использования useNativeDriver

  • Неудобно создавать анимации с несколькими состояниями и условиями

  • Не работает useNativeDriver с встроенной системой жестов

Что нам предлагает Reanimated?

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

Библиотека предлагает использовать SharedValue вместо Animated.Values и помечать некоторые функции магическим словом worklet.

Давайте попробуем написать простой пример.

const MyComponent = () => {
  const animation = useSharedValue(0)
  const run = () => {
  	‘worklet’
  	animation.value = withTiming( 1, {
    	duration: 2000,
    })
  }
  const tap = Gesture.Tap().onStart(() => { 
    anim() 
  })
  ...
  render (
  	...
    <GestureDetector gesture={tap}>
      ...
    </GestureDetector>
  )
}

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

Как это работает?

Reanimated, в отличии большинства похожих библиотек, не стали уходить от управления анимациями на стороне JS и элегантно решили проблему фриза потока. При старте, Reanimated создает еще один JS-поток, который связан с UI-потоком через JSI. JSI (javascript interface) — это новый способ общаться между потоками. Он написан на плюсах и работает в синхронном режиме. Получается, у нас есть поток, в котором мы можем синхронно обрабатывать все анимации, значит main JS-тред можно полностью от этого освободить. Но не совсем.

Мы вызываем создание SharedValue в главном JS-потоке, но само значение создается в потоке UI и затем Reanimated предоставляет нам ссылку на него в главном JS-потоке и Reanimated JS-потоке. Это даёт нам возможность управлять анимацией из любого JS-потока. Особенность в том, что JS-поток, созданный Reanimated связан с UI синхронно через JSI, значит любые изменения в нем будут сразу отработаны. Но мы также можем изменять анимированные значения из главного JS-потока, они будут обработаны асинхронно, через bridge, это сделано для решения проблемы параллельного запуска анимаций из разных потоков.

Если мы хотим завязаться на какие-то жесты, можем это сделать полностью в UI-потоке, и тогда это будет синхронно (спасибо worklet'ам) или, если мы хотим запускать анимации из JS, можем запускать их асинхронно, но они всё равно будут попадать в UI-поток, а запуск будет асинхронным.

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

Этот пример работает так хорошо (плюет на полностью замороженный JS-тред), потому что абсолютно всё происходит в Reanimated JS-потоке. В код выше я добавил GestureHandler для того, чтобы вызов функции run тоже перенести в UI-поток (не является best practices).

Так когда использовать Reanimated?

Если коротко, то во всех случаях, когда нам не подходит Animated API, то есть когда мы хотим анимировать свойства лейаута (например, ширину). В этом случае будет анимироваться нативно или у нас сложные взаимосвязи между анимированными значениями и нам требуется больше контроля.

Плюсы и минусы

Плюсы:

  • Нативный перфоменс

  • Прекрасно работает с жестами

  • Работает со всеми свойствами, можно даже реализовать текст-маску

  • Прозрачная работа с анимациями любой сложности

Минусы:

  • Нет совместимости с Animated API

Библиотека RN SVG

Что делать, если дизайнер нарисовал анимацию, где есть несколько левитирующих объектов, а нам надо это реализовать? Мы можем взять RN SVG и уже знакомый Reanimated.

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

useEffect(() => {
	float.value = withRepeat( withSequence(
		withTiming(1, {duration: 2000}),
		withTiming(-1, {duration: 2000}),
  ), -1, true)
}, [girlFloat])
const animateStyle = useAnimatedStyle(() => ({
  transform: [{
    translateY: interpolate( 
      float.value, 
      [-1, 1], 
      [-10, 10]
    ),
  }]
}))

return(
  <AnimatedSvg 
    style={animateStyle} 
    …
  >
)

Но это не всё, что можно делать с помощью SVG. Например — можно с его помощью можно создавать интерактивные интерфейсы

Видно, что всё работает хорошо. Дело в том, что с помощью sharedValue из Reanimated можно изменять числа, строки, объекты, массивы и булево значение. В этом примере нить представлена в виде строки svg, который мы на лету рассчитываем по какой-то нашей логике и в зависимости от нее обновляем. Все это работает в UI потоке.

Когда использовать?

На SVG обычно реализуют радиальные прогресс-бары, которые очень любят фитнес приложения, можно делать разные графики при условии, что там нет большой интерактивности.

Плюсы и минусы

Плюсы: 

  • Полное управление

  • Очень мало весит

Минусы:

  • Иногда двойная работа: анимацию делает дизайнер, потом разработчик

  • Сложный дизайн бывает трудно повторить

Lottie

Такой подход к созданию анимаций многих не устраивал, и Hernan Torrisi, наверное, был не доволен больше всех. Поэтому в 2015 году он подумал, что раз есть After Effects, которым уже давно пользуются дизайнеры и который скриптуется на JavaScript, почему бы не сделать утилиту, чтобы мы могли экспортировать эти анимацию из After Effects и запускать ее в браузере.

Так он сначала создал плагин Bodymovin для экспорта анимаций в JSON. Потом он сделал плеер для браузера, который мог проигрывать эту анимацию. А через 2 года компании Airbnb понравился такой подход, и они реализовали поддержку всего этого для нативных платформ и назвали это Lottie.

Так выглядит сплеш-скрин Сбермаркета. Он сделан на Lottie.

Как использовать?

Тут всё просто: мы прокидываем в source через require путь до анимации, которую нам дал дизайнер, и устанавливаем свойства, чтобы анимация начала проигрываться в цикле при маунте компонента.

const MyComponent = () => {
  ...
  render (
    <Lottie
      source={require('../path/to/animation.json')}
      autoPlay
      loop
    />
  )
}

Посмотрим что мы можем менять как разработчики. Тут нам доступен только прогресс, например через Animated.Value. Это полезно, когда у нас есть онбординг и какое-то количество слайдов, и мы хотим красиво между ними менять анимации (а-ля Telegram). Еще у нас есть ColorFilters, и мы можем менять цвета тех элементов, для которых дизайнеры это предусмотрели.

const MyComponent = () => {
  ...
  render (
    <Lottie
      source={require('../path/to/animation.json')}
      progress={animationProgress.value}
      colorFilters={[{
        keypath: 'garden, 
        color: strangerСolor1, 
      }, { 
        keypath: background, 
        color: strangerСolor2, 
      }]}
    />
  )
}

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

В сухом остатке

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

Плюсы и минусы

Плюсы:

  • Мало мало весит по сравнению с gif или видео

  • Требует мало ресурсов

  • Всегда (почти) точная анимация

Минусы:

  • Управление на стороне дизайна

OpenGL

Очень ситуативный инструмент, который подразумевает знание языка шейдеров. Ещё надо точно понимать, почему сейчас надо использовать именно его.

Сам по себе openGL — это просто спецификация, в которой описаны функции для работы с 2D и 3D-графикой. С давних времен она поддерживает язык шейдеров. Это функция, которая обрабатывает изображение, как вы сейчас видите. Ниже оставляю код для понимания, как выглядит работа с шейдерами.

Код
precision highp float;
varying vec2 uv;
uniform sampler2D t;
uniform vec2 move;
uniform float time, amp, freq, moving;
vec2 lookup (vec2 offset, float amp2) {
    return mod(
        uv + amp2 * amp * vec2(
            cos(freq*(uv.x+offset.x)+time),
            sin(freq*(uv.y+offset.x)+time))
        + vec2(
            moving * time/10.0,
            0.0),
        vec2(1.0));
}
void main() {
    float dist = distance(uv, move);
    float amp2 = pow(1.0 - dist, 2.0);
    float colorSeparation = 0.02 * mix(amp2, 1.0, 0.5);
    vec2 orientation = vec2(1.0, 0.0);
    float a = (1.0-min(0.95, pow(1.8 * distance(uv, move), 4.0) +
    0.5 * pow(distance(fract(50.0 * uv.y), 0.5), 2.0)));
    gl_FragColor = vec4(a * vec3(
    texture2D(t, lookup(colorSeparation * orientation, amp2)).r,
    texture2D(t, lookup(-colorSeparation * orientation, amp2)).g,
    texture2D(t, lookup(vec2(0.0), amp2)).b),
    1.0);
}

С помощью openGL можно реализовывать разные интересные эффекты, не только с фото или видео. Например, около четырёх лет назад я делал приложение-переводчик. Надо было добавить интерактивный элемент для распознавания голоса наподобие Siri. Тогда ещё не было библиотеки Reanimated, Skia, и даже SVG работал криво, зато довольно активно развивался React Native GL. Я его затащил в проект, написал шейдер, на вход подал уровень шума с микрофона. Получил классную анимацию 60fps, которая реагирует на голос.

Использовать или нет?

Сейчас я бы не советовал использовать GL в продакшене, так как затащить её трудно. В Expo можно поставить относительно легко, а в чистый RN-проект надо тащить uni-модули и долго париться с установкой. Второй особенностью является необходимость изучить язык шейдеров, потому что без них вы ничего не напишете. Но с ее помощью можно реализовывать любые маски а-ля Instagram, если сильно захотеть.

Плюсы и минусы

Плюсы:

  • рендер на видеокарте

  • стабильный

Минусы:

  • надо изучать язык шейдеров

  • уже не везде поддерживается

Так что использовать, если я хочу наложить маску на изображение?

Рекомендую Skia. Это библиотека для рендера 2D-графики. Её используют большие ребята типа Flutter (он абсолютно всё рендерит на Skia) и, если верить официальному сайту, то Google Chrome и ChromeOS. Из этого вы можете сделать вывод, что библиотека очень классная, хорошо поддерживается и выполняет свою задачу на 100%. И вы будете правы.

С React Native Skia дела идут немного по-другому (она пока в бете, хотя кому это мешает, да RN?), но она активно развивается, и я не вижу причин её сейчас не попробовать.

Я бы её сейчас взял, если вам нужно:

  • Рисовать интерфейсы

  • Накладывать фильтры, маски на изображения

  • Рисовать SVG

Помимо прочего можно использовать для написания анимаций с нативной производительностью, и, если вы все-таки прониклись шейдерами, а openGL завезти не смогли, то смело берите Skia (синтаксис шейдеров немного изменен, но очень похож).

На видео изображён пример интерфейса, который мог бы быть частью какого-то приложения биржи. Это всё работает на Skia, у них своя встроенная система жестов, и всё это работает на 60fps.

К выводам

Skia имеет высокий перфоманс, её можно использовать для решения широкого списка задач. Из минусов — бета версия, хотя работает довольно стабильно. А ещё вес, хотя 6 для iOS и 4 mb для Android — это не так уж и много.

Плюсы и минусы

Плюсы:

  • Высокая производительность

  • Можно использовать для решения широкого списка задач

  • Удобное АПИ

Минусы:

  • Вес (около 6mb - ios; 4mb - android)

  • Бета версия

Выбирай инструмент под задачу

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

Плюсы

Минусы

Когда использовать?

Animated

Работает из коробки

В некоторых случаях нативный перфоменс

Работает с событием скролла

Очень плохой перфоменс без использования useNativeDriver

Неудобно создавать анимации с несколькими состояниями и условиями

Не работает нативный перфоманс с встроенной системой жестов

Для создания простых анимаций по изменению свойств, которые не заставляют перерисовываться макет.

Reanimated

Нативный перфоменс

Прекрасно работает с жестами

Работает со всеми свойствами, можно даже текст вставлять

Прозрачная работа с анимациями любой сложности

Нет совместимости с Animated API

Там, где не подошел стандартный Animated или если вы хотите использовать библиотеки, представленные ниже.

RN SVG

Полное управление

Очень мало весит

Двойная работа: анимацию делает дизайнер, потом разработчик

Сложный дизайн бывает трудно повторить

Если надо сделать не очень трудную анимацию с интерактивностью или без.

Lottie

Мало мало весит по сравнению с gif или видео

Требует мало ресурсов

Всегда (почти) точная анимация

Управление на стороне дизайна

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

OpenGL

Рендер на видеокарте

Стабильный

Надо изучать язык шейдеров

Уже не везде поддерживается

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

Skia

Высокая производительность

Можно использовать для решения широкого списка задач

Удобное API

Вес (около 6mb - ios; 4mb - android)

Бета версия

Если надо создать сложный анимированный интерактивный интерфейс, писать шейдеры.

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

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