runtimeVersion: замок, который бережёт установленные приложения

обновления≈ 15 минут

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

Прошлая статья кончилась на заголовке Expo-Runtime-Version: по нему сервер решает, отдать ли манифест этому телефону. Там обновление меняло одну цифру в JS, и вопрос не вставал. Следующая правка «Подхода» — вибрация в конце отдыха — тянет пакет с нативным кодом, а натив по воздуху не летит. Доедет новый JS до старой сборки — и позовёт модуль, которого в бинарнике нет. runtimeVersion — замок против этого: строка, которую знают и сборка, и обновление, и сверяют их на равенство. Соберём эту сверку на сервере миниатюры, сломаем совпадение в обе стороны и найдём второй замок — в самом телефоне, по исходникам expo-updates 57.

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

Пакет с нативным кодом состоит из двух половин. JS-половину Metro кладёт в бандл, и она полетит по воздуху вместе со всем остальным. Нативная вкомпилирована в бинарник и попадает на телефон только с новой сборкой. Встречаются половины в реестре модулей из главы 9: JS спрашивает модуль по имени, а натив заранее кладёт туда то, что умеет. Реестр лежит в подложке примера вместе с функцией installBuild — она «ставит сборку», то есть кладёт в реестр ровно те модули, что вкомпилированы в бинарник.

Вибрацию даёт expo-haptics. Функция impactAsync ниже переписана с Haptics.ts этого пакета версии 57.0.3, с одной поправкой — она объяснена в комментарии. Прогноз: что увидит человек со сборкой из магазина, где expo-haptics ещё нет? Упадёт ли приложение при запуске?

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

При запуске ничего не упало. Экран на месте в обеих сборках, а сломалась одна функция, и только когда до неё дошло дело. Модуль ExpoHaptics пакет берёт через requireOptionalNativeModule, который вместо ошибки даёт null. Проверка стоит в самой функции: модуля нет — значит UnavailabilityError с кодом ERR_UNAVAILABLE. Текст ошибки дословно взят из expo-modules-core 57, только платформу миниатюра подставляет сама: Platform.OS в песочнице — web. Имя модуля в тексте — Haptic без «s», и это не опечатка пересказа: так написано в самом пакете. На телефоне это отклонённый промис в конце отдыха, то есть сбой без падения. Его не заметит ни магазин, ни вы, пока не пожалуется пользователь.

Не все пакеты такие терпеливые. expo-secure-store 57 берёт свой модуль прямо при импорте, это весь его файл ExpoSecureStore.ts:

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

При пустом реестре requireNativeModule бросает Cannot find native module 'ExpoSecureStore' — этот текст мы видели в главе 9. Импорт выполняется при старте бандла, поэтому приложение падает раньше первого экрана. На такой случай у expo-updates есть страховка — ErrorRecovery. Если ошибка случилась до первой отрисовки, запуск помечается неудачным. Дальше телефон пробует скачать свежее обновление, ждёт его до 5 секунд, а если свежего нет — запускает предыдущее рабочее из своей базы. Не помогло и это — падает. Исходник прямо предупреждает, что это не полная сетка: после первой отрисовки откатов нет, а на ошибки позже 10 секунд после неё страховка вовсе не реагирует. Вибрация из примера до неё не дотягивается: экран к тому времени давно на месте.

По воздуху летит JS-половина пакета. Нативная приходит только с новой сборкой, и никакой бандл её не заменит.

Значит, обновлению нужна метка: для какого набора натива собран его JS. Это и есть runtimeVersion — обычная строка, и сравнивают её целиком. «1.0.0» здесь не номер, а имя. Задают её в конфиге одним из двух способов: строкой или политикой, то есть правилом, по которому строку вычислят. Правила взяты из @expo/config-plugins 57:

В конфигеЧто получитсяКогда меняется
"1"та же строкатолько когда меняете руками
{ policy: "appVersion" } — у автораversion, а если задано — ios.version или android.versionкогда поднимают version
{ policy: "nativeVersion" }version(buildNumber) на iOS, version(versionCode) на Androidс каждым номером сборки
{ policy: "fingerprint" }в конфиге — метка file:fingerprint, само значение — хеш, его считают при сборкекогда меняется натив; это следующая статья
{ policy: "sdkVersion" }строится по sdkVersion из конфигасо сменой SDK

Поле можно задать и для одной платформы: ios.runtimeVersion и android.runtimeVersion перекрывают общее. Разрешает всё это функция getRuntimeVersionAsync; ниже она переписана без чтения нативных файлов. Прогноз: что получат пять конфигов на двух платформах?

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

Политика автора, appVersion, даёт обеим платформам 1.0.0. nativeVersion их разводит: номер сборки у iOS и Android свой, а с ним и runtimeVersion. Конфиг без runtimeVersion даёт null, и это не «подходит любая», а выключенные обновления. Не найдя версии, нативная часть expo-updates пишет в журнал: The expo-updates system is disabled due to an invalid configuration. Ensure a runtime version is supplied. — так в UpdatesController.kt.

Что получит сборка, можно проверить заранее командой из самого expo-updates. В приложении автора ответ зависит от окружения: секция updates вместе с runtimeVersion включается, только когда задан EAS_PROJECT_ID. Запись из терминала:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, приложение автора
{"runtimeVersion":null,"fingerprintSources":null,"workflow":"managed"}
{"runtimeVersion":"1.0.0","fingerprintSources":null,"workflow":"managed"}

workflow: "managed" в ответе стоит не для красоты. Политики appVersion, nativeVersion и sdkVersion работают, только пока папки android и ios генерирует prebuild. В проекте, где эти папки правят руками, resolveRuntimeVersionAsync примет лишь строку или fingerprint, а на остальное ответит ошибкой — в её тексте так и сказано: задайте версию вручную, например "1.0.0" вместо политики.

Дальше строка уезжает в нативный конфиг сборки. На Android это meta-data с именем expo.modules.updates.EXPO_RUNTIME_VERSION в AndroidManifest.xml, на iOS — ключ EXUpdatesRuntimeVersion в Expo.plist. Оттуда её читает expo-updates и шлёт в заголовке Expo-Runtime-Version. Пустую строку FileDownloader.kt просто не отправляет. Обновление получает свою runtimeVersion при публикации — из того же конфига, — и она ложится в одноимённое поле манифеста. Две строки вычисляются в разное время из одного файла, и на этом держится весь замок. Метка file:fingerprint особая: встретив её, Android берёт настоящее значение из файла отпечатка, вшитого в сборку.

Протокол expo-updates отдаёт выбор серверу. Тот обязан ответить самым свежим по времени создания обновлением из тех, что подходят под все условия заголовков запроса. Главных условий два: Expo-Runtime-Version и Expo-Platform. Сервер из прошлой статьи отдавал один манифест всем подряд, а теперь напишем тот, что смотрит на заголовки. Как ответить, когда подходящего нет, мы уже знаем: 204 с expo-protocol-version, и телефон остаётся на своём. Разбор ответа из частей приехал в подложку из прошлой статьи. Прогноз: что получат четыре запроса?

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

Телефон на 1.0.0 получил upd-0911, хотя upd-0912 свежее: оно собрано для 1.1.0. iOS-телефону на 1.1.0 не досталось ничего. Для iOS опубликовано только обновление под 1.0.0, и отказ пришёл пустым 204. Запрос без версии получил 400 — это решение нашего сервера: без runtimeVersion выбирать не из чего. Настоящий телефон без неё такого запроса и не пошлёт, ведь обновления у него выключены.

Сервер не знает, что изменилось в нативе. Он сравнивает две строки, и всё его знание о совместимости — это их равенство.

Теперь соединим сервер с телефоном. В подложке лежит сервер из прошлого раздела с одним опубликованным обновлением upd-0911 — это таймер на 90 секунд из прошлой статьи. Рядом реестр модулей и JS обновлений: всё, что новее upd-0911, вибрирует в конце отдыха. Автор добавил expo-haptics, и ему нужно выпустить две вещи: новую сборку в магазин и обновление с вибрацией. Сначала сделаем честно, подняв version до 1.1.0, потом — так, как бывает в спешке. Прогноз: какой из пяти запусков сломается и почему?

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

Первая строка — тот самый отказ, ради которого замок существует. Новая сборка уже стоит, обновление для 1.0.0 на сервере лежит, но ей не досталось ничего, и она работает на вшитом JS. После честной публикации каждая сборка получила своё: старая — upd-0911 без вибрации, новая — upd-0915. А когда version забыли поднять, метка 1.0.0 совпала у старой сборки и у обновления с вибрацией, и замок открылся. Сервер при этом ничего не нарушил: строки равны. Сломалась сама метка — она перестала говорить правду о нативе.

Отсюда правило политики appVersion: version поднимают при каждой смене натива. Это новый пакет с нативным кодом, переход на новый SDK, правка плагинов конфига и полей, которые prebuild переносит в нативные файлы. Забыть легко, а проверки нет: expo-updates не знает, что лежит внутри сборки. Этот пробел закрывает политика fingerprint — она вычисляет метку по самому нативу. О ней следующая статья.

Сервер — не единственный, кто сверяет runtimeVersion. Телефон тоже сверяет, но не там, где ждёшь. При каждом запуске expo-updates выбирает из своей базы, что запускать; это делает LauncherSelectionPolicyFilterAware, устроенный одинаково на Kotlin и Swift. Возьмём телефон, где поверх сборки 1.0.0 из магазина встала 1.1.0. В базе остались вшитое обновление прежней сборки и скачанное для неё upd-0911. И оно свежее новой вшитой: сборку сделали 10 сентября, а обновление вышло 11-го. Прогноз: что запустится?

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

Самое свежее в базе — upd-0911, то есть JS для натива 1.0.0. Без замка телефон запустил бы его на бинарнике 1.1.0. Фильтр оставил только записи с runtimeVersion 1.1.0, и запустилось вшитое. Прежнее вшитое отпало ещё раньше, по второй проверке из DatabaseLauncher.kt: вшитой считается только запись, чей id совпадает с обновлением этого бинарника. Когда скачалось upd-0915 для 1.1.0, выбор перешёл к нему: замок пускает свежее, если оно своё.

Выбор этот делается один раз — при старте, до первой строки вашего JS. Значит, обновление меняет бандл целиком: и экраны, и дерево React, и всё состояние в нём. Пока приложение открыто, оно живёт на старом бандле, даже если новый уже скачан и лежит в базе; замена происходит на следующем холодном старте или по Updates.reloadAsync(). И это не переход между экранами из части IV: JS-рантайм стартует заново, состояние компонентов и стек навигации теряются, как после убийства процесса. Поэтому перезапуск предлагают, а не делают молча посреди тренировки.

Свою метку приложение знает про себя само: Updates.runtimeVersion, рядом Updates.channel и Updates.updateId. А хук useUpdates() отдаёт то, что нативная часть знает о проверках: что запущено сейчас и не ждёт ли скачанное обновление перезапуска.

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

Хук здесь самый обычный, разбирать его нечего — это часть IV. Ново другое: значения приходят не из состояния приложения, а из нативной части, и меняются не между отрисовками, а между запусками. В отладочной сборке expo-updates выключен, и у двух полей это прописано в документации пакета: updateId — null в среде разработки, channel — null у отладочной сборки и в Expo Go. У isEmbeddedLaunch такой оговорки нет: документация говорит про него только то, что оно различает вшитое в сборку обновление и скачанное с сервера. Проверять экран «О приложении» осмысленно только на сборке для магазина или превью.

Для своего сервера важна ещё одна деталь, обещанная в прошлой статье: при проверке и загрузке runtimeVersion не сравнивается. Брать ли новое обновление, решает LoaderSelectionPolicyFilterAware по времени создания, фильтрам, адресу и заголовкам. Поля runtimeVersion в его коде нет — ни на Kotlin, ни на Swift. Пропусти сервер такое обновление, и если оно свежее запущенного, проверка ответит isAvailable: true. Телефон скачает файлы и запишет обновление в базу — а запускать не станет. Замок при запуске не даст сломать приложение, но трафик и место уже потрачены. Поэтому сверка на сервере обязательна, а не вежлива.

Замков два: сервер не отдаёт чужое, телефон не запускает чужое. Ни один не знает, что на самом деле в нативе, — оба верят метке.

  • По воздуху летит только JS-половина пакета. Если новый бандл доедет до сборки без нужного натива, requireOptionalNativeModule даст null и функция бросит ERR_UNAVAILABLE, а requireNativeModule при импорте уронит приложение на старте.
  • runtimeVersion — строка-метка набора натива. Её задают в конфиге строкой или политикой: appVersion, nativeVersion, fingerprint. Без неё expo-updates выключается. Проверить значение заранее можно командой npx expo-updates runtimeversion:resolve.
  • Одна и та же метка попадает в нативный конфиг сборки и в манифест обновления; телефон шлёт её в Expo-Runtime-Version. Сервер отдаёт самое свежее обновление с точно такой же меткой и платформой, иначе — «обновления нет».
  • Замок сравнивает строки, а не натив. Забыли поднять version при новом нативном пакете — метки совпали, и старые сборки получили JS, которому нечем работать.
  • Второй замок стоит в телефоне, при запуске: LauncherSelectionPolicyFilterAware запускает только обновления своей runtimeVersion. При проверке и загрузке метка не сравнивается, поэтому сверка на сервере обязательна.
  • Обновление подменяет бандл целиком, вместе с деревом React и его состоянием. Скачанное ждёт холодного старта или Updates.reloadAsync() — это перезапуск рантайма, а не переход между экранами.

1/4Автор добавил expo-haptics, version не поднял и выкатил обновление с вибрацией. Что будет на телефонах со сборкой из магазина?

2/4Телефон с runtimeVersion 1.1.0 спрашивает сервер, а обновления есть только для 1.0.0. Что должен ответить сервер?

3/4Где сам телефон сравнивает runtimeVersion обновления со своей?

4/4Почему сервер сравнивает runtimeVersion на точное равенство, а не пускает всё, что «не новее сборки»?

Замок — не порядок

Коллега переписал выбор на сервере: «новой сборке подойдёт и обновление для старой, всё, что не новее». Метки сравниваются строками через <=. Посмотрите, что получат четыре сборки, объясните каждую ошибку и почините pick.

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

Сборка 1.2.0 получила upd-0912, собранное под натив 1.1.0, — ровно та поломка, от которой замок бережёт, только в обратную сторону: если 1.2.0 убрала модуль, старый JS упадёт. e1b07d получила его же, потому что строка на букву «больше» любой строки на цифру. У хеша нет порядка, и «не новее» для него ничего не значит. Замок — только равенство:

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

Теперь 1.0.0 → upd-0911, 1.1.0 → upd-0912, а 1.2.0 и e1b07d — «обновления нет». Чтобы доставить исправление и старой, и новой сборке, его публикуют дважды, под каждую метку.

Сколько телефонов достанет nativeVersion

Политика nativeVersion склеивает версию приложения и номер сборки. Допишите nativeVersion так, как это делает @expo/config-plugins, и посмотрите, до кого долетит одно обновление, опубликованное из конфига сборки 8.

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

В getNativeVersion из @expo/config-plugins 57 это одна строка: `${version}(${buildNumber})` на iOS и то же с versionCode на Android.

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

Получит только сборка 8: у сборки 7 метка 1.0.0(7), у сборки 9 — 1.0.1(9). С этой политикой каждая сборка — отдельный замок, и обновление приходится публиковать под каждую, что ещё стоит у людей. У автора номер сборки растёт сам — в профиле production файла eas.json стоит autoIncrement, — поэтому в его конфиге политика appVersion: метка меняется, только когда поднимают version.