Самостоятельные обновления OTA для чистого React Native с hot-updater и Supabase
Настройка обновлений JavaScript через беспроводное соединение в простом приложении React Native с использованием hot-updater и Supabase: инициализация, хук для тихого обновления, каналы, скрипты развертывания и возврата к предыдущей версии.
Источники: Ship JavaScript Fixes in Minutes, Not Days: OTA Updates in React Native with Hot Updater
Однострочное исправление на JavaScript в приложении React Native не должно требовать дней ожидания рассмотрения в App Store, однако именно это происходит, когда каждое изменение развертывается через новый бинарный файл. Обновления по воздуху (OTA) позволяют напрямую доставлять изменения, сделанные на JavaScript, в установленные приложения, тогда как изменения, связанные с нативным кодом, по-прежнему проходят через магазины приложений. В этом руководстве описана производственная конфигурация, построенная на библиотеке open-source hot-updater с самостоятельно размещенным сервером Supabase: установка, однократная инициализация, скрытый механизм обновления с пользовательским запросом, каналы для отдельных версий приложений, скрипты развертывания с возможностью повторения и механизмы отката, обеспечивающие безопасность всей системы.
Примером может служить логистическое приложение, работающее на чистом React Native в рамках Новой архитектуры (версия 0.85 на момент написания текста), где водителям необходимо быстро получать исправления, и никто не хочет полагаться на инструментарий Expo.
Что на самом деле заменяет обновление OTA
Каждый бинарник React Native на самом деле представляет собой два компонента, упакованных вместе:
- Нативный код: оболочки Android и iOS, нативные модули, разрешения и всё, что находится в папках
android/иios/. - Пакет JavaScript: экраны, компоненты, бизнес-логика, а также большинство рутинных исправлений ошибок и работ по добавлению функций.
Выпуск через магазин превращает оба этих компонента в файлы формата APK или IPA. Проблема заключается в том, что большинство повседневных работ касаются только части, связанной с JavaScript. Неправильно названный кнопка, ошибочный параметр API или небольшая корректировка макета не требуют создания нового нативного кода, но традиционный процесс выпуска всё равно заставляет выполнять полную сборку и проходить цикл проверки.
Обновление через OTA отделяет пакет данных от этого цикла. Вместо того чтобы считать встроенный JavaScript постоянным, приложение запрашивает у сервера обновлений наличие более нового пакета, загружает его и заменяет старый при следующей загрузке. Не требуется ни отправка в магазин, ни переустановка; исправление поступает к пользователям при следующем запуске приложения.
Все дальнейшие действия регулируются одним правилом:
Изменения, касающиеся только JavaScript: их можно передавать через OTA. Изменения в нативном коде (новая зависимость, обновление версии, новый нативный модуль): для этого всё равно требуется настоящая сборка для магазина.
OTA не заменяет сборки нативного кода. Оно предотвращает ситуацию, когда сборки нативного кода мешают выпуску обновлений JavaScript. Перед каждым развертыванием через OTA необходимо проверить, изменился ли нативный код с момента последней версии бинарного файла; если да, новый пакет может вызвать сбои на устройствах, где всё ещё работает старая версия.
Почему лучше использовать hot-updater вместо альтернатив
В течение многих лет Microsoft CodePush считался стандартным решением. Поскольку App Center и CodePush были устарожены, командам пришлось выбирать замену. Во время оценки реальные варианты выглядели следующим образом (перед принятием решения убедитесь в текущем статусе каждого из них, поскольку рынок быстро меняется):
- CodePush / App Center: устарожены, поэтому не являются вариантом.
- EAS Update: зрелое и надежное решение, но оно еще сильнее связывает простой проект с экосистемой Expo.
- Revopush и Codemagic CodePush: подходящие замены, но это платные услуги.
- Firebase Storage в качестве бэкенда: доступно только в платном плане Blaze.
- hot-updater: бесплатный вариант с открытым исходным кодом, который можно разместить самостоятельно и поддерживает Новую архитектуру.
hot-updater оказался лучшим по нескольким конкретным причинам:
- Отсутствие затрат на лицензирование: это программное обеспечение с открытым исходным кодом, без начисления платы за каждого активного пользователя.
- Поддержка новой архитектуры: обязательное требование для приложений с включенной новой архитектурой.
- Возможность подключения бэкенда: хранилище и база данных выбираются с помощью плагинов. Supabase отлично подходит, поскольку один проект обеспечивает хранилище, Postgres и функцию на краевом сервере в качестве сервера обновлений, причем все это доступно в рамках щедрого бесплатного тарифа.
- Веб-консоль плюс CLI: вы не ограничены только рабочими процессами в терминале.
- Мгновенное откатывание: неисправный пакет можно отключить одним нажатием, без необходимости повторной развертки или проверки хранилища.
- Поэтапное внедрение и группы устройств: пакет может сначала быть запущен на небольшой группе устройств, например 1%, затем постепенно расширяться до 100%, или быть направлен на конкретную группу, указанную как
qa.
Учтите один момент: библиотека бесплатна, но хранение и пропускная способность для ваших пакетов — это ваша ответственность. Для небольшого или среднего приложения обычно достаточно бесплатного тарифа Supabase; ситуация с большой аудиторией, загружающей пакеты часто, требует других расчетов.
Интеграция hot-updater в чистое приложение React Native
Интеграция состоит из трех этапов: установка пакетов и настройка бэкенда, однократная инициализация библиотеки и настройка процесса обновления в интерфейсе пользователя.
Установка пакетов и настройка Supabase
Пакет для выполнения приложения входит в обычные зависимости, тогда как CLI, плагин для сборки в чистом режиме и плагин Supabase относятся к зависимостям разработки.
npm install @hot-updater/react-native
npm install -D @hot-updater/bare @hot-updater/supabase hot-updater
Далее позвольте CLI собрать для вас базу данных серверной части приложения.
npx hot-updater init
Команда init создает контейнер хранения, формирует необходимые таблицы и публикует функцию типа edge function update-server, которую будет вызывать приложение. Кроме того, она создает файл .env с учетными данными. Немедленно добавьте этот файл в файл .gitignore и описайте, как его можно сгенерировать заново на основе ключей service-role от Supabase, поскольку тем, кто будет работать с чистой копией проекта, потребуется его создать заново.
Инициализируйте один раз в точке входа
HotUpdater.init() должен находиться в области видимости модуля в файле index.js и выполняться один раз до любого другого использования библиотеки. Именно он указывает hot-updater, где искать обновления. Если его нет, каждая последующая проверка на наличие обновлений не имеет URL сервера и молча ничего не делает, что является одним из самых запутанных способов сбоев при отладке.
Импорты подключают саму библиотеку, react-native-config для значений, специфичных для среды, а также собственную службу аналитики приложения.
// index.js
import { HotUpdater } from '@hot-updater/react-native';
import Config from 'react-native-config';
import { analytics, AnalyticsEvent } from '@services';
Сама конфигурация зачитывает URL функции обработки крайних случаев и стратегию обновлений из переменных среды. Обработчик onError преднамеренно минимален: он запускается во время инициализации, поэтому не должен зависеть от частей приложения, которые еще могут не быть готовы. Его единственная задача — зафиксировать, что инициализация провалилась.
HotUpdater.init({
baseURL: Config.HOT_UPDATER_SUPABASE_FUNCTION_URL, // your edge function URL
updateStrategy: Config.HOT_UPDATER_UPDATE_STRATEGY, // e.g. 'appVersion'
onError: (error) => {
// Bootstrap-level handler — keep it dependency-free.
// Just enough to know init itself failed.
if (__DEV__) console.log('🔥 [OTA] init error', error);
analytics.track(AnalyticsEvent.OTA_INIT_ERROR, {
message: String(error?.message ?? error),
});
},
});
Обратите внимание на разделение ответственностей здесь. Функция init определяет источник обновлений. Решение о том, должна ли приложение вообще проверять обновления, относится к вашей собственной логике, например, к использованию флага функциональности на удаленном сервере. Разделение этих двух решений облегчает отключение функции OTA в определенной среде без необходимости изменять код запуска.
Функция для тихого обновления вместо wrap()
Библиотека поставляет компонент высшего порядка HotUpdater.wrap(), который автоматически обрабатывает проверку, загрузку и применение обновлений. Если вам нужно лишь «проверить при запуске, обновиться и закончить», используйте его — это самый быстрый способ.
Приложения для производственного использования часто требуют более строгого контроля: загрузка в фоновом режиме, отображение собственного модального окна, ограничение частоты проверок и защита всей системы с помощью флага функции. Для этого лучше использовать пользовательский хук, который вручную управляет API. Приведённая ниже версия — это упрощённый ядро, написанное на TypeScript. Оно использует два объекта типа ref в качестве защитных механизмов: один для проверки в процессе выполнения и другой для уже загруженного пакета; кроме того, предоставляет ссылку на модальное окно, флаг принудительного обновления и функцию применения.
// useOtaUpdater.ts
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, Modal } from 'react-native';
import { HotUpdater } from '@hot-updater/react-native';
export const useOtaUpdater = () => {
const otaModalRef = useRef<Modal | null>(null);
const stagedRef = useRef(false); // a bundle is downloaded & waiting
const busyRef = useRef(false); // a check is in flight
const [otaForced, setOtaForced] = useState(false);
// 1. Check the server + stage the bundle in the background (no reload yet).
const runSilentOtaCheck = useCallback(async () => {
if (busyRef.current || stagedRef.current) return;
busyRef.current = true;
try {
const updateInfo = await HotUpdater.checkForUpdate({
updateStrategy: 'appVersion',
requestTimeout: 20000, // default 5s is too tight for a cold edge function
});
if (!updateInfo) return; // null → up to date (or check failed)
// Download & stage to disk - does NOT reload the app.
await updateInfo.updateBundle();
stagedRef.current = true;
setOtaForced(!!updateInfo.shouldForceUpdate);
otaModalRef.current?.openModal(); // ask the user to apply it
} finally {
busyRef.current = false;
}
}, []);
// 2. User tapped "Update" → swap the JS bundle and restart.
const applyOtaUpdate = useCallback(async () => {
await HotUpdater.reload(); // this is what actually applies the update
}, []);
// 3. Triggers: check on launch and whenever the app returns to foreground.
useEffect(() => {
const timer = setTimeout(runSilentOtaCheck, 3000); // backstop launch check
const sub = AppState.addEventListener('change', (s) => {
if (s === 'active') runSilentOtaCheck();
});
return () => {
clearTimeout(timer);
sub.remove();
};
}, [runSilentOtaCheck]);
return { otaModalRef, otaForced, applyOtaUpdate };
};
Некоторые решения, принятые в этом хуке, основаны на реальных проблемах производственного использования:
requestTimeout: 20000: стандартное время ожидания составляет 5 секунд. Функция Supabase edge, запускаемая впервые, часто работает дольше этого времени, из-за чего запрос истекает, и функцияcheckForUpdateвозвращаетnull. Такой результат невозможно отличить от ситуации, когда данные уже актуальны, поэтому обновления так и не приходят. Увеличение времени ожидания до 20 секунд позволяет компенсировать задержку при первом запуске.- Сначала загрузить, потом применить: функция
updateBundle()загружает новый пакет на диск без перезагрузки каких-либо элементов. Только функцияreload()заменяет старый пакет на новый. Разделение этих действий гарантирует, что процесс доставки не будет прерван внезапной перезагрузкой; загрузка происходит на фоне, а пользователь сам решает, когда применить обновления.
busyRef или stagedRef. Две проверки никогда не выполняются одновременно, и пакет, уже находящийся в состоянии подготовки, больше не загружается.Стоит обратить внимание на два небольших момента. Проверка с задержкой в три секунды служит резервным механизмом при запуске на случай, если первое событие состояния приложения active происходит раньше, чем хук успевает подписаться на него. Кроме того, вызов openModal() предполагает, что переменная ref указывает на пользовательский компонент модала, обеспечивающий доступ к этому методу, а не на встроенный в React Native компонент Modal; поэтому тип переменной ref необходимо адаптировать под собственный компонент.
Подключите хук и отрисуйте интерфейс
Вызовите хук, расположенный рядом с корнем приложения, и отрисуйте единственное модальное окно, которое будет обрабатывать оба типа обновлений. Опциональное обновление можно отклонить; принудительное скрывает кнопку отмены и не может быть закрыто.
// App.tsx
const { otaModalRef, otaForced, applyOtaUpdate } = useOtaUpdater();
return (
<InfoModal
modalRef={otaModalRef}
subText={otaForced ? 'A required update is available.' : 'An update is available.'}
onAcceptPress={applyOtaUpdate}
showCancelButton={!otaForced} // forced updates can't be dismissed
dismissable={!otaForced}
/>
);
Поскольку значение shouldForceUpdate поступает с сервера, вы сами решаете для каждого пакета, могут ли пользователи отложить его выполнение. Откладывайте принудительные обновления только для исправлений, которые действительно не могут ждать.
Присвойте каждую нативную версию приложения определенному каналу
Последний элемент указывает каждому бинарному файлу, к какому каналу он относится — например, development, stage или production. Поскольку значение встраивается непосредственно в бинарный файл, сервер обновлений может определить, какую последовательность пакетов должен получить соответствующий бинарный файл. Для Android необходимо установить значение HOT_UPDATER_CHANNEL для каждой версии продукта в манифесте; для iOS — указать его для каждой целевой платформы в файле Info.plist. Настройте его один раз для каждой версии или целевой платформы, и оно останется неизменным.
Почти всех ждут две проблемы. OTA работает только в релизных версиях, а не в версиях для отладки. Кроме того, каждая новая нативная версия требует одной процедуры развертывания для регистрации ее в качестве базового пакета; без этого следующее развертывание будет сообщать
Skipped (no compatible base bundles).
Развертывание обновления из командной строки
После подключения всех компонентов для публикации изменений в JavaScript достаточно одного командного приказа.
npx hot-updater deploy -p android -t 3.0.0 -c development
Флаги означают следующее:
-p: платформа —androidилиios.-t: версия приложения-цели, с которой совместим пакет, что соответствует вашему значениюversionNameв нативном формате.-c: канал публикации.-f: необязательный флаг; отмечает обновление как принудительное, чтобы оно сразу же было применено.
Сохраните свой JavaScript, запустите процедуру развертывания, и в следующий раз, когда кто-то откроет версию приложения с этим обновлением на указанном канале, оно уже будет готово к использованию.
Интеграция процедур развертывания в скрипты для отдельных сред
Ввод такой команды для двух платформ через три канала быстро становится утомительным, и легко случайно опубликовать контент в неверный канал. Небольшой скрипт Node, управляемый переменными окружения, доступными через npm scripts, устраняет оба этих проблема.
Записи в package.json задают канал и необязательный флаг принудительной загрузки, после чего вызывается тот же скрипт.
{
"scripts": {
"ota:dev": "OTA_CHANNEL=development node scripts/ota-deploy.mjs",
"ota:stage": "OTA_CHANNEL=stage node scripts/ota-deploy.mjs",
"ota:prod": "OTA_CHANNEL=production node scripts/ota-deploy.mjs",
"ota:dev:force": "OTA_CHANNEL=development OTA_FORCE=true node scripts/ota-deploy.mjs",
"ota:stage:force": "OTA_CHANNEL=stage OTA_FORCE=true node scripts/ota-deploy.mjs",
"ota:prod:force": "OTA_CHANNEL=production OTA_FORCE=true node scripts/ota-deploy.mjs"
}
}
В файле scripts/ota-deploy.mjs окружение предоставляет как канал, так и опцию принудительной загрузки; затем скрипт извлекает целевую версию из файла android/app/build.gradle, если только переменная OTA_VERSION не переопределяет её, и последовательно развертывает обе платформы. Функция execSync с параметром stdio: 'inherit' выводит результаты работы CLI в ваш терминал и вызывает ошибку при ненулевом коде выхода, поэтому сбой при развертывании для Android прерывает процесс до попытки развертывания для iOS.
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
// Pull the native versionName straight from build.gradle so -t is never stale.
function getVersion() {
if (process.env.OTA_VERSION) return process.env.OTA_VERSION;
const gradle = readFileSync('./android/app/build.gradle', 'utf8');
const match = gradle.match(/versionName\s+["']([^"']+)["']/);
if (!match) throw new Error('Could not find versionName in build.gradle');
return match[1];
}
const version = getVersion();
const channel = process.env.OTA_CHANNEL ?? 'development';
const force = process.env.OTA_FORCE === 'true' ? '-f' : '';
for (const platform of ['android', 'ios']) {
const cmd = `npx hot-updater deploy -p ${platform} -t ${version} -c ${channel} ${force}`.trim();
execSync(cmd, { stdio: 'inherit' });
}
Ежедневная работа тогда сводится к нескольким командам.
npm run ota:dev # ship to the dev channel, both platforms
npm run ota:prod # ship to production
npm run ota:prod:force # ship to production, force immediately
Определение версии с помощью Gradle вместо ручного ввода исключает целый класс ошибок при выпуске: невозможно случайно опубликовать пакет для устаревшей целевой версии. Однако следует учитывать предположения, лежащие в основе этого подхода. Скрипт использует параметр versionName Android для обеих платформ, поэтому он будет работать только в том случае, если версия для iOS синхронизирована. Если между ними возникают расхождения, необходимо указывать версию iOS отдельно или явно задавать значение OTA_VERSION. Синтаксис VAR=value внутри скриптов npm также предполагает наличие POSIX-шелла; в Windows потребуется инструмент вроде cross-env.
Откат и поэтапные выпуски в качестве резервного варианта
Быстрое устранение проблемы — это лишь половина дела. Вторая половина заключается в ограничении зоны поражения при том, что какой-то пакет оказывается неисправным, и именно здесь пригождается консоль hot-updater.
Мгновенное откатывание
У каждого пакета, указанного в консоли, есть переключатель Enabled. Если только что загруженный пакет неисправен, отключите его. Устройства, которые уже установили его, при следующей проверке вернутся к последнему рабочему пакету, без необходимости пересборки, повторной развертки и ожидания от Apple или Google. CLI предоставляет аналогичный способ выхода из ситуации через команду hot-updater rollback, которая возвращает к последнему развернутому пакету.
Именно по этой причине OTA можно с уверенностью использовать: для исправления ошибки требуется всего несколько секунд. Обратите внимание, что откат происходит только тогда, когда устройства снова выполняют проверку, поэтому механизм проверки в фоновом режиме имеет такое же значение для восстановления, как и для доставки пакетов.
Вместо того чтобы сразу отправлять пакет на все устройства, вы можете сначала выпустить его для небольшого процента устройств, изучить отчеты о сбоях и аналитику, а затем постепенно увеличивать долю пользователей до 100%. Группы позволяют нацеливаться на конкретную аудиторию, например внутреннюю группу qa, прежде чем изменения увидят остальные. В сочетании с переключателем «Включено» это превращает рискованное выпускание в контролируемый эксперимент. Проверьте документацию к инструменту hot-updater для получения точных вариантов настройки, поскольку они могут меняться между версиями.
- Рассматривайте OTA как ускоренный способ обновления только для JavaScript; всё, что связано с нативным кодом, по-прежнему требует сборки в магазине, причем каждая новая сборка должна регистрироваться как базовый пакет.
HotUpdater.init() ровно один раз в файле index.js, и разделяйте параметры «где проверять» и «нужно ли проверять».requestTimeout, если ваш сервер обновлений представляет собой безсерверную функцию, поскольку пропущенная проверка будет выглядеть точно так же, как «обновлений нет».updateBundle(), а применяйте их с помощью reload() отдельно, чтобы пользователи не перезагружались посреди выполнения задачи.