Привет! Меня зовут Денис, я мобильный разработчик в СберМаркете. Пишу на React Native и люблю анимации, ведь они дают жизнь нашим проектам :)

В этой статье попробуем разобраться, что же всё-таки работает быстрее: React-Native-Reanimated или Animated + useNativeDriver: true.

Будем сравнивать FPS, нагрузку на процессор, оперативную память и воспользуется EventQueue для получения логов.

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

Почему именно этот эксперимент?

Эксперимент 1. Тестируем loop анимацию

Эксперимент 2. Добавляем scale

Эксперимент 3. Анимация, привязанная к скроллу

Так кто быстрее?

Почему именно этот эксперимент?

Не так давно мой коллега Женя Прокопьев (aka @Evgen175) опубликовал на Хабре статью в двух частях о том, почему анимации в React Native работают именно так, как работают. Цель серии — объяснить, откуда берутся лаги в анимациях, за которые многие так не любят RN, и дать советы, для каких случаев лучше использовать разные инструменты.

В финале получились следующие выводы:

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

Animated

Reanimated

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

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

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

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

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

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

А ещё появилась гипотеза:

Animated с useNativeDriver отправляет граф, описывающий анимацию, для расчета на UI, а Reanimated делает все расчеты на стороне JS.

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

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

В красном углу ринга — Animated. В синем — Reanimated. Поехали!

Эксперимент 1. Тестируем loop анимацию

Возьмем 500 шариков с анимированным translateY, чтобы было нагляднее. Добавим loop и накинем easing функцию.

Код Reanimated
export const ReanimatedAnimatedItem = () => {
  const animation = useDerivedValue(() =>
    withRepeat(
      withSequence(
        withTiming(0, {
          duration: twoNumRandom(1500, 2000),
          easing: Easing.bounce,
        }),
        withTiming(1, {
          duration: twoNumRandom(1500, 2000),
          easing: Easing.bounce,
        }),
      ),
      -1,
      true,
    ),
  );

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      {
        translateY: interpolate(
          animation.value,
          [0, 1],
          [0, 834.3333129882812],
        ),
      },
    ],
  }));

  return (
    <Animated.View
      style={[styles.box, {left: generateRandom()}, animatedStyle]}
    />
  );
};

Код Animated
export const NativeAnimatedItem = () => {
  const value = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    Animated.loop(
      Animated.sequence([
        Animated.timing(value, {
          toValue: 834.3333129882812,
          useNativeDriver: true,
          duration: twoNumRandom(1500, 2000),
          easing: Easing.bounce,
        }),
        Animated.timing(value, {
          toValue: 0,
          useNativeDriver: true,
          duration: twoNumRandom(1500, 2000),
          easing: Easing.bounce,
        }),
      ]),
    ).start();
  }, []);

  const style = useMemo(
    () => [
      styles.box,
      {
        left: generateRandom(),
        transform: [
          {
            translateY: value,
          },
        ],
      },
    ],
    [],
  );

  return <Animated.View style={style} />;
};

Получаем вот такой результат:

Сначала смотрим аниманию на Animated, затем на Reanimated
Сначала смотрим аниманию на Animated, затем на Reanimated

Animated

Reanimated

RAM

121

200

CPU(ios)

60%

60%

FPS

60

60


Оба инструмента справились неплохо, хотя в начале проявились небольшие просадки при построении виртуального дерева. Однако перфоманс монитор сверху показывает, что Reanimated требуется как минимум на 60мб больше оперативной памяти.

Нагрузка на ЦП в этом кейсе одинаковая.

Результаты первого эксперимента:

Плавность анимации (fps)

по очку Animated и Reanimated

Расходование памяти

победил Animated

Нагрузка на ЦП

по очку Animated и Reanimated

Animated 3-2 Reanimated

Первый раунд за Animated

Эксперимент 2. Добавляем scale

Добавим анимированное свойство scale. Посмотрим, как будет влиять на перфоманс наличие больше чем одного свойства.

Код Animated
export const NativeAnimatedItem = () => {
  const value = useRef(new Animated.Value(0)).current;
  const scallableValue = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    Animated.loop(
      Animated.sequence([
        Animated.timing(scallableValue, {
          toValue: twoNumRandom(0.85, 0.95),
          useNativeDriver: true,
          easing: Easing.bounce,
        }),
        Animated.timing(scallableValue, {
          toValue: twoNumRandom(2.6, 3),
          useNativeDriver: true,
          easing: Easing.bounce,
        }),
      ]),
    ).start();

    Animated.loop(
      Animated.sequence([
        Animated.timing(value, {
          toValue: 834.3333129882812,
          useNativeDriver: true,
          duration: twoNumRandom(1500, 2000),
          easing: Easing.bounce,
        }),
        Animated.timing(value, {
          toValue: 0,
          useNativeDriver: true,
          duration: twoNumRandom(1500, 2000),
          easing: Easing.bounce,
        }),
      ]),
    ).start();
  }, []);

  const style = useMemo(
    () => [
      styles.box,
      {
        left: generateRandom(),
        transform: [
          {
            translateY: value,
          },
          {scale: scallableValue},
        ],
      },
    ],
    [],
  );

  return <Animated.View style={style} />;
};

Код Reanimated
export const ReanimatedAnimatedItem = () => {
  const scallableValue = useDerivedValue(() =>
    withRepeat(
      withSequence(
        withTiming(twoNumRandom(0.85, 0.95)),
        withTiming(twoNumRandom(2.6, 3)),
      ),
      -1,
      true,
    ),
  );

  const animation = useDerivedValue(() =>
    withRepeat(
      withSequence(
        withTiming(0, {
          duration: twoNumRandom(1500, 2000),
          easing: Easing.bounce,
        }),
        withTiming(1, {
          duration: twoNumRandom(1500, 2000),
          easing: Easing.bounce,
        }),
      ),
      -1,
      true,
    ),
  );

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      {
        translateY: interpolate(
          animation.value,
          [0, 1],
          [0, 834.3333129882812],
        ),
      },
      {
        scale: scallableValue.value,
      },
    ],
  }));

  return (
    <Animated.View
      style={[styles.box, {left: generateRandom()}, animatedStyle]}
    />
  );
};

Animated

Reanimated

RAM

140

240

CPU(iOS)

181%

85%

FPS

2 (JS-поток)

60

useNativeDriver не справляется с нагрузкой, приложение просело до 1 кадра в секунду. А вот Reanimated показывает те же стабильные 60 fps. 

Нагрузка на процессор у Reanimated ниже чем у useNativeDriver, но оперативной памяти расходуется на 100 мб больше. Также, посмотрим на перфоманс монитор в Android Studio.

Perfomance монитор в android studio
Perfomance монитор в android studio

Для наглядности сравним расход оперативной памяти в кейсе с шариками на разном количестве элементов.

Существенная разница заметна лишь на 1000 нодах и выше. Но в реальности больше чем 100 анимированных нод встретить тяжело. В рамках эксперимента Reanimated берёт верх, но запомним, что преимущество здесь весьма умозрительное.

Результаты эксперимента:

Плавность анимации (fps)

победил Reanimated

Расходование памяти

победил Animated

Нагузка на ЦП

победил Reanimated

Animated 1-2 Reanimated

Второй раунд за Reanimated

Почему так произошло?

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

Не все методы поддерживают useNativeDriver. Вместо этого подключается requestAnimationFrame. Event.start() запускается в нативке, а вот остальные методы требуют лишний раз перегнать значение в JS-потоке.

В данном примере мы используем Sequence для объединения анимаций в цепочку. Из-за этого fps падает. Если убрать Sequence, то useNativeDriver будет включен и анимации станут более плавными.

В случае с Reanimated общение происходит через JSI, поэтому JS-поток не перегружается.

Давайте посмотрим в логи с помощью MessageQueue для большего понимания того что происходит под капотом:

Если кликнуть на картинку, будет проще прочитать :)
Если кликнуть на картинку, будет проще прочитать :)

В Reanimated создается таймер для анимации и сама нода. Никаких лишних вызовов не видим. Animated + useNativeDriver делает то же самое, но ещё каждый раз делает вызовы на выполнение анимации в JS-потоке.

Эксперимент 3. Анимация, привязанная к скроллу

Посмотрим на другой вид анимации — привязанный к скроллу. Для этого у Animated есть метод Event который создает карту анимированных значений. А в случае с Reanimated создадим shared value для получения позиции скролла. Анимировать будем Lottie анимацию.

Обзор на разные библиотеки, которые можно подключить к Reanimated можно прочитать ещё в одной статье Жени: Эффектно и эффективно. 6 инструментов для анимации в React Native

Код Reanimated
export const CompareWithScroll = () => {
  const scrollY = useSharedValue(0);

  return (
    <Animated.ScrollView
      style={{flex: 1, backgroundColor: '#fff'}}
      onScroll={event => {
        scrollY.value = event.nativeEvent.contentOffset.y;
      }}
      scrollEventThrottle={16}>
      {new Array(100).fill(0).map(() => (
        <LottieReanimated source={lottie} scrollY={scrollY} />
      ))}
    </Animated.ScrollView>
  );
};

export const LottieReanimated = ({source, scrollY}: Props) => {
  const animatedProps = useAnimatedProps(() => ({
    progress: interpolate(-scrollY.value, [0, height], [0, 1]),
  }));
  const styles = useAnimatedStyle(() => ({
    left: generateRandom(width),
    top: generateRandom(height),
  }));

  return (
    <AnimatedLottieView
      style={[ownStyles.view, styles]}
      source={source}
      animatedProps={animatedProps}
    />
  );
};

Код Animated
export const CompareWithScroll = () => {
  const scrollY = useRef(new Animated.Value(0));

  return (
    <Animated.ScrollView
      style={{flex: 1, backgroundColor: '#fff'}}
      onScroll={Animated.event(
        [
          {
            nativeEvent: {
              contentOffset: {y: scrollY.current},
            },
          },
        ],
        {useNativeDriver: true},
      )}
      scrollEventThrottle={16}>
      {new Array(100).fill(0).map(() => (
        <LottieNative source={lottie} scrollY={scrollY.current} />
      ))}
    </Animated.ScrollView>
  );
};

export const LottieNative = ({source, scrollY}: Props) => {
  const styles = useMemo(
    () => [
      {
        left: generateRandom(width),
        top: generateRandom(height),
      },
    ],
    [],
  );

  return (
    <AnimatedLottieView
      style={[ownStyles.view, styles]}
      source={source}
      progress={scrollY.interpolate({
        inputRange: [0, height],
        outputRange: [0, 1],
      })}
    />
  );
};

Теперь создадим сразу 100 элементов.

Animated

Reanimated

RAM

640

650

CPU(ios)

96%

97%

FPS

25-35

1


По оперативной памяти видим, что разницы практически нет, но Reanimated просел до 1 fps. Animated + useNativeDriver держится в пределах 20-30 fps.

В этом кейсе нагрузка на ЦП одинаковая.

Результаты эксперимента:

Плавность анимации (fps)

победил Animated

Расходование памяти

ничья

Нагузка на ЦП

ничья

Animated 3-2 Reanimated

Третий раунд за Animated

Так кто быстрее?

Animated победил, хоть и дал слабину во втором эксперименте. Оперативная память сильнее расходуется в Reanimated, а Animated + useNativeDriver сильнее нагружает процессор.

Итоговый результат:

эксперимент 1

эксперимент 2

эксперимент 3

Плавность анимации (fps)

R, A

R

A

Расходование памяти

A

A

R, A

Нагузка на ЦП

R, A

R

R, A

Animated 7-6 Reanimated

Animated ????

По факту же все зависит от контекста. Например, в эксперименте 2 Reanimated вырвал победу у Animated по плавности анимации при весьма нереалистичных для настоящего проекта предпосылках.

Итого, на небольших проектах можно в принципе не задумываться о скорости и использовать, что понравится. Reanimated покрывает почти все кейсы, но, в некоторых случаях, хуже по перфомансу. В то время как useNativeDriver имеет ограничения и позволяет анимировать только базовые свойства. Если нужно анимировать макет(width, height и т.д), то точно присматриваемся к Reanimated.

А вот и финальное сравнение инструментов:

Reanimated

Animated

+

Покрывает почти все кейсы

Удобный API

Идет из коробки

Большой размер исходников(3мб)

Ест много оперативной памяти

Ест ресурсы процессора

Не все свойства можно анимировать

Не все методы работают с useNativeDriver

Спасибо тем, кто дочитал до конца! Надеюсь, было полезно (и интересно). Если есть, что рассказать на тему производительности Animated и Reanimated, буду ждать вас в комментариях :)

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

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