Привет! Меня зовут Евгений Прокопьев, я по-прежнему старший инженер-разработчик в СберМаркете. Пишу на RN и по-прежнему очень люблю создавать красивые анимации.
Это вторая и последняя статья из серии как работает React Native (RN) под капотом, на примере анимаций. Призываю сначала ознакомиться с первой: Под капотом анимаций в React Native. Часть 1/2: Animated and Bridge
В предыдущих сериях
Мы пытаемся разобраться в том, как работают инструменты для анимации в RN и почему в итоге получается тот резльтат, который получается.
Чтобы было интереснее, представим, что мы совсем не имеем опыта в создании анимаций на RN, поэтому по ходу дела наступаем на все популярные грабли. В качестве задачи ставим себе реализовать экран с двумя типами анимаций:
Параллакс картинки (картинка немного смещается и масштабируется) — здесь мы посмотрим, как RN работает с transform-свойствами стилей.
Эффект аккордеона (раскрытие карточки в интерфейсе) — тут мы проверим, как 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. Начнем с параллакса картинки.
C помощью хука useSharedValue создадим переменную, в которой будет храниться положение скрола. К нему можно относиться как к аналогии Animated Value. И с помощью useAnimatedScrollHandler мы будем обновлять наши значения скролла и свяжем значение скрола с нашей переменной
Потом с помощью хука useDerivedValue мы будем вычислять значение масштабирования и положения картинки.
Анимировать мы всё это будем с помощью useAnimatedStyle, в который передадим наши анимированные значения.
Затем этот объект стиля передадим в компонент картинки и посмотрим что получилось.
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 и решить главные его проблемы.
Давайте посмотрим на новую архитектуру:
Начнем с Codegen. Он решает проблему взаимодействия между JS и остальными потоками. Чтобы нам сделать это взаимодействие синхронным, нужно, чтобы мы могли предоставить надежный и предсказуемый интерфейс со стороны JS. По сути нам нужен статически типизированный JS, на типы которого мы сможем опираться.
Codegen — это средство автоматизации, которое создает эти типы для нашего кода. Он парсит JS, ищет определенные шаблоны и создает для них интерфейсы на C++. Благодаря этому RN может связать наш JavaScript и UI-потоки синхронно, потому что теперь они опираются на одни и те же типы. Мы можем их не перепроверять в ходе передачи данных между потоками.Также появляются турбомодули, которые пришли на замену нативным. Они должны предоставляют синхронный доступ к методам платформы и бонусом поддерживают ленивую загрузку. В старой архитектуре все нативные модули инициализировались при старте приложения и просто лежали в памяти. C новой архитектурой мы можем их инициализировать по надобности и вызывать напрямую через JavaScript так же, как это работает в браузере.
Взамен UI-модулям пришел Fabric, который должен перерисовывать наши компоненты на девайсе. Тут суть такая же: у нас все работает синхронно, поэтому теперь перерисовки должны происходить в том же кадре.
И сам 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.
Увеличим до 100.
Пока полет нормальный, 100 элементов анимируются хорошо. И у каждого из них изменяется ширина, высота, его положение и opacity.
При количестве в 300 нод видно, что общая картина немного проседает и уже заметны небольшие лаги. Но поднимем до 1000.
Да, у нас начало все откровенно лагать — но такое количество анимированных элементов на реальном проекте представить сложно.
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-менеджеров.