useNativeDriver: два потока

движение≈ 16 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Кольцо отдыха из прошлой статьи крутилось с useNativeDriver: false, а смысл флага был оставлен на эту статью. Поставим true — и предпросмотр ответит длинным подлинным предупреждением react-native-web; прочитаем его по частям. Потом выясним, что на телефоне уезжает в другой поток: не код анимации, а её описание — массив кадров, параметры пружины, граф узлов. И поймём, почему уезжает не всё: у нативной стороны свой короткий словарь, и ширина полосы отдыха в него не входит.

Разделы темы:·

Во всех примерах главы стояло useNativeDriver: false. Переключим кольцо из прошлой статьи на true — само кольцо лежит в подложке — и запустим заполнение дважды подряд. Прогноз: будет ли ошибка, доедет ли кольцо и сколько строк напечатает сам Animated?

Как и во всей главе: кадры идут, только пока экран примера виден. Запускайте примеры с анимацией клавишей Enter из поля прогноза — экран стоит прямо над ним.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Ошибки нет, кольцо доехало оба раза, а в выводе появилось длинное предупреждение — одно, хотя нативный драйвер просили дважды: флаг _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 appRCTAnimation — iOS-библиотека React Native с нативными анимациями: папка Libraries/NativeAnimation и её React-RCTAnimation.podspec.
bundle exec pod install, autolinkingСовет для голого React Native на iOS, где нативные библиотеки подключают через CocoaPods. В браузере подов нет, а в проекте на Expo модуль входит в сам React Native.
jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Текст не сочинён для веба. В 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. Прогноз: чем кончится второй прогон в предпросмотре?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Оба прогона простояли дольше 300 мс. Предупреждение не соврало: с true в предпросмотре работает тот же JS-цикл, и занятый поток останавливает его так же. Будь на экране кольцо, оно замерло бы в обоих прогонах.

На телефоне второй прогон разойдётся с первым, только не в консоли. С useNativeDriver: true кадры считает главный поток: на iOS — по сигналу CADisplayLink в RCTNativeAnimatedNodesManager (метод stepAnimations:), на Android — в колбэке кадра ReactChoreographer из NativeAnimatedModule.kt. JS-поток стоит, кольцо едет. А слушатель простоял бы и там: он живёт в JS, значения ему приносят события onAnimatedValueUpdate, и они ждут, пока поток освободится. Замер из JS видит JS-поток — и ничего больше.

useNativeDriver: falseuseNativeDriver: true на телефоне
Кто считает кадрJS-потокглавный поток
Что пересекает границу каждый кадрновые пропсы компонентаничего; если есть слушатели — событие со значением в JS
JS-поток занятанимация стоитанимация идёт, слушатели ждут
Что JS знает о значениивсё и сразуто, что пришло событием или колбэком

Главный поток JavaScript не выполняет. Значит, отдать ему «функцию анимации» нельзя — только описание, которое нативный код поймёт сам. Посмотрим, как в описание превращается timing. Его ход задаёт easing — произвольная функция JS: Easing.out(Easing.cubic) или ваш easeOut из первой задачи первой статьи главы. Выполнить её натив не может, поэтому React Native вычисляет её заранее, по значению на каждый кадр 60 Гц, и отправляет массив чисел.

Соберём это сами. toFrames строит массив, а граница между потоками в нашей модели — JSON.stringify и обратно. Прогноз: сколько кадров у секундной анимации, что лежит на середине, доедет ли easing — и сколько чисел понадобится таймеру отдыха на 90 секунд?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Кадров 61: шестьдесят интервалов и отдельно конечная точка easing(1), чтобы анимация кончалась ровно в цели. На середине 0.875 — это easing(0.5) кубической кривой, посчитанный ещё в JS. Функция через границу не прошла — доезжает только то, что выражается данными, — но она и не нужна: всё, что она могла сказать, уже лежит в массиве. Цена видна в последней строке: полторы минуты отдыха — 5401 число, посчитанное в момент старта.

toFrames не придуман — это пересказ настоящего метода:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Проверим, что настоящий Animated строит то же самое. Метода нет в типах — это внутренности, поэтому доступ к нему описан своим интерфейсом. Заодно заглянем в пружину. Прогноз: сколько кадров у spring?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

У 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, TrackingAnimatedNodenew Animated.Value, Animated.Color, toValue-значение
вычисленияузлы Interpolation, Addition, Subtraction, Multiplication, Division, Modulus, DiffClampinterpolate, Animated.add, subtract, multiply, divide, modulo, diffClamp
сборщикиузлы Transform, Style, Props, Objectстиль и пропсы Animated.View
драйверыFrameBasedAnimationDriver, SpringAnimation, DecayAnimation, EventAnimationDrivertiming, spring, decay, Animated.event

Это и есть ответ на первый вопрос статьи: в нативный поток уезжает не код, а описание в словаре, который натив знает заранее. Отсюда же видно, чего в нём быть не может: вашей функции на каждый кадр, слушателя, который что-то решает, условия «если подход последний». Всё это остаётся в JS.

Второе ограничение — не на анимацию, а на то, куда её направляют. Нативный драйвер кладёт значение кадра прямо в пропсы нативного вида, минуя всё, что обычно стоит между ними. А между ними стоит раскладка: ширину, отступы, top и flex превращает в координаты Yoga, и от ширины полосы зависят её соседи и дети. Кадр, записанный мимо раскладки, её не пересчитает. Поэтому натив принимает только то, что раскладку не трогает, — прозрачность, трансформации, цвета, скругления. Список лежит в React Native 0.86 одним файлом:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание на ветку useSharedAnimatedBackend: с этим флагом в список добавляются размеры, позиция и flex. Это экспериментальный общий движок анимаций, и в 0.86 флаг по умолчанию выключен — рассчитывать на него пока нельзя.

Сверку React Native делает в отладочной сборке: validateStyles обходит анимируемые ключи стиля и на ключ вне списка печатает console.error. Соберём такую же для «Подхода» и сверим половину кольца, цвет дуги из задачи прошлой статьи и полосу отдыха из первой статьи главы. Сверяются только ключи, в которые приходит анимируемое значение: статичные стили натив не трогает. Прогноз: что пройдёт?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Кольцо проходит целиком: поворот и цвет в списке есть. Полоса — нет: она убывает шириной. Строка ошибки та же, что на телефоне: в 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-овым. Прогноз?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Предпросмотр довёз обе половины. На телефоне вторая не стартовала бы вовсе — start бросил бы исключение:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Правило жёсткое, но понятное: у значения один хозяин кадров. Если бы 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.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

  • В предпросмотре нативного модуля анимаций нет: 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.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Ширину нельзя, сдвиг можно. Полоса получает полную ширину обычным стилем, а убывает, уезжая влево за край дорожки; дорожка с overflow: "hidden" прячет уехавшее:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Статичная ширина переехала в обычный стиль: её натив не трогает, и сверка её не спрашивает. Теперь checkNative отвечает «можно», а на телефоне полоса поедет в главном потоке и не замрёт, пока JS разбирает ответ сервера. Тот же приём годится для любой «меняющейся длины»: выезжающую панель двигают translateY, а не height.

Сторона натива: значение между кадрами

Кадры timing посчитаны для 60 Гц, а экран бывает и 120 Гц, и кадр бывает пропущенным. Натив получает массив и сам ищет, какое значение показать в момент elapsed. Допишите frameAt: между соседними кадрами — прямая, за последним — конечное значение.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Индекс кадра даёт целая часть elapsed / FRAME, а дробная — долю пути до следующего кадра:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

На 25 мс получается 0.025, на 2000 мс — 1. Ровно так поступает iOS-драйвер RCTFrameAnimation.mm: берёт два кадра вокруг текущего времени и линейно пересчитывает между ними, а когда следующего кадра нет, ставит последнее значение и считает анимацию оконченной. Время он меряет от первого кадра, а не считает кадры, — по той же причине, по которой наш animate из первой статьи главы жил на своих часах.

«+30 с» на нативном кольце

Кольцо отдыха заполняется нативным драйвером, а «+30 с» откатывает его к четверти и запускает отдых снова. В предпросмотре это работало бы, на телефоне — нет. В подложке стоит сторож, который возвращает правило React Native 0.86, так что теперь падает и здесь. Почините «+30 с».

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Кольцо стартовало с useNativeDriver: true, и его значение переехало в натив. Любая следующая анимация этого значения обязана быть нативной:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь «+30 с» прерывает заполнение, откатывает кольцо и запускает его снова, а в конце приходит «отдых окончен: true». Если бы откат требовал свойства вне списка — скажем, ширины, — выход был бы другим: завести для этого отдельное значение с JS-драйвером, а не делить одно на двоих. Проверять флаг лучше в одном месте: собрать все анимации кольца в функции рядом со значением, чтобы «+30 с», пропуск и заполнение не могли разойтись.