expo-updates 实战指南:在 Expo 应用中实现远程代码更新的原理、配置与调试
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
导读:
expo-updates是 Expo 生态中负责"应用代码远程更新"的核心模块,它让 React Native(Android / iOS / Web)应用能够在无需重新发布商店包的情况下,从符合Expo Update 协议的服务器拉取并应用新的 JS 代码与资源。本文以 packages/expo-updates/README.md 为主线,结合仓库内 DEVELOPMENT.md、guides/examples.md 与 src/Updates.ts 源码,完整讲解 expo-updates 的更新模型、协议、配置项、JS API、安装方式以及本地开发与调试技巧,读完即可上手为应用接入远程更新能力。
一、expo-updates 是什么
根据 packages/expo-updates/README.md 的定位,expo-updates模块使你的应用能够管理应用到应用代码的远程更新。它本身并不提供更新托管服务,而是扮演"客户端运行时"的角色:负责发现、下载、校验、存储并最终切换运行"更新(update)"。
从 package.json 的描述中可以进一步确认其职责:"Fetches and manages remotely-hosted assets and updates to your app's JS bundle."(获取并管理托管在远端、针对你应用 JS 包的资源与更新)。当前仓库中该模块版本为57.0.11,采用 MIT 协议开源,依赖expo-manifests(清单解析)、expo-structured-headers(结构化请求头)、expo-updates-interface(原生接口)等内部包。
这个模块有三个关键的使用前提:
- 它必须搭配一个实现了 Expo Update 协议的服务器才能发挥完整能力;
- Expo 官方的EAS Update托管服务实现了该协议,是开箱即用的选择;
- 如果你需要自建服务,可以参考官方提供的示例服务器实现(README 中指向 custom-expo-updates-server 示例仓库)。
二、核心概念:Update(更新)由什么构成
理解 expo-updates 之前,先要建立"更新"的原子模型。仓库的 guides/general.md 给出了非常清晰的表述,这是理解整个模块的基石:
- 一个"更新"是一个原子单元,由两部分组成:
- manifest(清单/元数据):描述该更新是什么、何时创建、包含哪些资源;
- 一组 assets(资源):JS bundle、Hermes 字节码(HBC)、图片、字体以及该更新运行所需的其他媒体文件;
- 其中有一个资源被指定为launch asset(启动资源),通常是 JS bundle / HBC,应用启动时会把它交给宿主(React Native)执行。
更新有多个来源:
- 远程更新:从远程服务器下载;
- 嵌入式更新(embedded update):除开发构建外,每个应用二进制包内都内置一份更新。
一个值得注意的设计细节是:expou-updates 会把嵌入式更新也复制进 SQLite 与资源存储,尽管它已经存在于磁盘上。这样做的优势在于:全模块只保留一条启动更新的代码路径;嵌入式更新中的资源若被远程更新复用则无需重新下载;即使应用被新构建覆盖(带来了新的嵌入式更新),旧的嵌入式更新也依然可用。
排序问题与 SelectionPolicy
由于客户端"必须能够在不访问服务器的情况下确定多个更新的先后顺序",expo-updates 引入了SelectionPolicy类作为可插拔的排序接口。举例来说:用户安装 build 1(内置更新 A),开发者发布更新 B 并被下载;随后开发者发布 build 2(内置更新 C),用户从应用商店更新后首次启动时,expo-updates 必须在没有服务器往返的情况下立刻比较 B 与 C 并决定启动哪一个。如果单纯依赖createdAt字段排序,那么基于服务器的回滚(rollback)就必须以"创建时间更晚的新更新"形式发布才能被客户端执行——这是选择排序策略时需要考虑的权衡。
三、服务端协议:Expo Update protocol 与 EAS Update
README 明确指出:
This module works with a server that implements the [Expo Update protocol]. The [EAS Update] hosted service implements this protocol.
也就是说,expo-updates 是面向通用协议实现编写的。仓库 guides/general.md 特别强调了一条设计原则:
expo-updates should be written for a general server implementation (using the Expo Updates specification) and should not make any assumptions or allowances specifically for the EAS Update service.
即 expo-updates 不应为 EAS Update 服务做任何专属假设,其他符合协议的服务器实现不应沦为"二等公民"(实践中仅少数小功能存在 EAS 专属代码,大型特性都刻意设计为可通用)。
客户端对协议服务器的两个关键假设
guides/general.md 还记录了客户端对服务端行为的两条硬性依赖,自建服务器时必须遵守:
- manifest 的
id是更新的唯一标识。若服务器托管了两个id相同但其他内容不同的 manifest,expo-updates 不会逐字段比较去发现差异。换句话说,从客户端视角看,更新在服务端本质上不可变; - 资源按 manifest 中的
key属性命名落盘,客户端假设任何两个key相同的文件是同一资源、可以互相替换。因此服务端必须保证key在全部资源中唯一(EAS Update 与经典 Expo 更新服务目前都遵守)。
四、JS API 全面解析(基于源码)
expo-updates 的 JS API 全部实现在 src/Updates.ts,由 src/index.ts 导出。以下逐一说明其导出的常量与方法(均以import * as Updates from 'expo-updates'使用)。
4.1 只读状态常量
| 常量 | 类型 | 说明(依据 src/Updates.ts) |
|---|---|---|
isEnabled | boolean | 模块是否启用。以下任一情况会返回false:配置中显式关闭、URL 缺失或无效、缺少 runtimeVersion 或 SDK version、初始化时存储访问出错。为false时直接加载嵌入式更新 |
updateId | string \| null | 当前运行更新的 UUID(小写规范形式)。在本地开发环境或模块未启用时为null |
channel | string \| null | 当前构建的渠道名(用于 EAS Update)。Expo Go 与开发构建不绑定渠道,恒为null |
runtimeVersion | string \| null | 当前构建的 runtime version |
checkAutomatically | enum \| null | 是否以及何时在启动时自动检查/下载更新,取值见下文 |
localAssets | LocalAssets | 本地已存在资源的映射 |
isEmergencyLaunch | boolean | 是否处于"紧急启动"回退状态(expo-updates 尽力启动单调更新的版本,但极少数情况下会回退到二进制内嵌更新),可用于做特殊兼容处理 |
emergencyLaunchReason | string | isEmergencyLaunch为true时的错误信息 |
launchDuration | number | 启动耗时(毫秒) |
isEmbeddedLaunch | boolean | 当前运行的更新是否为构建内置的那份 |
manifest | Partial<Manifest> | 当前运行更新的 manifest 对象;开发模式或模块未启用时为空对象 |
createdAt | Date \| null | 当前运行更新的创建时间;开发模式下为null |
checkAutomatically的映射关系定义在 src/Updates.ts,原生值到 JS 值的对应为:
ALWAYS→ON_LOAD(启动时检查)ERROR_RECOVERY_ONLY→ON_ERROR_RECOVERY(仅错误恢复时)NEVER→NEVER(从不自动检查)WIFI_ONLY→WIFI_ONLY(仅在 Wi-Fi 下检查)
4.2 核心方法
checkForUpdateAsync(): Promise<UpdateCheckResult>:向服务器询问是否有新更新,不实际下载。源码中该方法在开发模式(__DEV__或开发者工具运行时)会抛出ERR_UPDATES_DISABLED的CodedError,并提示"请用npx expo run:ios --configuration Release或npx expo run:android --variant Release构建 Release 版来测试"。官方建议:检查更新的合理频率是用户启动或应用回到前台时,避免高频轮询(网络请求消耗流量与电量,Expo 侧可能限流)。fetchUpdateAsync(): Promise<UpdateFetchResult>:把服务器上最新部署的更新下载到设备本地存储。下载完成后若立即调用reloadAsync()可立刻应用;否则将在下次冷启动时应用。同样在开发模式下会拒绝。reloadAsync(options?):使用最近下载的版本重新加载应用。与expo包提供的Expo.reloadAppAsync()不同,它不仅重新加载,还会把 JS bundle 切换到最新下载的更新。源码提示:不要在await Updates.reloadAsync()之后放置关键逻辑,因为 Promise 只保证"重新加载指令已提交"。getExtraParamsAsync() / setExtraParamAsync(key, value):读取/设置"额外参数"。这些参数会以 [Expo Structured Field Value] 格式放进Expo-Extra-Params请求头,符合协议的服务器可利用它来选择返回哪个更新。value传null表示取消该参数。readLogEntriesAsync(maxAge = 3600000)/clearLogEntriesAsync():读取最近(默认 1 小时内)的 expo-updates 日志条目 / 清空日志。setUpdateURLAndRequestHeadersOverride(configOverride)/setUpdateRequestHeadersOverride(requestHeaders)(实验性):在运行时覆盖构建时的更新 URL 与请求头,用于从自定义 URL 加载特定更新。源码明确警告"使用风险自负",且要求 app.json 中开启disableAntiBrickingMeasures: true才会生效。showReloadScreen(options) / hideReloadScreen():显示/隐藏可定制的"重新加载过渡屏",主要供调试构建中测试 reload 界面的视觉效果。ReloadScreenOptions支持backgroundColor、spinner(颜色)、image(支持require的图片资源)、imageResizeMode等。
4.3 事件监听:UseUpdates Hook
除命令式 API 外,模块还提供 React Hook(见 src/UseUpdates.ts 与 src/UpdatesEmitter.ts),典型用法是"新更新已下载完成时提示用户重启应用":
import { useUpdates } from 'expo-updates'; function UpdateManager() { const { isUpdateAvailable, isUpdatePending, downloadedUpdate } = useUpdates(); useEffect(() => { if (isUpdateAvailable) { // 提示用户:新版本已就绪 } }, [isUpdateAvailable]); }当LoaderTask在启动超时后才下载完新更新时,客户端会向正在运行的 JS 实例发送事件,监听方即可借此调用reloadAsync()让新更新立刻生效(详见下文启动流程)。
五、安装:bare React Native 项目接入方式
README 明确指出,在纯裸(bare)React Native 项目中的安装方法以官方文档页"Installing expo-updates"为准。结合 DEVELOPMENT.md 的说明,可以归纳出以下要点:
- 使用
expo init选择任一 bare 模板时,最新版 expo-updates 已预装并预配置; - 原生配置位置:iOS 在
Expo.plist,Android 大多可在AndroidManifest.xml中配置; - 必须确保两个核心配置正确:
- 更新服务 URL:iOS 键
EXUpdatesURL,Android 键expo.modules.updates.EXPO_UPDATE_URL; - 运行时版本:iOS 键
EXUpdatesRuntimeVersion,Android 键expo.modules.updates.EXPO_RUNTIME_VERSION。
- 更新服务 URL:iOS 键
若在 JS 中使用了expo-updates的 API,还需要按官方页面完成原生侧初始化(iOS 设置EXUpdatesAppController的bridge、Android 调用UpdatesController.initialize或设置ReactNativeHost),否则reloadAsync()在产线模式下会因找不到 JS runtime 引用而拒绝——这一点在 src/Updates.ts 的文档注释中有明确提示。
本地开发时链接本地源码
DEVELOPMENT.md 提供了在本地仓库中联调 expo-updates 的方法:
- 若不使用任何 JS API,可用
yarn link直接链接; - 若需要使用 JS 模块方法,由于Metro 不支持符号链接,建议二选一:
- 在 package.json 中将依赖替换为
"expo-updates": "file:/path/to/expo/expo/packages/expo-updates",每次改动源码后执行yarn --force让 yarn 重新拷贝到 node_modules; - 不等待 yarn,手动把 expo-updates 复制进 node_modules。
- 在 package.json 中将依赖替换为
六、原生配置与开发调试(DEVELOPMENT.md 详解)
6.1 忽略嵌入式更新(Ignore Embedded Update)
当你在用 expo-updates 测试自己开发的服务器时,很可能希望它忽略内置更新。原因在于:每次新构建都会生成一个"创建时间更新"的 bundle,导致客户端拒绝加载此前发布的所有更新。
解决办法是把EXUpdatesHasEmbeddedUpdate(iOS)与expo.modules.updates.HAS_EMBEDDED_UPDATE(Android)设为false,强制走远程更新。
6.2 附加请求头(Additional Headers)
若需要给 manifest 请求附加自定义请求头:
- iOS:在
EXUpdatesRequestHeaders键下以字典(map)形式配置; - Android:目前无法在 AndroidManifest.xml 中配置,需在
MainApplication.java中调用UpdatesController.overrideConfiguration(Context, Map<String, Object>)方法,把requestHeaders传入。
6.3 构建与启用时机
默认行为:expo-updates 只在裸应用的 Release 构建中启用,Debug 构建从本地 Metro 服务器加载。
- iOS Release 构建:Xcode → Product → Scheme → Edit Scheme,把 Run 配置的 Build Configuration 从 "Debug" 改为 "Release",再点击 Run;
- Android Release 构建:在项目根目录执行
react-native run-android --variant Release。
6.4 在 Debug 模式下启用 expo-updates
某些场景(例如想打断点单步调试)需要 Debug 构建也启用 expo-updates:
- 先按上文配置"忽略嵌入式更新";
- 设置环境变量:
export EX_UPDATES_NATIVE_DEBUG=1- iOS 额外两步:
- 修改工程文件,把
SKIP_BUNDLING替换为FORCE_BUNDLING,强制 Debug 与 Release 都打包应用 JS:
- 修改工程文件,把
sed -i '' 's/SKIP_BUNDLING/FORCE_BUNDLING/g;' ios/<project name>.xcodeproj/project.pbxproj- 重新安装 CocoaPods(在项目顶层目录执行
npx pod-install)。
完成后再构建 Debug 包,它就会表现得像 Release 构建(只是不带嵌入式更新)。
七、启动与下载的运行时流程(源码级)
guides/examples.md 详细记录了 expo-updates 的运行时行为,是理解其内部架构的最好材料。
7.1 应用启动流程(Release 构建)
- 通过 expo-modules-core 与
UpdatesPackage(Android)或ExpoUpdatesReactDelegateHandler(iOS)初始化并启动 expo-updates; - 读取原生构建配置,转换为
UpdatesConfiguration/EXUpdatesConfig对象,同时初始化数据库、文件系统引用与错误恢复处理器; - 用配置对象初始化并启动
LoaderTask; - 若配置要求检查新更新,
LoaderTask以launchWaitMs为时长启动计时器; LoaderTask读取嵌入式 manifest,用SelectionPolicy决定是否通过EmbeddedLoader将嵌入式更新载入 SQLite——每次启动都必须执行,因为二进制随时可能被商店更新;- 在其余动作之前,先启动一个
DatabaseLauncher实例,选择并准备好一个"绝对安全可启动"的更新(逐一确认资源存在并取得磁盘路径); - 若配置允许检查更新,
LoaderTask在后台线程启动RemoteLoader:请求 manifest → 用SelectionPolicy决定是否入库 → 若入库则下载 SQLite 中缺失的资源; RemoteLoader完成后回调LoaderTask决策:- 计时器未超时:创建新的候选
DatabaseLauncher选择刚下载的更新并交给UpdatesController启动; - 计时器已超时(旧更新已启动):向 JS 发送"新更新可用"事件,由应用决定何时调用
reloadAsync();
- 计时器未超时:创建新的候选
- 全部完成后,后台运行
Reaper进程清理数据库中的旧更新:保留当前运行更新、任何更新的版本以及最近的一个旧版本(作为回滚安全网)。
该流程体现了 guides/general.md 提到的核心架构原则:Loader类负责"装载进 SQLite"(写),Launcher类负责"从 SQLite 启动"(读),两者可以独立、同时、分离地运行;且"几乎任何情况都好过崩溃"——开发者依赖本模块不破坏用户对应用的信任,除错误恢复模式下由开发者代码引起的崩溃外,都应尽量避免崩溃。
7.2 下载更新的详细步骤
Loader装载更新的过程(远程或内置皆同):
- 通过子类方法加载 manifest(从 URL 下载或从应用包内读取);
RemoteLoader检查数据库是否已有此更新且状态为READY——若是则直接触发成功回调,不再做任何事;- 否则遍历 manifest 中的每个资源:检查是否 (a) 已在数据库中且 (b) 已存在于磁盘(约定"文件名相同即同一资源")。磁盘缺失则发起下载;
- 全部下载完成后,为"已在磁盘但不在数据库"的资源补写 SQLite 行(例如破坏性数据库迁移后可能出现);
- 若无错误且所有资源齐备,将更新标记为
READY并触发成功回调;否则触发错误回调。
7.3 资源意外缺失的自愈机制
正常情况资源文件不会被系统清理,但若存储损坏或代码缺陷导致资源缺失,DatabaseLauncher会依次尝试:从嵌入式 manifest 中找回缺失资源并复制 → 从 SQLite 记录的 URL 下载 → 若启动资源缺失则触发失败回调,否则仍触发成功回调(期望更新可带缺失资源运行)。
7.4 嵌入式更新的特殊处理(Android 多分辨率资源)
Android 上通过require('./image.png')引用图片时,系统可能按屏幕密度映射到image.png/image@2x.png/image@3x.png。Expo 侧把每个文件当作独立资源,全部下载后才算READY;但嵌入式更新在 Android 上会按dpi目录存放于res中,运行时ResourcesAPI 只允许访问与当前设备匹配的分辨率资源,导致EmbeddedLoader无法复制其他密度的资源。为此这些更新被标记为特殊的EMBEDDED状态,直接从应用包启动(不经.expo-internal目录、不做资源覆盖),由 RN 直接从应用包读取资源——这是 expo-updates 极少把嵌入式更新区别对待的场景之一,启动时必须额外校验嵌入式更新仍是预期的那份(用户可能已更新构建)。
八、二进制补丁支持:BSPatch 与 BZip2
DEVELOPMENT.md 说明 expo-updates 内置了对资源应用二进制补丁(binary patches)的支持:
- 采用修改版FreeBSD BSPatch(为保证移动端环境安全、不导致用户应用崩溃而做了改造);
- BSPatch 依赖BZip2:iOS 链接系统自带 BZip2 库;Android 则在仓库中内置一份 BZip2 源码随原生构建一起编译,且只包含解压缩部分;
- 需要升级 BZip2 时,下载官方源码后将必要文件替换到
packages/expo-updates/android/src/main/cpp/third-party/bzip2目录。
九、常见问题排查
如果 expo-updates 没有加载你期望的更新,优先检查两点(依据 DEVELOPMENT.md Troubleshooting 一节):
- 目标更新的创建时间是否晚于嵌入式 bundle,或者已配置忽略嵌入式更新;
- Expo.plist / AndroidManifest.xml 中配置的 SDK 或 runtime version 是否与所加载 manifest 中的一致——版本不匹配会被拒绝加载。
另外可结合两个调试入口:readLogEntriesAsync()读取模块日志定位失败原因;isEmergencyLaunch+emergencyLaunchReason判断是否发生了"紧急启动"回退并获取原因。
十、总结
expo-updates 把"远程更新"这件事抽象成一套清晰且可扩展的运行时模型:原子化的 update(manifest + assets)、客户端可独立排序的SelectionPolicy、读写分离的Loader/Launcher、以及遵循通用 Expo Update 协议的服务器交互。无论你选择官方 EAS Update 托管服务,还是基于协议自建服务器,掌握本文涉及的配置键、JS API、启动流程与调试手段,都能让远程更新在你的应用中稳定、可控地运行。进一步阅读仓库材料,可参考 DEVELOPMENT.md(开发与调试)、guides/general.md(设计哲学与假设)、guides/examples.md(完整启动/下载流程)以及 src/Updates.ts(JS API 全量源码)。
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考