使用 hot-updater 与 Supabase 为纯 React Native 实现自托管 OTA 更新
使用 hot-updater 和 Supabase 在纯 React Native 应用中实现空中 JavaScript 更新:包括初始化、静默更新钩子、频道管理、部署脚本以及回滚功能。
来源: Ship JavaScript Fixes in Minutes, Not Days: OTA Updates in React Native with Hot Updater
在 React Native 应用中,只需一行 JavaScript 代码的修改本不应需要等待数天才能通过 App Store 审核,但每当通过新二进制文件发布更改时却恰恰如此。空中下载(OTA)更新功能允许直接将纯 JavaScript 修改应用到已安装的应用中,而原生代码修改仍需通过应用商店审核。本指南将介绍基于开源 hot-updater 库并结合自托管 Supabase 后端构建的生产级方案:包括安装步骤、一次性初始化流程、带自定义提示的静默更新机制、按构建版本划分的渠道、可重复执行的部署脚本,以及确保整个流程安全的回滚控制功能。
参考场景是一个在全新架构(撰写本文时为版本 0.85)上直接运行 React Native 的物流应用,这类应用中的司机需要快速获得修复,且没人希望依赖 Expo 工具链。
OTA更新实际上替代了什么
每个React Native二进制文件其实是由两个组件打包在一起的:
- 原生代码:Android和iOS的框架、原生模块、权限,以及位于
android/和ios/目录下的所有内容。 - JavaScript代码包:界面元素、组件、业务逻辑,以及大部分常规的漏洞修复和功能开发工作。
应用商店发布的版本会将这两部分整合成APK或IPA文件。问题在于,日常开发工作大多只涉及JavaScript层面。按钮标签错误、API参数不当或布局调整这类问题并不需要新的原生代码,但传统的发布流程仍然要求进行完整的构建和审核流程。
OTA更新使应用包与这一流程解耦。应用不会将嵌入的JavaScript视为固定不变,而是向更新服务器查询是否有更新的版本包,下载后在下一次重新加载时替换旧版本。无需提交到应用商店,也无需重新安装;修复内容会在用户下次启动应用时立即生效。
所有后续操作都遵循一条规则:
仅涉及JavaScript的更改:可通过OTA方式推送。涉及原生代码的更改(新的原生依赖、版本升级、新的原生模块):仍需进行正式的应用商店构建。
OTA更新并不能替代原生构建方式。它的作用是避免原生代码的变更阻碍JavaScript版本的发布。在每次进行OTA部署之前,都要确认自上一个二进制文件发布以来是否有任何原生代码发生变更;如果有,新的应用包可能在仍在运行旧版本的设备上导致崩溃。
选择热更新机制而非其他方案
多年来,Microsoft CodePush一直是默认选择。随着App Center和CodePush被废弃,各团队不得不寻找替代方案。在评估阶段,可行的选项如下(决策前请核实各项的当前状态,因为这一领域变化迅速):
- CodePush / App Center:已被废弃,故不可选。
- EAS Update:功能成熟且可靠,但会使得纯项目更紧密地绑定在Expo生态系统中。
- Revopush和Codemagic CodePush:可作为直接替代品,但属于付费服务。
- 以Firebase Storage作为后端:仅适用于付费的Blaze套餐。
- hot-updater:一种开源且可自行托管的免费选项,支持新架构。
hot-updater因以下几个具体原因更胜一筹:
- 无需许可费用:属于开源项目,没有按每月活跃用户计费的模式。
- 支持新架构:对于启用新架构的应用而言,这是硬性要求。
- 可插拔的后端:存储和数据库可通过插件来选择。Supabase非常合适,因为单个项目就能提供存储、Postgres数据库以及作为更新服务器的边缘函数,而且所有这些都在充足的免费额度范围内。
- 网页控制台与命令行工具:不必局限于仅使用终端的工作流程。
- 即时回滚功能:出现问题的版本包只需一键即可禁用,无需重新部署或等待商店审核。
- 分阶段发布与目标群体设置:版本包可以先在少量设备上测试,比如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
接下来,让命令行界面为您搭建后端框架。
npx hot-updater init
init命令会创建存储桶,设置所需的表结构,并发布应用程序将会调用的update-server边缘函数。同时它还会生成一个包含凭证的.env文件。请立即将该文件加入.gitignore列表,并记录如何根据Supabase的service-role密钥重新生成该文件,因为从全新克隆的代码库开始工作的用户需要重新创建它。
在入口点处初始化一次
HotUpdater.init() 应位于 index.js 的模块作用域中,且必须在使用该库的任何操作之前仅执行一次。它负责告知热更新模块去何处查找更新内容。如果缺少此函数,后续的所有更新检查都将没有服务器地址,从而默默无为,这是最容易让人困惑且难以调试的故障模式之一。
这些导入语句用于引入该库、用于获取特定环境配置值的 react-native-config,以及应用自身的分析服务。
// index.js
import { HotUpdater } from '@hot-updater/react-native';
import Config from 'react-native-config';
import { analytics, AnalyticsEvent } from '@services';
配置本身会从环境变量中读取边缘函数地址及更新策略。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边缘函数通常需要更长时间,从而导致请求超时,checkForUpdate会返回null。这个结果与“已是最新版本”无法区分,因此更新内容永远不会被应用。将超时时间提高到20秒可以弥补冷启动带来的延迟。- 先准备,后应用:
updateBundle()会将新版本包下载到磁盘,而不会重新加载任何内容。reload()是唯一会触发实际应用的函数。将这两个步骤分开,可以确保在传输过程中驱动程序不会因突然重启而中断;下载操作在后台进行,由用户决定何时应用新版本。
busyRef 或 stagedRef 被设置时,该函数会立即退出。这两种检查永远不会同时运行,且已处于准备状态的包不会再被下载。有两点值得注意。三秒延迟的检查机制可作为启动时的备用方案,以防在钩子完成订阅之前就触发了第一个 active 应用状态事件。此外,openModal() 函数假定该引用指向的是提供了该方法的自定义模态组件,而非 React Native 内置的 Modal,因此需根据自身组件调整引用类型。
挂载钩子并渲染提示框
在应用根目录附近调用钩子函数,然后渲染一个能够处理这两种更新类型的模态框。可选的更新可以取消;而强制更新的模态框则会隐藏取消按钮,且无法关闭。
// 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 代码并运行部署命令,下次有人在该渠道打开对应版本时,更新就会立即应用。
在特定环境脚本中封装部署流程
在三个渠道中为两个平台重复输入相同命令会变得十分繁琐,而且很容易误将内容发布到错误的渠道。通过 npm scripts 提供、由环境变量驱动的简单 Node 脚本可以解决这两个问题。
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' 可以将命令行输出直接显示在终端上,若退出码非零则会抛出异常,因此 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 自动生成版本号而非手动输入,可以避免一大类发布错误:这样就不会意外使用过时的目标版本来发布应用包。不过需要注意的是,该脚本对两个平台都使用了 Android 的 versionName,因此只有当 iOS 的营销版本与之保持同步时才能正常工作。如果两者出现差异,则需要单独处理 iOS 版本或明确设置 OTA_VERSION。此外,npm 脚本中使用的 VAR=value 内联语法依赖于 POSIX shell,在 Windows 系统上需要使用 cross-env 这类工具。
将回滚与分阶段发布作为安全保障
快速解决问题只是其中一半工作。另一半则是当某个软件包出现故障时限制其影响范围,而这就是热更新控制台发挥作用的地方。
即时回滚
控制台中列出的每个软件包都有一个Enabled开关。如果新发布的软件包出现故障,只需关闭该开关即可。已安装该软件包的设备在下次检查时就会自动恢复到上一个正常的版本,无需重新构建、重新部署,也无需等待苹果或谷歌的响应。命令行界面通过hot-updater rollback指令提供相同的解决方案,可以回滚到最近部署的版本。
这正是OTA更新值得信赖的主要原因:纠正错误只需几秒钟。需要注意的是,回滚功能只有在设备再次检查时才会生效,因此钩子函数中的前台检查触发机制对于恢复系统而言与确保更新成功同样重要。
分阶段发布与分组
无需一次性将完整包推送到所有设备,可以先向少量设备发布,查看崩溃报告和分析数据,然后逐步增加覆盖比例,直至达到100%。分组功能允许你在其他用户看到变更之前,针对特定群体(如内部的qa小组)进行测试。结合“启用”开关,这可将有风险的发布转变为可控的实验。具体配置选项可能因版本不同而变化,请查阅热更新工具的文档。
关键要点
- 将OTA视为仅适用于JavaScript的快速通道;任何涉及原生代码的内容仍需通过应用商店构建,且每个新版本都必须作为基础包进行注册。
index.js中仅调用一次HotUpdater.init(),并将“检查位置”与“是否检查”分开处理。requestTimeout,因为超时的检查结果会与“没有可用更新”完全相同。updateBundle()下载内容,再通过reload()应用,这样用户就不会在操作过程中被强制重启。