Reanimated и gesture-handler: как это читать
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья отправила решение свайпа в настоящий второй поток — Web Worker — и оставила три долга: почему сам Reanimated не попадает в предпросмотр, зачем ему плагин сборки и что такое shared value. Здесь они закрываются. Сама библиотека в этой статье не запускается: её поведение показано записями — выводом сборщика и Babel-плагина на пакетах из приложения автора — и строками её исходников. Исполняется одно: миниатюра shared value поверх второго потока из прошлой статьи. Цель — читать код на Reanimated: видеть, какая функция уедет во второй рантайм, что поедет вместе с ней и почему строка подхода на нём не отстаёт от пальца. Этаж — сторонние библиотеки react-native-reanimated 4.5, react-native-worklets 0.10 и react-native-gesture-handler 2.32: в Expo их ставят отдельно, а плагин сборки Expo подключает сам.
Первая причина — та же, что у пакетов Expo: пример — один файл без npm и сборщика, и у него есть только глобали ReactNative и React. Но это причина песочницы, а не библиотеки: react-native-web — тоже npm-пакет, и в песочницу он попал, потому что его собрали заранее. Попробуем так же собрать Reanimated — тем же esbuild, которым собран рантайм предпросмотра, с той же подменой react-native на react-native-web. Файл app.js — одна строка import "react-native-reanimated";, пакеты — из мобильного приложения автора: Reanimated 4.5.1 и react-native-worklets 0.10.1. Сначала соберём так, как собирают для телефона, — без платформенных расширений:
Сборка встала на строке, знакомой по главе 1: TurboModuleRegistry.get('ReanimatedModule') из src/specs/NativeReanimatedModule.ts. Это нативная половина Reanimated — модуль на C++, который на телефоне поднимает второй рантайм и отдаёт JS ручки к нему. Реестра турбомодулей в react-native-web нет вовсе — это показала probe в статье «Что в этой песочнице настоящее». Теперь соберём так, как собирает веб-версию Expo: с платформенными расширениями, когда файл .web.ts берётся раньше обычного, — тот же резолвер, что в статье главы 2 про файлы платформ:
Собралось. Для веба у Reanimated свои файлы, и в одном из них прямо написано, зачем он заведён:
Значит, точнее так: собранный как для телефона, Reanimated падает на TurboModuleRegistry, а собранный для веба собирается — но это другой Reanimated. В нём нет ровно того, ради чего библиотеку берут и о чём эта статья:
| Телефон | Веб-сборка Reanimated | |
|---|---|---|
| Модуль | C++, найден через TurboModuleRegistry | createJSReanimatedModule() — реализация на JS |
| Второй рантайм | UI-рантайм: второй экземпляр JS-движка в главном потоке | нет: scheduleOnUI вызывает worklet в том же потоке, в ближайшем кадре (листинг threads.ts в прошлой статье) |
| Shared value | хозяин в UI-рантайме, в JS-потоке — гость (раздел «Shared value» ниже) | makeMutableWeb: обычный объект с полем value в том же потоке |
| Жест gesture-handler | распознаёт нативный код, колбэки — в UI-рантайме | распознаёт JS-реализация gesture-handler по событиям браузера |
| Babel-плагин | обязателен | можно без него, если каждому хуку дать массив зависимостей |
Последняя строка таблицы — третья стена. Предпросмотр компилирует пример TypeScript'ом, Babel с плагином worklet-ов в нём не участвует. Веб-сборка Reanimated это переживает, но только если хуку дать массив зависимостей вручную; иначе отладочная сборка бросает прямо в useAnimatedStyle:
Здесь видна вторая работа замыкания: __closure от плагина служит хуку и списком зависимостей. В вебе второго потока нет, и хватает перечислить зависимости руками, как у эффекта из части IV. На телефоне руками не обойтись: без плагина функция не станет worklet-ом и не уедет вовсе. А второго JS-рантайма в том потоке, где рисуется экран, браузер не даёт совсем: там живёт сама страница, а второй поток у неё один — Worker, который рисовать не умеет. Поэтому Reanimated здесь не исполняется: собранный для телефона — не собирается, собранный для веба — показал бы библиотеку без второго рантайма, то есть без предмета статьи.
В своём проекте на Expo всё это ставится одной командой, а плагин сборки подключается сам:
Пресет Expo ищет react-native-worklets среди установленных пакетов и, если нашёл, ставит его плагин в сборку. Поэтому в babel.config.js проекта на Expo плагина обычно не видно, а работает он всегда. И ещё одно требование записано в исходнике Reanimated строкой ошибки: «Reanimated 4 supports only the React Native New Architecture and web.» — старой архитектуры четвёртая версия не поддерживает.
Вот строка подхода из статьи про жесты, переписанная на Reanimated и gesture-handler. Поведение то же: жест берётся только горизонтальный, после 10 точек; строка не уезжает правее нуля; после отпускания пружина везёт её назад или за край, и тогда подход удаляется. Прочитайте её сначала сами: какие функции, по-вашему, будут выполняться не в JS-потоке?
Директивы "worklet" здесь нет ни одной, а плагин нашёл четыре worklet-а. Вот его настоящий вывод для этого файла — сначала тексты, которые он кладёт в сборку, потом то, во что превратился колбэк onUpdate:
Четыре текста — четыре функции, которые уедут в UI-рантайм: колбэк onEnd (swipeRowTsx1), колбэк onUpdate (swipeRowTsx2), колбэк пружины внутри onEnd (swipeRowTsx3) и функция стиля (swipeRowTsx4). Устройство знакомо по прошлой статье: __closure с внешними именами и __initData.code с текстом, где эти имена читаются из this.__closure. Новое — как плагин их нашёл. Директивы нет, и он узнал функции по месту, куда их передали. Таких мест у плагина список:
| Где стоит функция | Что плагин делает worklet-ом |
|---|---|
| хуки Reanimated | первый аргумент useAnimatedStyle, useAnimatedProps, useDerivedValue, useFrameCallback, useAnimatedScrollHandler; оба первых — у useAnimatedReaction |
| анимации | колбэк окончания: третий аргумент withTiming и withSpring, второй — у withDecay, четвёртый — у withRepeat |
| переходы между рантаймами | первый аргумент scheduleOnUI, runOnUISync, runOnUIAsync и прежнего runOnUI |
| жесты gesture-handler | аргумент методов onBegin, onStart, onUpdate, onChange, onEnd, onFinalize, onTouchesDown, onTouchesMove, onTouchesUp, onTouchesCancelled, вызванных у объекта жеста |
| где угодно | функцию, у которой первой строкой тела стоит "worklet" |
Отсюда правило чтения: во втором рантайме выполняется функция, переданная в одно из этих мест, вместе со всем, что она вызывает изнутри. Остальной компонент — вызовы хуков, сборка объекта жеста, разметка — обычный React в JS-потоке. Место решает всё: та же стрелка, переданная в useEffect, осталась бы обычной функцией.
Теперь прочитаем замыкания — что поедет с каждой функцией. У onUpdate одно имя, x. У onEnd пять: WIDTH, x, withSpring, scheduleOnRN и onDelete. Число WIDTH уедет копией, снятой однажды, как порог в прошлой статье. withSpring в исходнике Reanimated сам помечен директивой "worklet" и переезжает целиком. x — shared value, о нём следующий раздел. А onDelete — обычная функция JS из родителя, и перенести её нельзя по той же причине, по которой postMessage не принял функцию: текста в сборке у неё нет, окружение — в чужой куче. Исключения при переносе не будет: react-native-worklets кладёт на её место заглушку — «удалённую функцию». Но вызвать заглушку в UI-рантайме нельзя, она бросает «[Worklets] Tried to synchronously call a Remote Function…» — это вторая задача. Её можно только отдать scheduleOnRN, и тот поставит вызов в очередь JS-потока — как второй поток нашей миниатюры ставил в очередь свой ответ.
И последнее, что видно из вывода: объект жеста собирается в каждой отрисовке, а с ним и worklet-ы. Значит, onDelete в их замыкании — из этой отрисовки. Ловушки второй задачи статьи про жесты — колбэков, навсегда видящих первую отрисовку, — здесь нет: она держалась на PanResponder, созданном однажды.
Теперь можно ответить на вопрос, которым кончилась статья про жесты. Там строку вёл setValue из колбэка PanResponder, а колбэк выполнялся в JS-потоке: занят поток — строка стоит. Нативный драйвер не выручал: он увозит только описание из своего словаря, а жест на ответчике рождается в JS. Проследим касание в строке на Reanimated.
| Шаг | Где | Кто делает |
|---|---|---|
| палец сдвинулся; жест проверяет пороги: 10 точек по горизонтали — взять, 10 по вертикали — отказаться | главный поток | нативный код gesture-handler: RNPanHandler.m на основе UIPanGestureRecognizer на iOS, PanGestureHandler.kt под управлением GestureHandlerOrchestrator.kt на Android |
| событие жеста приходит колбэку | UI-рантайм | обработчик, который GestureDetector получил от Reanimated.useEvent для событий onGestureHandlerEvent и onGestureHandlerStateChange |
x.value = Math.min(0, e.translationX) | UI-рантайм | worklet onUpdate; запись сразу на хозяине |
| пересчёт стиля | UI-рантайм | функция из useAnimatedStyle: Reanimated выполняет её снова, когда меняется shared value, которое она читает |
новый transform попадает в вид | UI-рантайм | global._updateProps(…) в src/updateProps/updateProps.ts — мимо рендера React |
JS-потока в этой цепочке нет ни на одном шаге. Пусть он занят разбором ответа — строка всё равно едет за пальцем. В него попадает только то, что вы туда отправили сами: scheduleOnRN(onDelete) в конце пружины. Если JS-поток занят, подход исчезнет из списка позже, но строка к этому моменту уже за краем, и палец этого не заметит.
Второй шаг видно в исходнике gesture-handler. Раздаёт события колбэкам функция, которая сама — worklet:
Колбэк, который не стал worklet-ом, отсюда не вызвать — он получит только предупреждение. Если нужно наоборот, у жеста есть модификатор .runOnJS(true): тогда колбэки выполняются в JS-потоке, и строка снова ждёт его, как на PanResponder. А если колбэки случайно смешались — часть worklet-ы, часть нет, — отладочная сборка печатает ошибку «Some of the callbacks in the gesture are worklets and some are not…».
| PanResponder и Animated | нативный драйвер | gesture-handler и Reanimated | |
|---|---|---|---|
| кто распознаёт жест | система ответчика в JS | жест остаётся на ответчике в JS | нативный код в главном потоке |
| где выполняется код кадра | JS-поток | нигде: в натив уехало описание | UI-рантайм |
| что можно сделать в кадре | что угодно | только узлы словаря: interpolate, сложение, пружина… | любой код, который плагин сделал worklet-ом |
| занятый JS-поток | строка стоит | анимация идёт, строка под пальцем стоит | строка едет |
| как вернуться в JS | всё и так в JS | события к слушателям | scheduleOnRN |
Первая статья главы начиналась с провала: CSS-переход transition в стиле React Native на телефоне не двигает ничего — ключа нет в списке атрибутов, и телефон молча его отбрасывает, а едет полоса только в предпросмотре, силами браузера. В Reanimated 4 этот стиль вернулся. Компоненты Reanimated — Animated.View и всё, что сделано createAnimatedComponent, — понимают в стиле свойства CSS-переходов, и полосу отдыха можно написать почти как в вебе:
Имена — из src/css/types/transition.ts: transitionProperty, transitionDuration, transitionTimingFunction, transitionDelay, есть и сокращение transition; длительность — число миллисекунд или строка вроде "300ms". Обычному View из react-native эти ключи по-прежнему ничего не говорят. И переход тянет ширину — то, чего не умел нативный драйвер: его список держал только свойства, которые не трогают раскладку. У переходов Reanimated список свой, в C++ (Common/cpp/reanimated/CSS/InterpolatorRegistry.cpp), и width с height в нём есть. Кадры такого перехода считает C++ Reanimated, а не ваш код: читается он так же, как описание нативного драйвера, — данные, отданные один раз.
Осталась сводка: три среды, в которых может оказаться строка подхода.
| Предпросмотр учебника | Веб-версия на Expo | Телефон | |
|---|---|---|---|
| Reanimated | нет: пример — один файл без сборщика | веб-сборка: модуль на JS вместо C++ | модуль на C++ и UI-рантайм |
| worklet | миниатюра: текст и копия замыкания в Worker | обычная функция в том же потоке, в ближайшем кадре | текст из сборки и копия замыкания в UI-рантайме |
| shared value | миниатюра: хозяин в Worker, чтение отсюда промисом | объект с полем value в том же потоке | хозяин в UI-рантайме, чтение из JS синхронное |
| жест | PanResponder и палец-мышь | gesture-handler по событиям браузера | нативный распознаватель, колбэки в UI-рантайме |
| занятый JS-поток | стоит всё, кроме Worker | стоит всё: поток один | строка едет, ждёт только scheduleOnRN |
На телефоне иначе: колбэки жеста и функция стиля выполняются в UI-рантайме в главном потоке, и занятый JS-поток строку не останавливает. В веб-версии на Expo второго рантайма нет: жест, стиль и ваш код делят один поток. Плавность Reanimated проверяют только на телефоне.
Shared value поверх второго потока — в том виде, в каком он лежит в подложке первой задачи, сразу за вторым потоком из прошлой статьи. Глава на этом кончается, и дальше миниатюра не едет: следующая глава — про нативные модули.
- Reanimated в предпросмотре не исполняется: у примера нет сборщика и Babel, собранный как для телефона пакет останавливается на
TurboModuleRegistry, а веб-сборка обходится модулем на JS без второго рантайма — worklet там обычная функция, shared value — обычный объект. - Директива
"worklet"в коде на Reanimated редкость: плагин делает worklet-ами функции по месту — первый аргумент хуков Reanimated, колбэки окончания анимаций, методы жеста вродеonUpdateиonEnd, функции переходов между рантаймами. В Expo SDK 57 плагин подключает babel-preset-expo, если пакет установлен. - Обычную функцию JS в UI-рантайм не перенести: в замыкании worklet-а она становится удалённой функцией, синхронный вызов которой бросает. Её отдают
scheduleOnRN— вызов встаёт в очередь JS-потока. - Shared value — значение с одним хозяином в UI-рантайме; в замыкание уезжает гость, по которому хозяина находят. Запись из JS —
scheduleOnUI, чтение из JS — синхронный вызов в UI-рантайм. Отрисовки изменение не вызывает, а новое присваивание отменяет прошлую анимацию. - Жест не проседает, потому что JS-потока нет в его цепочке: распознаёт нативный код gesture-handler, событие получает worklet в UI-рантайме, функция стиля пересчитывает
transformи отдаёт его виду через_updateProps. В веб-версии на Expo всё это делит один поток. - В Reanimated 4 в стиль его компонентов вернулись CSS-переходы —
transitionProperty,transitionDuration, — и они тянут дажеwidth, которую нативный драйвер не брал.
1/4Какие функции в строке подхода на Reanimated плагин делает worklet-ами, хотя директивы "worklet" в них нет?
2/4Почему Reanimated не исполняется в предпросмотре учебника?
3/4В JS-потоке выполнили x.value = 5, где x — shared value. Что произошло?
4/4JS-поток на 400 мс занят разбором ответа, а палец тянет строку, написанную на gesture-handler и Reanimated. Что со строкой на телефоне?
Счёт удалённых — у второго потока
В последней задаче прошлой статьи счёт удалённых подходов вернули в этот поток, а второй поток только решал. В Reanimated так не делают: worklet отпускания и решает, и считает сам, записывая shared value, а JS-поток счёт только читает. Сделайте так же. В подложке — второй поток и shared value из статьи: sharedValue и readShared, а hosts — память второго потока. Сейчас счёт скачет, а прочитанный отсюда — ноль.
Число в замыкании едет копией: второй поток прибавляет единицу к своей копии, и она пропадает вместе с вызовом, а переменная этого потока так и остаётся нулём. Счёту нужен один хозяин во втором потоке, а в замыкание — его адрес:
Вывод: 1, 1, 2 и 2. Прибавляет хозяин на месте, поэтому второй вызов видит то, что оставил первый. В Reanimated это const deleted = useSharedValue(0) и deleted.value += 1 внутри onEnd: и чтение, и запись выполняются в UI-рантайме, у хозяина. А deleted.value в JS-потоке прочитается синхронно — без промиса, который понадобился миниатюре.
Строка ушла за край, а подход остался
В строке подхода на Reanimated удаление вызвали прямо в колбэке пружины, без scheduleOnRN. На телефоне длинный свайп увозит строку за край, но подход из списка не исчезает, и в отладочной сборке приходит ошибка react-native-worklets. Найдите причину по выводу плагина из статьи — что лежит в замыкании колбэка — и почините.
Колбэк пружины — worklet: плагин делает им третий аргумент withSpring. В его замыкании onDelete, а это обычная функция JS из родителя. Перенести её в UI-рантайм нельзя, и react-native-worklets переносит заглушку — «удалённую функцию» (в исходнике это cloneNonWorkletFunction в serializable.native.ts). Вызов заглушки в UI-рантайме бросает:
В кавычках — имя функции: в отладочной сборке оно известно, в выпускной там «anonymous». Строка к этому моменту уже за краем — её увезла пружина, а до удаления из списка дело не дошло. Функцию JS из worklet-а не вызывают, а отдают JS-потоку:
Аргументы идут следом: scheduleOnRN(setSets, next). В коде постарше встретится runOnJS(onDelete)() — то же самое в прежней форме; в react-native-worklets 0.10 он помечен «@deprecated Use `scheduleOnRN` instead.».
Секунды отдыха стоят на месте
Таймер отдыха перевели на Reanimated. Отсчёт идёт — кольцо, которое читает left в useAnimatedStyle, убывает, — а текст всё время показывает «90 с». Почему текст стоит и что напечатает отладочная сборка, если компонент перерисуется по другой причине? Сделайте так, чтобы текст менялся раз в секунду, а кольцо осталось в UI-рантайме.
Изменение shared value отрисовку не вызывает: withTiming меняет хозяина в UI-рантайме, и React об этом не узнаёт. Текст отрисован один раз, с тем, что прочиталось при первой отрисовке, — 90. Если компонент перерисуется по другой причине, left.value прочитается снова — синхронной поездкой в UI-рантайм посреди отрисовки, — и Reanimated предупредит: «[Reanimated] Reading from `value` during component render. Please ensure that you don't access the `value` property nor use `get` method of a shared value while React is rendering a component.» На первой отрисовке он молчит, а строгий режим, в котором печатается это предупреждение, включён по умолчанию. Текст держат в состоянии и обновляют, только когда меняется целая секунда:
Первая функция выполняется в UI-рантайме на каждом изменении left и отдаёт целые секунды, вторая получает новое и прошлое значения и зовёт JS-поток только на смене числа — 90 отрисовок за полторы минуты вместо одной на кадр. Кольцо по-прежнему считает UI-рантайм.