Головна / Статті

Самостійні оновлення OTA для чистого React Native з hot-updater та Supabase

Налаштування оновлень JavaScript через мережу у простому додатку React Native за допомогою hot-updater та Supabase: ініціалізація, функція тихого оновлення, канали, скрипти розгортання та скасування змін.

Видавець: REACTAPP.TOP12 хв читання2830 слів

Джерела: 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 налаштовує бакет зберігання, створює необхідні таблиці та публікує функцію краю 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. Воно використовує два рефи як захисні механізми: один для перевірки під час виконання та інший для вже завантаженого пакету, а також надає реф модального вікна, флаг примусового оновлення та функцію застосування.

    // 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() окремо, щоб користувачі ніколи не перезавантажувалися посеред виконання завдань.
  • Скриптуйте процеси розгортання та визначайте цільову версію на основі оригінального проекту, щоб уникнути помилок із неправильним каналом чи версією.
  • Заздалегідь сплануйте процедуру відновлення перед першим випуском: дізнайтеся, де знаходиться перемикач „Увімкнено“, та починайте ризиковані зміни з невеликого тестового запуску.