useNativeDriver: два потока
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Кольцо отдыха из прошлой статьи крутилось с useNativeDriver: false, а смысл флага был оставлен на эту статью. Поставим true — и предпросмотр ответит длинным подлинным предупреждением react-native-web; прочитаем его по частям. Потом выясним, что на телефоне уезжает в другой поток: не код анимации, а её описание — массив кадров, параметры пружины, граф узлов. И поймём, почему уезжает не всё: у нативной стороны свой короткий словарь, и ширина полосы отдыха в него не входит.
Во всех примерах главы стояло useNativeDriver: false. Переключим кольцо из прошлой статьи на true — само кольцо лежит в подложке — и запустим заполнение дважды подряд. Прогноз: будет ли ошибка, доедет ли кольцо и сколько строк напечатает сам Animated?
Как и во всей главе: кадры идут, только пока экран примера виден. Запускайте примеры с анимацией клавишей Enter из поля прогноза — экран стоит прямо над ним.
Ошибки нет, кольцо доехало оба раза, а в выводе появилось длинное предупреждение — одно, хотя нативный драйвер просили дважды: флаг _warnedMissingNativeAnimated в модуле NativeAnimatedHelper взводится после первого раза, и до перезагрузки страницы строка больше не повторится. И пришла она не при вызове Animated.timing, а после строки, напечатанной между созданием и стартом: в предпросмотре объект анимации строится внутри start(), и его конструктор спрашивает shouldUseNativeDriver. Прочитаем текст по частям.
| Фрагмент | Что он значит |
|---|---|
the native animated module is missing | Нативного модуля анимаций нет. В react-native-web реестр нативных модулей — функция в одну строку, которая на любое имя отвечает null (листинг ниже). |
Falling back to JS-based animation | Главное. Анимация пойдёт тем же циклом кадров в JS, что и с false: флаг пример не сломал, но и ничего не дал. |
add `RCTAnimation` module to this app | RCTAnimation — iOS-библиотека React Native с нативными анимациями: папка Libraries/NativeAnimation и её React-RCTAnimation.podspec. |
bundle exec pod install, autolinking | Совет для голого React Native на iOS, где нативные библиотеки подключают через CocoaPods. В браузере подов нет, а в проекте на Expo модуль входит в сам React Native. |
Текст не сочинён для веба. В React Native 0.86 функция shouldUseNativeDriver печатает дословно то же и лишь промолчит под Jest. На телефоне вы его не увидите — модуль там есть. А в веб-версии приложения на Expo увидите: она тоже работает на react-native-web, и в его копии никакой проверки сборки вокруг этой строки нет. Поэтому в коде, который собирается и для веба, флаг часто пишут так: useNativeDriver: Platform.OS !== "web".
На телефоне иначе: нативный модуль анимаций в React Native 0.86 есть всегда, и useNativeDriver: true действительно отдаёт кадры другому потоку. В предпросмотре флаг принимается, предупреждает и ничего не меняет.
Зачем вообще отдавать кадры. Для анимации в приложении важны два потока. JS-поток выполняет ваш код — рендеры, обработчики нажатий, разбор ответов — и при useNativeDriver: false ещё и каждый кадр: считает долю времени, пропускает её через easing и interpolate и отправляет новые пропсы нативному компоненту (через setNativeProps, как сказано в первой статье главы). Главный поток рисует интерфейс и принимает касания. Пока JS-поток свободен, разницы не видно. Она появляется, когда он занят.
Займём его. Анимация идёт 800 мс, на 200-й миллисекунде JS-поток на 400 мс уходит в тяжёлую работу. Слушатель меряет самую длинную паузу между кадрами. Опыт ставится дважды: с false и с true. Прогноз: чем кончится второй прогон в предпросмотре?
Оба прогона простояли дольше 300 мс. Предупреждение не соврало: с true в предпросмотре работает тот же JS-цикл, и занятый поток останавливает его так же. Будь на экране кольцо, оно замерло бы в обоих прогонах.
На телефоне второй прогон разойдётся с первым, только не в консоли. С useNativeDriver: true кадры считает главный поток: на iOS — по сигналу CADisplayLink в RCTNativeAnimatedNodesManager (метод stepAnimations:), на Android — в колбэке кадра ReactChoreographer из NativeAnimatedModule.kt. JS-поток стоит, кольцо едет. А слушатель простоял бы и там: он живёт в JS, значения ему приносят события onAnimatedValueUpdate, и они ждут, пока поток освободится. Замер из JS видит JS-поток — и ничего больше.
useNativeDriver: false | useNativeDriver: true на телефоне | |
|---|---|---|
| Кто считает кадр | JS-поток | главный поток |
| Что пересекает границу каждый кадр | новые пропсы компонента | ничего; если есть слушатели — событие со значением в JS |
| JS-поток занят | анимация стоит | анимация идёт, слушатели ждут |
| Что JS знает о значении | всё и сразу | то, что пришло событием или колбэком |
Главный поток JavaScript не выполняет. Значит, отдать ему «функцию анимации» нельзя — только описание, которое нативный код поймёт сам. Посмотрим, как в описание превращается timing. Его ход задаёт easing — произвольная функция JS: Easing.out(Easing.cubic) или ваш easeOut из первой задачи первой статьи главы. Выполнить её натив не может, поэтому React Native вычисляет её заранее, по значению на каждый кадр 60 Гц, и отправляет массив чисел.
Соберём это сами. toFrames строит массив, а граница между потоками в нашей модели — JSON.stringify и обратно. Прогноз: сколько кадров у секундной анимации, что лежит на середине, доедет ли easing — и сколько чисел понадобится таймеру отдыха на 90 секунд?
Кадров 61: шестьдесят интервалов и отдельно конечная точка easing(1), чтобы анимация кончалась ровно в цели. На середине 0.875 — это easing(0.5) кубической кривой, посчитанный ещё в JS. Функция через границу не прошла — доезжает только то, что выражается данными, — но она и не нужна: всё, что она могла сказать, уже лежит в массиве. Цена видна в последней строке: полторы минуты отдыха — 5401 число, посчитанное в момент старта.
toFrames не придуман — это пересказ настоящего метода:
Проверим, что настоящий Animated строит то же самое. Метода нет в типах — это внутренности, поэтому доступ к нему описан своим интерфейсом. Заодно заглянем в пружину. Прогноз: сколько кадров у spring?
У timing — те же 61 кадр и та же середина. У пружины кадров нет вовсе: её описание — жёсткость, трение, масса, пороги остановки и начальная скорость. Умолчания tension: 40 и friction: 7 из прошлой статьи здесь уже переведены в stiffness: 230.2 и damping: 22. Кадров нет потому, что формула пружины у натива своя — RCTSpringAnimation.mm на iOS и SpringAnimation.kt на Android. А easing — чужой код, его приходится превращать в данные заранее.
Так же описывается всё, что участвует в анимации. У каждого узла есть __getNativeConfig(): у значения это { type: "value", value: 0, offset: 0, … }, у половины кольца — { inputRange: [0, 0.5], outputRange: [0, 3.141592653589793], extrapolateLeft: "clamp", … }: строки "180deg" переведены в радианы ещё в JS. Узлы связаны в граф — значение, interpolate, трансформация, стиль, пропсы вида, — и граф отправляется в натив один раз, при старте. Дальше натив ходит по нему сам: берёт значение кадра, прогоняет через узлы и пишет результат в вид.
| Что знает натив | Классы на Android | Откуда в JS |
|---|---|---|
| значения | ValueAnimatedNode, ColorAnimatedNode, TrackingAnimatedNode | new Animated.Value, Animated.Color, toValue-значение |
| вычисления | узлы Interpolation, Addition, Subtraction, Multiplication, Division, Modulus, DiffClamp | interpolate, Animated.add, subtract, multiply, divide, modulo, diffClamp |
| сборщики | узлы Transform, Style, Props, Object | стиль и пропсы Animated.View |
| драйверы | FrameBasedAnimationDriver, SpringAnimation, DecayAnimation, EventAnimationDriver | timing, spring, decay, Animated.event |
Это и есть ответ на первый вопрос статьи: в нативный поток уезжает не код, а описание в словаре, который натив знает заранее. Отсюда же видно, чего в нём быть не может: вашей функции на каждый кадр, слушателя, который что-то решает, условия «если подход последний». Всё это остаётся в JS.
Второе ограничение — не на анимацию, а на то, куда её направляют. Нативный драйвер кладёт значение кадра прямо в пропсы нативного вида, минуя всё, что обычно стоит между ними. А между ними стоит раскладка: ширину, отступы, top и flex превращает в координаты Yoga, и от ширины полосы зависят её соседи и дети. Кадр, записанный мимо раскладки, её не пересчитает. Поэтому натив принимает только то, что раскладку не трогает, — прозрачность, трансформации, цвета, скругления. Список лежит в React Native 0.86 одним файлом:
Обратите внимание на ветку useSharedAnimatedBackend: с этим флагом в список добавляются размеры, позиция и flex. Это экспериментальный общий движок анимаций, и в 0.86 флаг по умолчанию выключен — рассчитывать на него пока нельзя.
Сверку React Native делает в отладочной сборке: validateStyles обходит анимируемые ключи стиля и на ключ вне списка печатает console.error. Соберём такую же для «Подхода» и сверим половину кольца, цвет дуги из задачи прошлой статьи и полосу отдыха из первой статьи главы. Сверяются только ключи, в которые приходит анимируемое значение: статичные стили натив не трогает. Прогноз: что пройдёт?
Кольцо проходит целиком: поворот и цвет в списке есть. Полоса — нет: она убывает шириной. Строка ошибки та же, что на телефоне: в 0.86 её печатает NativeAnimatedValidation.js, и старта она не останавливает, а что нативный вид сделает с ключом вне списка, не обещано ничем.
А предпросмотр промолчит. До сверки дело не дойдёт: в react-native-web значение нативным не становится никогда. Любопытно, что его старая копия этой функции ещё бросает исключение — throw new Error("Style property '…' is not supported by native animated module"), — но зовут её только при описании нативного значения, а такого здесь не бывает. Как с transition в первой статье главы, предпросмотр врёт в лучшую сторону: полоса с width и useNativeDriver: true в нём честно убывает.
Полосу чинят не флагом, а формой движения: не менять ширину, а сдвигать полосу полной ширины за край дорожки с overflow: "hidden" — translateX в списке есть. Это первая задача.
На телефоне иначе: анимация ширины, высоты, отступов или позиции с useNativeDriver: true в отладочной сборке печатает console.error «Style property '…' is not supported by native animated module». Предпросмотр об этом молчит: значение в нём не бывает нативным.
Третье ограничение — во времени. Значение, которое хоть раз стартовало с useNativeDriver: true, становится нативным: оно и все зависящие от него узлы переезжают в граф натива и остаются там. Проверим, что скажет предпросмотр: первую половину пути везём нативным драйвером, вторую — JS-овым. Прогноз?
Предпросмотр довёз обе половины. На телефоне вторая не стартовала бы вовсе — start бросил бы исключение:
Правило жёсткое, но понятное: у значения один хозяин кадров. Если бы JS взялся двигать значение, которое уже двигает натив, две стороны спорили бы о нём каждый кадр. Поэтому в «Подходе» все анимации одного значения — заполнение, «+30 с», пропуск — идут с одним и тем же флагом; это третья задача. setValue при этом можно: это не анимация, а одна запись, и у нативного значения она отправляется в натив.
Отсюда же ответ на загадку первой статьи главы — почему у Animated.Value нет публичного чтения. Пока значение двигает натив, его JS-копия стоит на месте, и __getValue() вернёт старое число. Свежее приходит тремя путями, и все асинхронные: событиями onAnimatedValueUpdate к слушателям, колбэком stopAnimation, который спрашивает натив через getValue, и в конце анимации — вместе с результатом, по которому JS-сторона догоняет узлы. Синхронный геттер у такого значения врал бы.
И последнее, что уезжает вместе со значением, — события. Animated.event с useNativeDriver: true раскладывает событие по значениям в нативном коде, и так прокрутка ScrollView двигает шапку без единого кадра в JS. Но событие должно рождаться у нативного компонента. Если такой обработчик вызвать из JS, он только позовёт слушателей, а значения не сдвинутся — так написан __getHandler в AnimatedEvent.js. Жест на PanResponder из следующей статьи рождается в JS, поэтому свайп по подходу поедет там с useNativeDriver: false. Выход — перенести в другой поток не описание, а сам код; это worklet-ы, через одну статью.
Сверка по полным спискам React Native 0.86 и кадры timing — в том виде, в каком они лежат в подложке первой задачи и едут в следующие статьи главы рядом с кольцом. Над ними в подложке — строка var global = globalThis.
- В предпросмотре нативного модуля анимаций нет:
useNativeDriver: trueпечатает одно предупреждение на страницу и откатывается на JS-цикл кадров. На телефоне модуль есть всегда, а в веб-версии на Expo предупреждение будет тоже — отсюдаuseNativeDriver: Platform.OS !== "web". - С
falseкадр считает JS-поток, и занятый поток останавливает анимацию. Сtrueкадры считает главный поток (CADisplayLinkна iOS,ReactChoreographerна Android), и анимация идёт, пока JS занят. Замер из JS видит только JS. - В натив уезжает описание, а не код: граф узлов — значение,
interpolate, трансформация, стиль, пропсы — и конфиг анимации. Easing уtimingвычисляется заранее: 61 кадр на секунду, 5401 на полторы минуты. Уspringвместо кадров числа: её формула у натива своя. - Разрешены только свойства, которые не трогают раскладку:
opacity,transform, цвета, скругления. Ширину, высоту, отступы и позицию считает Yoga, а натив пишет мимо неё; меняющуюся длину делают сдвигом или масштабом. Предпросмотр этого не проверяет. - Значение, стартовавшее с нативным драйвером, остаётся нативным: JS-анимация того же значения на телефоне бросает исключение. Все анимации одного значения идут с одним флагом.
- JS-копия нативного значения стоит на месте; свежее приходит событиями к слушателям, колбэком
stopAnimationи в конце анимации. Поэтому уAnimated.Valueнет синхронного чтения, аAnimated.eventс нативным драйвером работает только для событий, рождённых нативным компонентом.
1/4В предпросмотре анимацию запустили с useNativeDriver: true. Что произойдёт?
2/4Почему timing с нативным драйвером отправляет массив кадров, а spring — несколько чисел?
3/4Какое свойство нельзя анимировать нативным драйвером?
4/4Кольцо заполняется с useNativeDriver: true, а кнопка «+30 с» запускает timing того же значения с false. Что будет на телефоне?
Полоса отдыха без width
Полоса отдыха из первой статьи главы убывает шириной, и нативный драйвер её не возьмёт. Сделайте так, чтобы полоса убывала так же, но через свойство из списка. checkNative из статьи лежит в подложке и сверяет стиль по полным спискам React Native 0.86.
Ширину нельзя, сдвиг можно. Полоса получает полную ширину обычным стилем, а убывает, уезжая влево за край дорожки; дорожка с overflow: "hidden" прячет уехавшее:
Статичная ширина переехала в обычный стиль: её натив не трогает, и сверка её не спрашивает. Теперь checkNative отвечает «можно», а на телефоне полоса поедет в главном потоке и не замрёт, пока JS разбирает ответ сервера. Тот же приём годится для любой «меняющейся длины»: выезжающую панель двигают translateY, а не height.
Сторона натива: значение между кадрами
Кадры timing посчитаны для 60 Гц, а экран бывает и 120 Гц, и кадр бывает пропущенным. Натив получает массив и сам ищет, какое значение показать в момент elapsed. Допишите frameAt: между соседними кадрами — прямая, за последним — конечное значение.
Индекс кадра даёт целая часть elapsed / FRAME, а дробная — долю пути до следующего кадра:
На 25 мс получается 0.025, на 2000 мс — 1. Ровно так поступает iOS-драйвер RCTFrameAnimation.mm: берёт два кадра вокруг текущего времени и линейно пересчитывает между ними, а когда следующего кадра нет, ставит последнее значение и считает анимацию оконченной. Время он меряет от первого кадра, а не считает кадры, — по той же причине, по которой наш animate из первой статьи главы жил на своих часах.
«+30 с» на нативном кольце
Кольцо отдыха заполняется нативным драйвером, а «+30 с» откатывает его к четверти и запускает отдых снова. В предпросмотре это работало бы, на телефоне — нет. В подложке стоит сторож, который возвращает правило React Native 0.86, так что теперь падает и здесь. Почините «+30 с».
Кольцо стартовало с useNativeDriver: true, и его значение переехало в натив. Любая следующая анимация этого значения обязана быть нативной:
Теперь «+30 с» прерывает заполнение, откатывает кольцо и запускает его снова, а в конце приходит «отдых окончен: true». Если бы откат требовал свойства вне списка — скажем, ширины, — выход был бы другим: завести для этого отдельное значение с JS-драйвером, а не делить одно на двоих. Проверять флаг лучше в одном месте: собрать все анимации кольца в функции рядом со значением, чтобы «+30 с», пропуск и заполнение не могли разойтись.