Samodzielnie hostowane aktualizacje OTA dla czystego React Native z hot-updaterem i Supabase
Ustawienie aktualizacji JavaScript przez sieć bezprzewodową w prostym aplikacji React Native przy użyciu hot-updater i Supabase: inicjalizacja, cichy hook do aktualizacji, kanały, skrypty wdrażania oraz funkcja cofnięcia.
Źródła: Ship JavaScript Fixes in Minutes, Not Days: OTA Updates in React Native with Hot Updater
Korekta w języku JavaScript składająca się z jednej linii w aplikacji React Native nie powinna wymagać dni oczekiwania na przegląd w App Store, a jednak dokładnie to się dzieje, gdy każda zmiana jest dostarczana poprzez nowy plik binarny. Aktualizacje typu over-the-air (OTA) umożliwiają bezpośrednie dostarczanie zmian zrealizowanych w czystym JavaScriptu do zainstalowanych aplikacji, podczas gdy zmiany typu native nadal muszą przechodzić przez sklepy z aplikacjami. Ten przewodnik opisuje konfigurację przeznaczoną do użycia w produkcji, zbudowaną na bazie otwartej biblioteki hot-updater wraz z samodzielnie hostowanym backendem Supabase: instalację, jednorazową inicjalizację, funkcję cichej aktualizacji z dostosowanym komunikatem, kanały dla poszczególnych wersji aplikacji, skrypty do rozruchu aplikacji oraz mechanizmy odwracania zmian, które zapewniają bezpieczeństwo całego procesu.
Scenariuszem referencyjnym jest aplikacja logistyczna działająca wyłącznie na React Native w ramach Nowej Architektury (wersja 0,85 w momencie pisania tekstu), gdzie kierowcy potrzebują szybkich poprawek, a nikt nie chce polegać na narzędziach Expo.
Czym właściwie zastępuje się aktualizację OTA
Każdy plik binarny React Native składa się w rzeczywistości z dwóch elementów zapakowanych razem:
- Kod natywny: warstwy systemowe Androida i iOS, moduły natywne, uprawnienia oraz wszystko, co znajduje się w katalogach
android/iios/. - Pakiet JavaScripta: ekrany, komponenty, logika biznesowa oraz większość rutynowych poprawek błędów i dodawanych funkcji.
Wydanie w sklepie zamraża oba te elementy w formie pliku APK lub IPA. Problem polega na tym, że większość codziennej pracy dotyczy wyłącznie strony JavaScripta. Błędnie oznaczony przycisk, nieprawidłowy parametr API lub drobna modyfikacja układu nie wymagają nowego kodu natywnego, ale klasyczny proces wydawania nadal zmusza do pełnej kompilacji i cyklu przeglądania.
aktualizacje OTA oddzielają plik pakietowy od tego cyklu. Zamiast traktować wbudowany JavaScript jako stały, aplikacja pyta serwer aktualizacji, czy istnieje nowszy plik pakietowy, pobiera go i zastępuje stary przy następnym załadowaniu. Nie jest potrzebne żadne przesłanie do sklepu ani ponowna instalacja; poprawka dociera do użytkowników przy następnym uruchomieniu aplikacji.
Jedna zasada rządzi wszystkim, co następuje:
Zmiana dotycząca wyłącznie JavaScripta: należy ją przesłać przez OTA. Zmiana natywna (nowa zależność natywna, podniesienie wersji, nowy moduł natywny): nadal wymaga prawdziwej budowy aplikacji dla sklepu.
OTA nie zastępuje budów natywnych. Zapobiega temu, by budowy natywne blokowały wydawanie aktualizacji JavaScripta. Przed każdym wdrożeniem OTA należy sprawdzić, czy coś w obszarze natywnym uległo zmianie od ostatniej wersji binarnej; jeśli tak, nowy plik pakietowy może spowodować awarię na urządzeniach, które nadal używają starej wersji.
Wybór hot-updatera zamiast innych alternatyw
Przez lata Microsoft CodePush był standardowym rozwiązaniem. Po wycofaniu App Center i CodePush z użycia, zespoły musiały znaleźć ich zamiennik. Realistyczne opcje wyglądały w ten sposób podczas oceny (sprawdź aktualny stan każdej z nich przed podjęciem decyzji, ponieważ rynek rozwija się szybko):
- CodePush / App Center: wycofane z użycia, więc nie stanowią opcji.
- EAS Update: dojrzałe i niezawodne, ale ściśle wiążą prosty projekt z ekosystemem Expo.
- Revopush i Codemagic CodePush: sprawdzone zamienniki, ale są to usługi płatne.
- Firebase Storage jako backend: dostępne tylko w płatnym planie Blaze.
- hot-updater: opcja open-source, samodzielnie hostowalna i bezpłatna, która obsługuje nową architekturę.
hot-updater okazał się lepszy z kilku konkretnych powodów:
- Brak kosztów licencji: jest to oprogramowanie otwarte źródłowo, bez opłat za użytkowników miesięcznie.
- Wsparcie nowej architektury: obowiązkowy wymóg dla aplikacji z włączoną nową architekturą.
- Zintegrowany backend: przechowywanie danych i baza danych są wybierane za pomocą pluginów. Supabase doskonale się nadaje, ponieważ jeden projekt zapewnia przechowywanie danych, Postgres oraz funkcję edge służącą jako serwer aktualizacji, wszystko to w ramach hojnego pakietu darmowego.
- Konsola internetowa plus CLI: nie jesteś ograniczony do pracy wyłącznie w terminalu.
- Błyskawiczne cofnięcie zmian: uszkodzony pakiet można wyłączyć jednym kliknięciem, bez konieczności ponownego wdrożenia lub przeglądania przez sklep.
- Etapowe wdrażanie i grupy docelowe: pakiet może najpierw trafić do niewielkiej grupy urządzeń, na przykład 1%, a następnie zwiększyć się do 100%, lub być skierowany do określonej grupy, np.
qa.
Pamiętaj o jednej kwestii: biblioteka jest darmowa, ale przechowywanie i przepustowość dla Twoich plików są Twoją odpowiedzialnością. Dla małej lub średniej aplikacji zazwyczaj wystarcza darmowy plan Supabase; inaczej jest w przypadku dużej bazy użytkowników pobierającej często pliki.
Integracja hot-updatera z prostą aplikacją React Native
Integracja składa się z trzech części: instalacji pakietów i przygotowania backendu, inicjalizacji biblioteki raz oraz skonfigurowania procesu aktualizacji w interfejsie użytkownika.
Zainstaluj pakiety i przygotuj Supabase
Pakiet do działania w czasie rzeczywistym trafia do zwykłych zależności, natomiast CLI, plugin do budowania prostego workflow oraz plugin Supabase należą do zależności rozwojowych.
npm install @hot-updater/react-native
npm install -D @hot-updater/bare @hot-updater/supabase hot-updater
Następnie niech narzędzie CLI stworzy dla Ciebie strukturę backendu.
npx hot-updater init
Polecenie init konfiguruje pojemnik przechowywania, tworzy niezbędne tabele oraz publikuje funkcję brzegową update-server, do której będzie się odwoływać aplikacja. Zapisuje ono również plik .env zawierający dane uwierzytelniające. Dodaj ten plik natychmiast do .gitignore i udokumentuj, jak go odtworzyć na podstawie kluczy service-role w Supabase, ponieważ każdy pracujący z nowym klonem będzie musiał go stworzyć ponownie.
Inicjalizuj raz w punkcie wejścia
HotUpdater.init() znajduje się w zakresie modułu w pliku index.js i musi zostać wywołany tylko raz, przed jakimkolwiek innym użyciem biblioteki. To właśnie on informuje hot-updater o miejscu, gdzie szukać aktualizacji. Jeśli brakuje tego wywołania, każda późniejsza kontrola aktualizacji nie ma adresu serwera i po prostu nic nie robi, co stanowi jeden z najtrudniejszych do zdiagnozowania przypadków awarii.
Importy pozwalają na zaimportowanie biblioteki, react-native-config do uzyskiwania wartości specyficznych dla środowiska oraz własnej usługi analitycznej aplikacji.
// index.js
import { HotUpdater } from '@hot-updater/react-native';
import Config from 'react-native-config';
import { analytics, AnalyticsEvent } from '@services';
Sama konfiguracja odczytuje adres funkcji Edge oraz strategię aktualizacji z zmiennych środowiskowych. Obsługa błędów onError jest celowo minimalna: uruchamia się podczas startu aplikacji, więc nie powinna polegać na elementach aplikacji, które jeszcze mogą nie być gotowe. Jej jedynym zadaniem jest zapisanie informacji o nieudanej inicjalizacji.
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),
});
},
});
Zwróć uwagę na rozdzielenie zadań w tym przypadku. Funkcja init decyduje o źródle aktualizacji. To, czy aplikacja powinna w ogóle je sprawdzać, należy do twojej własnej logiki, na przykład do flagi funkcjonalności zdalnej. Rozdzielenie tych dwóch decyzji ułatwia wyłączenie aktualizacji OTA w danym środowisku bez konieczności edytowania kodu startowego.
Cichy hook do aktualizacji zamiast wrap()
Biblioteka dostarcza komponent wyższego rzędu HotUpdater.wrap(), który automatycznie zajmuje się sprawdzaniem, pobieraniem i aplikowaniem aktualizacji. Jeśli wystarczy ci opcja „sprawdź przy uruchomieniu, zaktualizuj i skończ”, użyj jej – to najszybsza droga.
Aplikacja produkcyjna często wymaga większej kontroli: pobierania w tle, wyświetlania własnego okna modalnego, ograniczania częstotliwości sprawdzeń oraz ukrywania całego mechanizmu za flagą funkcji. W takich przypadkach lepiej sprawdza się dostosowany hook, który ręcznie steruje API. Poniższa wersja to uproszczony rdzeń napisany w TypeScript. Wykorzystuje dwa refy jako mechanizmy ochronne – jeden do sprawdzania trwającego procesu, a drugi do pliku już pobraanego – oraz udostępnia ref okna modalnego, flagę wymuszonej aktualizacji oraz funkcję apply.
// 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 };
};
Kilka decyzji w tym hooku wynika z rzeczywistych problemów występujących w środowisku produkcyjnym:
requestTimeout: 20000: domyślny czas oczekiwania wynosi 5 sekund. Funkcja krawędziowa Supabase po raz pierwszy uruchamiana często potrzebuje więcej czasu, przez co żądanie wygaśnie icheckForUpdatezwrócinull. Taki wynik nie różni się od sytuacji „już zaktualizowane”, więc aktualizacje w tle nigdy nie przychodzą. Zwiększenie czasu oczekiwania do 20 sekund pomaga pokonać opóźnienie spowodowane pierwszym uruchomieniem.- Najpierw etapowanie, później zastosowanie:
updateBundle()pobiera nowy plik na dysk bez ponownego ładowania żadnych danych.reload()to jedyna funkcja, która go wprowadza. Rozdzielenie tych dwóch kroków oznacza, że proces dostawy nie zostanie przerwany nagłym restartem; pobieranie odbywa się w tle, a użytkownik decyduje, kiedy go zastosować.
busyRef lub stagedRef. Dwie te weryfikacje nigdy nie odbywają się jednocześnie, a plik, który został już przygotowany, nigdy nie jest ponownie pobierany.Warto zwrócić uwagę na dwa małe szczegóły. Weryfikacja z opóźnieniem trzech sekund pełni rolę zapasu bezpieczeństwa podczas uruchamiania, na wypadek gdyby pierwsze zdarzenie stanu aplikacji active wystąpiło przed tym, jak hook się do niego przypisze. Ponadto wywołanie openModal() zakłada, że ref wskazuje na własny komponent modalowy, który udostępnia tę metodę, a nie na wbudowany w React Native komponent Modal, więc należy dostosować typ ref do swojego komponentu.
Zainstaluj hook i wyświetl prośbę
Zawołaj funkcję hook znajdującą się blisko początku aplikacji i wyświetl pojedynczy moduł modalny, który obsługuje oba typy aktualizacji. Opcjonalną aktualizację można zamknąć; wymuszoną natomiast ukrywa przycisk anulowania i nie można jej zamknąć.
// 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}
/>
);
Ponieważ shouldForceUpdate pochodzi z serwera, ty decydujesz dla każdego pakietu, czy użytkownicy mogą ją odłożyć. Zachowaj wymuszone aktualizacje na naprawy, które rzeczywiście nie mogą czekać.
Przypisz każdą wersję natywną do konkretnego kanału
Ostatnia część informuje każdy plik binarny, do którego kanału należy – na przykład development, stage lub production. Ponieważ wartość ta jest wbudowana w plik binarny, serwer aktualizacji może zdecydować, jaka sekwencja paczek zostanie otrzymana. W przypadku Androida należy ustawić wartość HOT_UPDATER_CHANNEL dla każdej wersji produktu w pliku manifest; w przypadku iOS należy to zrobić dla każdego docelowego urządzenia w pliku Info.plist. Należy to skonfigurować raz dla każdej wersji lub docelowego urządzenia, a wartość pozostanie niezmieniona.
Dwa pułapki dotykają praktycznie wszystkich. OTA działa tylko w wersjach produkcyjnych, a nie w wersjach do debugowania. Ponadto każda nowa natywna wersja wymaga jednej operacji rozgłoszenia, aby została zarejestrowana jako podstawowa paczka; bez tego kolejna operacja rozgłoszenia wyświetla komunikat
Skipped (no compatible base bundles).
Rozsyłanie aktualizacji z linii poleceń
Gdy wszystko jest podłączone, opublikowanie zmiany w JavaScript wymaga tylko jednego polecenia.
npx hot-updater deploy -p android -t 3.0.0 -c development
Flagi oznaczają następujące rzeczy:
-p: platforma,androidlubios.-t: wersja aplikacji docelowej, z którą jest kompatybilny plik, czyli twoja lokalnaversionName.-c: kanał, na który ma zostać opublikowane.-f: opcjonalne; oznacza aktualizację jako wymuszoną, dzięki czemu zostanie natychmiast zastosowana.
Zapisz swój JavaScript, uruchom proces rozgłoszenia, a następnym razem, gdy ktoś otworzy wersję wydania na tym kanale, aktualizacja będzie już gotowa do użycia.
Otoczenie procesów rozgłoszenia skryptami specyficznymi dla środowiska
Wpisywanie tego polecenia dla dwóch platform w trzech kanałach szybko staje się uciążliwe, a łatwo jest przez pomyłkę opublikować treść w niewłaściwym kanału. Mały skrypt Node napędzany zmiennymi środowiskowymi, dostępnym za pośrednictwem npm scripts, eliminuje oba te problemy.
Wpisy w pliku package.json określają kanał oraz opcjonalną flagę przymusową, a następnie wywołują ten sam skrypt.
{
"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"
}
}
W pliku scripts/ota-deploy.mjs środowisko dostarcza zarówno informację o kanału, jak i opcję przymusową; skrypt następnie pobiera wersję docelową z pliku android/app/build.gradle, chyba że zostanie to przejęte przez zmienną OTA_VERSION, i rozprowadza aplikację na obu platformach kolejno. Funkcja execSync z ustawieniem stdio: 'inherit' przekazuje wyniki działania interfejsu wiersza poleceń do terminala i wywołuje błąd przy niezerowym kodzie zakończenia, dzięki czemu nieudana próba rozprowadzenia aplikacji na Androidzie zatrzymuje proces przed podjęciem próby na 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' });
}
Powszednia praca zmniejsza się wtedy do zaledwie kilku poleceń.
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
Pobieranie wersji z Gradle zamiast wpisywania jej ręcznie eliminuje całą kategorię błędów podczas wydawania aplikacji: nie można przypadkowo opublikować pliku dla przestarzałej wersji docelowej. Należy jednak pamiętać o założeniach, na których to polega. Skrypt używa pola versionName w systemie Android dla obu platform, więc działa tylko wtedy, gdy wersja marketingowa dla iOS jest zsynchronizowana. Jeśli te wersje mogą się różnić, należy przeczytać informacje o wersji iOS osobno lub wyraźnie ustawić OTA_VERSION. Składnia VAR=value używana w skryptach npm zakłada również istnienie shella POSIX; na Windows potrzebny będzie narzędzie takie jak cross-env.
Rollback i stopniowe wdrażanie jako twoja sieć bezpieczeństwa
Szybkie naprawienie problemu to zaledwie połowa zadania. Drugą połową jest ograniczenie zasięgu uszkodzeń, gdy okazuje się, że dany pakiet jest nieprawidłowy, i właśnie tutaj przydaje się konsola hot-updater.
Błyskawiczne cofnięcie
Każdy pakiet wymieniony w konsoli ma przełącznik Enabled. Jeśli świeżo wysłany pakiet jest uszkodzony, należy go wyłączyć. Urządzenia, które już go zainstalowały, przy następnej weryfikacji wrócą do ostatniego sprawdzonego pakietu, bez konieczności ponownej kompilacji, rozruchu ani czekania na odpowiedź od Apple lub Google. CLI oferuje taką samą możliwość ucieczki poprzez polecenie hot-updater rollback, które cofa najnowszy zainstalowany pakiet.
To główny powód, dla którego OTA można bezpiecznie używać: cofnięcie błędu zajmuje zaledwie kilka sekund. Należy pamiętać, że cofnięcie dotrze do urządzeń dopiero przy ich kolejnej weryfikacji, więc mechanizm check-on-foreground odgrywa równie ważną rolę przy naprawianiu problemów, jak i przy dostarczaniu aktualizacji.
Etapowe wdrażanie i grupy użytkowników
Zamiast wysyłać pakiet do wszystkich urządzeń jednocześnie, możesz najpierw go udostępnić niewielkiemu procentowi użytkowników, obserwować raporty o błędach i dane analityczne, a następnie stopniowo zwiększać liczbę użytkowników, aż osiągnie ona 100%. Grupy użytkowników umożliwiają skierowanie aktualizacji do konkretnej grupy, np. wewnętrznej grupy qa, zanim zmiana dotrze do pozostałych użytkowników. W połączeniu z przełącznikiem „Włączone” przekształca to ryzykowne wdrożenie w kontrolowane doświadczenie. Sprawdź dokumentację hot-updatera, aby poznać dokładne opcje konfiguracji, ponieważ mogą one ulegać zmianom pomiędzy wersjami.
Główne wnioski
- Traktuj OTA jako szybką ścieżkę wyłącznie dla JavaScriptu; wszystko, co dotyczy kodu natywnego, nadal wymaga budowy w sklepie, a każda nowa wersja musi zostać zarejestrowana jako podstawowy pakiet.
HotUpdater.init() dokładnie raz w pliku index.js, trzymając oddzielnie informację o miejscu sprawdzania od decyzji o dokonaniu tego sprawdzenia.requestTimeout, gdy twoim serwerem aktualizacji jest funkcja serverless, ponieważ sprawdzenie z przekroczeniem czasu wygląda dokładnie tak samo jak „brak dostępnych aktualizacji”.updateBundle(), a następnie stosuj je za pomocą reload() osobno, aby użytkownicy nie byli restartowani w trakcie wykonywania zadań.Literatura pokrewna
- Wiązanie modułu Turbo w aplikacji end-to-end z użyciem React Native Codegen — Definiowanie typizowanej specyfikacji, uruchamianie procesu codegen oraz implementacja modułu Turbo na iOS i Android przy użyciu metod synchronicznych, Promise, callback oraz event-emitter.
- Przechowywanie ustawień tła w React Native z użyciem Context i Hooks — Tworzenie mechanizmu zmiany tła w React Native przy użyciu Context, useState i useEffect: nawigacja kart, wybieracz tła, przechowywanie danych w AsyncStorage oraz mechanizm ochronny podczas uruchamiania aplikacji.