Привет! Меня зовут Денис, я мобильный разработчик в СберМаркете. Пишу на React Native и люблю анимации, ведь они дают жизнь нашим проектам :)
В этой статье попробуем разобраться, что же всё-таки работает быстрее: React-Native-Reanimated или Animated + useNativeDriver: true.
Будем сравнивать FPS, нагрузку на процессор, оперативную память и воспользуется EventQueue для получения логов.
Дисклеймер: Это теоретическое сравнение на абстрактных примерах, так что перфоманс на реальных проектах может отличаться. Но я постарался нагрузить инструменты как следует и хорошенько проверить их на прочность.
Почему именно этот эксперимент?
Эксперимент 1. Тестируем loop анимацию
Эксперимент 2. Добавляем scale
Эксперимент 3. Анимация, привязанная к скроллу
Почему именно этот эксперимент?
Не так давно мой коллега Женя Прокопьев (aka @Evgen175) опубликовал на Хабре статью в двух частях о том, почему анимации в React Native работают именно так, как работают. Цель серии — объяснить, откуда берутся лаги в анимациях, за которые многие так не любят RN, и дать советы, для каких случаев лучше использовать разные инструменты.
Под капотом анимаций в React Native. Часть 1/2: Animated and Bridge
Под капотом анимаций в React Native. Часть 2/2: Reanimated and JSI
В финале получились следующие выводы:
Какой инструмент выбрать? | |
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 |
|
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.
Для наглядности сравним расход оперативной памяти в кейсе с шариками на разном количестве элементов.
Существенная разница заметна лишь на 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 |
|
+ |
Покрывает почти все кейсы |
Идет из коробки |
– |
Большой размер исходников(3мб) Ест много оперативной памяти |
Ест ресурсы процессора Не все свойства можно анимировать Не все методы работают с useNativeDriver |
Спасибо тем, кто дочитал до конца! Надеюсь, было полезно (и интересно). Если есть, что рассказать на тему производительности Animated и Reanimated, буду ждать вас в комментариях :)
Tech-команда СберМаркета ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.