runtimeVersion: замок, который бережёт установленные приложения
Здесь будет встроен видео-разбор темы (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 ещё нет? Упадёт ли приложение при запуске?
При запуске ничего не упало. Экран на месте в обеих сборках, а сломалась одна функция, и только когда до неё дошло дело. Модуль ExpoHaptics пакет берёт через requireOptionalNativeModule, который вместо ошибки даёт null. Проверка стоит в самой функции: модуля нет — значит UnavailabilityError с кодом ERR_UNAVAILABLE. Текст ошибки дословно взят из expo-modules-core 57, только платформу миниатюра подставляет сама: Platform.OS в песочнице — web. Имя модуля в тексте — Haptic без «s», и это не опечатка пересказа: так написано в самом пакете. На телефоне это отклонённый промис в конце отдыха, то есть сбой без падения. Его не заметит ни магазин, ни вы, пока не пожалуется пользователь.
Не все пакеты такие терпеливые. expo-secure-store 57 берёт свой модуль прямо при импорте, это весь его файл ExpoSecureStore.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; ниже она переписана без чтения нативных файлов. Прогноз: что получат пять конфигов на двух платформах?
Политика автора, 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. Запись из терминала:
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, и телефон остаётся на своём. Разбор ответа из частей приехал в подложку из прошлой статьи. Прогноз: что получат четыре запроса?
Телефон на 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, потом — так, как бывает в спешке. Прогноз: какой из пяти запусков сломается и почему?
Первая строка — тот самый отказ, ради которого замок существует. Новая сборка уже стоит, обновление для 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-го. Прогноз: что запустится?
Самое свежее в базе — 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() отдаёт то, что нативная часть знает о проверках: что запущено сейчас и не ждёт ли скачанное обновление перезапуска.
Хук здесь самый обычный, разбирать его нечего — это часть 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.
Сборка 1.2.0 получила upd-0912, собранное под натив 1.1.0, — ровно та поломка, от которой замок бережёт, только в обратную сторону: если 1.2.0 убрала модуль, старый JS упадёт. e1b07d получила его же, потому что строка на букву «больше» любой строки на цифру. У хеша нет порядка, и «не новее» для него ничего не значит. Замок — только равенство:
Теперь 1.0.0 → upd-0911, 1.1.0 → upd-0912, а 1.2.0 и e1b07d — «обновления нет». Чтобы доставить исправление и старой, и новой сборке, его публикуют дважды, под каждую метку.
Сколько телефонов достанет nativeVersion
Политика nativeVersion склеивает версию приложения и номер сборки. Допишите nativeVersion так, как это делает @expo/config-plugins, и посмотрите, до кого долетит одно обновление, опубликованное из конфига сборки 8.
В getNativeVersion из @expo/config-plugins 57 это одна строка: `${version}(${buildNumber})` на iOS и то же с versionCode на Android.
Получит только сборка 8: у сборки 7 метка 1.0.0(7), у сборки 9 — 1.0.1(9). С этой политикой каждая сборка — отдельный замок, и обновление приходится публиковать под каждую, что ещё стоит у людей. У автора номер сборки растёт сам — в профиле production файла eas.json стоит autoIncrement, — поэтому в его конфиге политика appVersion: метка меняется, только когда поднимают version.