EAS 发布与热更新
上一章(06-EAS构建.md)解决"怎么把二进制造出来",这一章解决剩下两件事:把包送进商店(EAS Submit),以及在两个商店包之间把修复送到用户手机上(EAS Update),最后补上"送出去之后怎么知道它好不好"(EAS Observe / PostHog 集成)。移动端发版和 Web 发布最大的不同就在这里:商店审核是天,热更新是唯一能绕过这个天的合法通道——而它有自己的物理边界。
Submit:把构建产物送进商店
eas submit 命令族
eas submit 把已经完成的 EAS 构建(或你指定路径的本地包)上传到 App Store Connect / Google Play:
eas submit -p ios # 交互选择要提审的构建
eas submit -p android --latest # 直接提交该平台最新构建
eas submit --id <BUILD_ID> -p ios # 指定构建 ID
eas submit --path ./app-release.aab -p android # 提交本地产物(也支持 --url)
eas submit -e production # 使用 eas.json 里名为 production 的 submit profile
eas submit -p ios -g qa-team -m "修复登录闪退" # 加 TestFlight 内部测试组 + What to test
eas submit --non-interactive # CI 姿势周边命令:eas submit:list / :status / :view <ID> / :cancel / :retry(重试失败的提交)。最常见的组合技是构建完自动提审:
eas build -p all --profile production --auto-submit
# 或构建与提审 profile 不同名时:
eas build --profile production --auto-submit-with-profile=production --non-interactivesubmit profile(eas.json 的 submit 段)
字段与 build 段平级,同样按 profile 组织(旧文写作 submitProfiles,现行 schema 是 submit):
{
"submit": {
"production": {
"android": {
"track": "internal",
"releaseStatus": "draft"
},
"ios": {
"ascAppId": "1234567890",
"appleId": "dev@yourcompany.com",
"appleTeamId": "AB12XYZ34S",
"ascApiKeyPath": "$ASC_API_KEY_PATH",
"ascApiKeyIssuerId": "$ASC_API_KEY_ISSUER_ID",
"ascApiKeyId": "$ASC_API_KEY_ID",
"groups": ["internal-testing"]
}
}
}
}Android 侧关键字段(docs eas/json.md):
track:internal | alpha | beta | production四档,对应 Play 的测试轨道;releaseStatus:completed | draft | halted | inProgress——第一次提交建议draft,包传上去、商店信息在 Play Console 补完后再发布;rollout: 0–1 的小数,投放比例,仅与inProgress状态搭配(配合 Play 自身的分阶段发布);serviceAccountKeyPath: 本地 Google Service Account 密钥 JSON 路径(支持$ENV_VAR内插);省略则用 EAS 服务器上为该 app 分配的 key;changesNotSentForReview: 上传但不触发审查,留给你在 Play Console 手动点。
iOS 侧关键字段:
ascAppId: App Store Connect 里应用的 “Apple ID”(General Information 页可查)。设了它会跳过"自动创建 App 记录"步骤;没设的话首次提交 EAS 会在 ASC 里替你建好应用条目;ascApiKeyPath / ascApiKeyIssuerId / ascApiKeyId: 自带 App Store Connect API Key(.p8)三件套;appleId / appleTeamId: Apple ID 用户名与团队 ID(也可用EXPO_APPLE_ID等环境变量);sku / language / companyName / appName: 仅首次创建 App 记录时用到;groups: 构建完成后自动加入的 TestFlight 内部测试组名;metadataPath: 商店文案元数据目录,对接 EAS Metadata(eas metadata:push/pull/lint,见 docs eas/metadata.md)。
非交互提交的硬约束(CI 必看)
docs 把这两条写得很死,第一次配 CI 十个有九个撞在这:
注意
eas submit --non-interactive(以及eas build --auto-submit的非交互场景)不能现场创建/补齐 Apple 侧凭证——没有预先分配的 App Store Connect API Key 会直接报App Store Connect API Keys cannot be set up in --non-interactive mode.,并且 submit profile 必须显式含ascAppId。
- iOS:要么在 expo.dev dashboard(项目 Project credentials)或
eas credentials面板里让 EAS 代管一个 ASC key,要么自带.p8三件套写进 submit profile / 环境变量。 - Android:要么在 dashboard 的 Service credentials 上传 Google Service Account key,要么
serviceAccountKeyPath指向 CI 机器上的文件;缺 key 同样报错。 - Expo 账号侧靠
EXPO_TOKEN(构建章已讲,同一枚 token 通吃 submit)。
审核流程要点
两边的"提审"语义不同,别互相套用心智:
- Google Play:
eas submit上传的 release 进入指定 track。首次提交按 docs 的说法走 internal testing 轨道开箱即用;但应用商店信息(listing、内容分级等)没补齐之前,Play Console 里的 release 保持草稿状态,无法 promote 到 production——这部分是人工工作,EAS 的商店元数据自动化目前只覆盖 iOS(见下文 EAS Metadata)。从 internal 一路手动 promote:internal → external(alpha/beta) → production,或配track: "production"直发(前提是你已完成 Google 的生产环境测试要求等政策项,政策细节以 Play Console 当期提示为准)。 - App Store:
eas submit -p ios只是把.ipa传到 App Store Connect——docs 实测口径:构建处理约 10–15 分钟后出现在 TestFlight(可用-g/--groups自动进内部测试组,npx testflight工具或 dashboard 分发)。到这里还没有"提审",要发正式版必须人工登录 App Store Connect 把该 build 关联到版本并提交 App Review。所以 iOS 的节奏是:eas submit → TestFlight 内测若干天 → ASC 手动提审 → 过审上架。 - 商店审核规则随账号类型、地区、类目变化快,本文不引用任何"审核时长/被拒率"类的未核实数字;涉及原生能力配置(如权限描述、后台模式)被拒的经典案例,Expo 团队在 SDK 58 里顺手修掉了一个——
expo-location的 iOS 地理围栏不再需要声明location后台模式,避免 guideline 2.5.4 拒审(changelog 原文)。做合规时先查 docs 与当期官方指引,再问 Google/Apple。
TestFlight 分发还有两个现成工具减少人工环节:eas submit -p ios --auto-testflight-setup 首次提交时自动建好内部测试组(配合 profile 里的 groups 数组把人/组直接挂上);独立的 npx testflight 工具走 Apple TestFlight API 做上传与分发(docs build-reference/npx-testflight.md)。商店文案别手动在后台维护:EAS Metadata 用项目根目录的 store.config.json 描述应用信息(多语言文案、截图等),eas metadata:lint 自带常见拒审点校验,提交时经 submit profile 的 metadataPath 一并上传;已有应用可 eas metadata:pull 反向拉取。截至 2026-10-20,docs 明确它只支持 Apple App Store,Android 侧元数据仍要在 Play Console 维护。
EAS Update:热更新
为什么需要 OTA,以及它的物理边界
商店审核以"天"为单位,线上崩溃和计费错误以"分钟"为单位。EAS Update 让你在两个商店包之间推送 JS 代码与静态资源 的修复,不需要用户去商店更新,也不需要过审(额度与带宽随套餐不同,以 expo.dev/pricing 为准)。
能更 / 不能更的清单:
| 能靠 OTA 解决 | 必须发新商店包 |
|---|---|
| JS 逻辑修复、状态机调整 | 新增/升级含原生代码的库(如新装 expo-camera) |
| Hermes 字节码、bundle 内资源(图片/字体 JSON 等) | 权限申请、Info.plist / AndroidManifest 变化 |
| 文案、配色、路由调整、Feature flag 行为 | app icon、splash、原生模块初始化参数 |
| 修复上一批 OTA 引入的 bug(回滚) | Expo SDK 大版本升级 |
一句话心智:二进制是容器,OTA 只能换容器里的 JS/资源;容器本身的形状(原生代码、配置进原生工程的任何字段)改动都要过商店。expo-updates 的机制细节见 05-Expo-Go与开发构建.md 与 docs eas-update/how-it-works.md。
概念链:runtimeVersion ↔ channel ↔ branch ↔ update group
发布模型四个词,理顺了 OTA 就不会出大事:
- runtimeVersion:给"二进制原生层"贴的兼容性标签。设备只接受 runtimeVersion 相同 的更新——它保证推出去的 JS 不会调用当前二进制里不存在的原生 API。
- channel:二进制的"收件箱"。构建 profile 的
channel字段(或裸项目的updates.requestHeaders里expo-channel-name)在编译时烧进包内,设备请求更新时带着它,服务端按 平台 + runtimeVersion + channel 三元组决定发哪个包。Updates.channel常量在 dev build 和 Expo Go 里恒为null——它们不绑 channel,可以加载任何 runtimeVersion 兼容的更新(这正是用 dev build 预览 OTA 的原因)。 - branch:发布侧的"开发轴",类比 git branch。
eas update --channel preview会在 preview 渠道上找/建一条同名 branch,更新按 branch 追加;渠道当前对外提供哪组更新,由 channel 指向 branch 的决定。 - update group:一次
eas update产物的集合(Android/iOS 各一个 update,打包成一个 group),eas update:list/view操作的粒度就是它。
首次配置一条命令:
eas update:configure它写三样东西:app config 的 runtimeVersion(默认策略问你要,见下节)、updates.url(形如 https://u.expo.dev/<projectId>),以及缺了才补的 extra.eas.projectId;如果用 EAS Build,还会在 eas.json 的 preview/production 档写入 channel。裸项目(不用 EAS Build)则要把 channel 落到原生配置(updates.requestHeaders,下次 prebuild 生效)——docs getting-started 有逐条对照。
runtimeVersion 策略:OTA 事故的第一现场
三种自动策略(合法取值即这三个,另可手写字符串),取舍完全不同:
{ "expo": { "runtimeVersion": { "policy": "appVersion" } } }appVersion:runtimeVersion =version字段。最直观,代价是你的热更兼容性被商店版本号节奏绑架:每次发商店包前必须记得手动 bumpversion,忘了 bump 又改了原生代码,新旧二进制的 runtimeVersion 撞车,新 OTA 会被旧二进制当成兼容的收下。docs 推荐的搭配是:商店要求每包版本号递增,所以发版时人工 bumpversion,构建号交给autoIncrement(远端管理,正好 remote autoIncrement 不动version,两条不打架)。nativeVersion:runtimeVersion ="1.0.0(41)"这种version(buildNumber|versionCode)组合。每个二进制天然唯一,但两个坑:双端 buildNumber/versionCode 各走各的会造成双端 runtimeVersion 分叉;且它与cli.appVersionSource: "remote"的 autoIncrement 互斥(docs 明列 limitation)。基本只给"每包必改原生且手动管号"的团队用。fingerprint:用@expo/fingerprint对原生相关输入算哈希当 runtimeVersion。加了原生库、升 SDK、改 config plugin 都会变指纹,与原生层兼容性自动对齐,代价是构建更频繁(指纹一变就得重出包)——这正是文档推荐给"想要几乎不可能推出兼容事故"的团队的原因。
SDK 58 把指纹默认 preset 从 strict 改为 balanced(changelog 原话:在 app.json 里升 version、改 node_modules 内部文件,不再产生新指纹;原生模块按包名+版本哈希而非逐文件)。三种 preset:strict(历史默认,任何风吹草动都变)、balanced(现默认)、relaxed(在 balanced 之上连 app icon、包名/bundleIdentifier 也忽略,适合一套原生工程出多变体)。想微调还有项目根的 .fingerprintignore 与 fingerprint.config.js,对比两版差异用 eas fingerprint:generate / eas fingerprint:compare。
为什么说 OTA 历史事故多发于 runtimeVersion 配错:docs 的教科书剧本——你发了 runtimeVersion "1.0.0" 的包,之后装了 expo-camera 但忘了动 runtimeVersion,推的 OTA 里调用了旧二进制没有的原生 API。旧包收下 OTA,运行、报错,expo-updates 检测到后回滚到上一可用更新(error recovery),用户看到"更新后闪一下退回旧版"。balanced 指纹 + fingerprint 策略把"忘 bump"这个环节从人手里拿走了;再叠加 rollout 和预览渠道验证,剩下的事故面就很窄了。
eas update 命令:branch/channel/environment
# 日常发布(SDK 55 起 --environment 必传):
eas update --channel preview --environment preview -m "fix: 支付页白屏"
# 用当前 git 分支名与 commit message 自动生成 branch 与 message:
eas update --auto --environment production
# 只发指定平台 / 换 bundle 缓存:
eas update --platform ios --clear-cache --environment production
# 渐进发布起步 10%(详见下节)+ 传 source map 供 Observe 符号化:
eas update --channel production --rollout-percentage 10 --upload-source-maps --environment production语义要点(docs eas-update/eas-cli.md、getting-started.md):
--channel与--branch:branch 是你工作的轴(一个 feature 一条 branch 很常见),channel 决定设备收件。用--channel发布时 CLI 找到该渠道对应的 branch;渠道上有 rollout 进行中时必须改用--branch <name>,否则 CLI 无法判断该归到哪条 branch 会拒绝执行。branch/channel 的管理命令族:eas branch:create/list/view/rename/delete、eas channel:create/list/view/protect/pause/resume等。--environment production|preview|development:选择服务端 EAS 环境变量集(eas env那套,06-EAS构建.md),要求与产出该 bundle 的构建用同一 environment 内联的EXPO_PUBLIC_*值一致,否则 OTA 里的 API 地址和二进制里配的环境对不上——这是新增的高频坑,也是 docs 把 “SDK 55+ 必传” 写进 CLI 说明的原因。- 背后动作是
npx expo export出 bundle + 资产再上传;eas update --skip-bundler --input-dir dist可复用 CI 里已导出的产物。代码签名项目用--private-key-path。
发布完怎么验证:dev build 的 Extensions 面板加载(见 05),或 Expo Orbit;release 包需要强杀重启最多两次(首次后台下载、二次应用)。给少数人验证可用渠道冲浪(见下文)。核对设备实际跑的是哪组更新:Updates.updateId / isEmbeddedLaunch 常量,dashboard 可按 update ID 反查(docs trace-update-id 页)。
渐进发布(rollout)
两种正交的灰度机制,选错会打架:
- 按更新灰度:
--rollout-percentage 10发布,只有 10% 设备拿到;升比例eas update:edit(交互式选新百分比),全量即拉到 100;不满意eas update:revert-update-rollout回退到上一状态(回退会 republish 一条控制更新,确保已拿到的设备也退回)。同一 branch 同时只允许一个 rollout,rollout 未结束前不能往该 branch 同 runtimeVersion 再发新更新。 - 按 branch 灰度(channel rollout):
eas channel:rollout交互式选渠道 + 新 branch + 百分比,让一部分设备从旧 branch 切到新 branch,适合"整个版本的迭代"级灰度;结束时两选一:Republish and revert(新 branch 转正,全量指回旧 branch)或 Revert(新 branch 作废)。期间两条 branch 都能发更新(用--branch)。
--force-end-active-rollout 可以强行让新更新顶掉进行中的 rollout,紧急修 rollout 卡壳时用。dashboard 上也能直接操作百分比。
回滚与"强制更新"
回滚手段按侵入性从小到大:
eas update:rollback [GROUPID]:把该 branch 上此组之前的更新重新发布为新更新(没有更早的就回滚到内嵌更新)——设备侧表现为"收到一次新更新",干净且不依赖商店。eas update:roll-back-to-embedded --channel <c> --branch <b>:直接推一条"回到二进制内嵌版本"的更新。- 客户端兜底:
expo-updates检测到更新启动失败会自动回滚并进入 emergency launch(Updates.isEmergencyLaunch/emergencyLaunchReason可观测到),这层不需要你配置。
影响面已经扩散到整个渠道时,用渠道级止血(eas channel:pause),见下一节。
注意 文档里没有叫"强制更新/阻塞更新"的开关。最接近的机制是手动检查模式:
updates.checkAutomatically设NEVER(或ON_ERROR_RECOVERY)禁掉默认启动检查,自己在合适时机checkForUpdateAsync→fetchUpdateAsync→reloadAsync();配合业务层版本门槛可以实现"不更新不许用",但那是应用自己的判断逻辑,不是 EAS 提供的能力。默认行为是:启动时检查(ON_LOAD),下启动生效;fallbackToCacheTimeout(即launchWaitMs,默认 0)控制冷启动愿意多等一秒半秒。
import * as Updates from 'expo-updates';
async function applyUpdateNow() {
const { isAvailable } = await Updates.checkForUpdateAsync();
if (isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync(); // 重启 JS 运行时加载新包
}
}渠道级控制与止血命令
除了针对单条更新的 rollback,还有一组作用于渠道整体的命令,事故升级路径按"影响面从小到大"排:
# 暂停渠道:整个渠道立即停止下发更新(设备端表现为"无更新可用")
eas channel:pause production
eas channel:resume production
# 把渠道指回另一条 branch(一键切换数据源,常用于 rollout 之外的紧急改道)
eas channel:edit production --branch stable-1.2
# 把某条历史更新重新发布(可跨 branch/channel 搬运)
eas update:republish --group <GROUP_ID> --rollout-percentage 10
# 保护生产渠道:只有 Release Manager/Admin/Owner 能向其发布
eas channel:protect production
# 渠道级观测:采纳率、崩溃率、独立用户,按渠道+runtimeVersion 查询
eas channel:insights --channel production --runtime-version 1.2.3 --days 7eas channel:rollout 也有完全非交互的旗标形态(--action create|edit|end|view、--percent、--outcome republish-and-revert|revert),适合把灰度操作也写进发布自动化。给生产渠道上 channel:protect 是低成本高收益的纪律:它把"谁的手滑 eas update --channel production“挡在权限层。
渠道冲浪与 maxUpdatesToKeep
Channel surfing 让一个已安装的 release 包在运行时改收件渠道:Updates.setUpdateRequestHeadersOverride() 覆盖 expo-channel-name 请求头(SDK 54 / expo-updates 0.29+ 提供)。它不绕过兼容规则——目标渠道的更新仍须匹配本机平台与 runtimeVersion。适合"给指定测试同学的生产包临时切到 preview 渠道验证 OTA”。开发期迭代直接用 dev build 预览更新即可,setUpdateRequestHeadersOverride 在 development build 里不可用。
冲浪频繁会撞上设备缓存问题:设备默认只保留 2 组更新(当前 + 上一组),渠道间来回切就要重新下载。SDK 58 新增 updates.maxUpdatesToKeep 配置(app config,整数,默认 2;命中 manifest 过滤器的更新优先保留),给重度冲浪的测试设备调到 3–5。
自托管与商店侧限制(一句边界)
两个时间点要知道:2026-05-12 起,Expo Go 只能加载你自己/你组织发布到 EAS Update 的更新;Hermes 字节码(HBC)bundle 只允许来自 EAS Update,自托管更新服务器必须提供纯 JS bundle。也就是说"把 HBC 产物搬到自建 CDN"这条路在 Expo Go 场景被关死;生产应用用自有 development/release 包则不受 Expo Go 规则约束,但更新服务的兼容性与安全边界仍建议以 EAS Update 为默认选择(展开见 05-Expo-Go与开发构建.md)。
观测:发出去之后
EAS Observe
Expo Observe(2026 年进入 GA,官方 changelog 标题为 “EAS Observe moves to general availability on August 20”)监控生产环境表现:启动与导航性能、JS 错误、原生崩溃,并把每次发布横向对比全部历史版本,回归早发现。它与 OTA 的接口尤其值钱:能按 update 维度看崩溃率,灰度期间"这 10% 的设备"表现如何一目了然。
接入三步(SDK 56+ 用 Observe API;SDK 55 的 AppMetrics 命名在 SDK 58 已标记弃用,由 Observe 取代):
npx expo install expo-observe// 根组件文件(app/_layout.tsx 等)
import { Observe, ObserveRoot } from 'expo-observe';
Observe.configure({ integrations: { 'expo-router': true } }); // 必须在任何屏幕挂载前执行且只此一处
export default ObserveRoot.wrap(RootLayout);要点:Observe 依赖原生库,Expo Go 里不可用,需要 development/release 构建;debug 包默认不上报,联调可临时 dispatchInDebug: true(上线前删掉);堆栈符号化靠构建时的 uploadSourceMaps: true(06-EAS构建.md)。SDK 58 新增 errorHandlingEnabled 选项(关闭自动记录未处理 JS 错误,想接管自己的错误上报管道时用)和 Observe.clientId(把 Observe 数据与你自己的其他工具做用户级对齐)。命令行侧 eas observe:versions / metrics-summary / metrics / routes / errors / events 直接查,不用开 dashboard;OTA 质量用 eas update:view <GROUP_ID> --insights(启动次数、崩溃率、独立用户、包大小)。
PostHog 集成:产品分析自带 EAS 上下文
SDK 58 同期的一键集成:
eas integrations:posthog:connectEAS CLI 创建/关联 PostHog 项目、安装 SDK 与 config plugin、把环境变量写进项目与 EAS。关键是每个事件自动带 EAS 上下文:PostHog 识别 eas/update_id、eas/build_id、eas/channel、eas/runtime_version 四个属性并反链到 expo.dev——“这批 OTA 之后转化率掉了"这类问题可以直接在产品分析里切片回答,不用手写版本埋点。它是 EAS CLI 能力,不限 SDK 版本。
实战建议
一条完整发版流水线,把两章串起来(文字版顺序图):
改 JS/资源 ──► eas build --profile preview --channel preview(首次/有原生变更时)
│
├─► eas update --channel preview --environment preview (preview 包/QA 验证)
│
├─► eas build --profile production --auto-submit (含原生变更的周期)
│ └─► TestFlight / Play internal 内测 ─► 人工提审 ─► 上架
│
└─► eas update --channel production --rollout-percentage 10 --upload-source-maps
└─► Observe / update:view --insights 盯崩溃率
├─ 正常 ─► eas update:edit 拉 50% ─► 100%
└─ 异常 ─► eas update:revert-update-rollout 或 eas update:rollback热更新事故预防清单(每条背后都是 docs 明说的机制,不是玄学):
- 策略选
fingerprint(balanced 默认),或至少纪律化appVersion:改原生必 bump;别让两套二进制共用一个 runtimeVersion。 - 永远先 preview 渠道再 production 渠道,同一 branch 也别跨渠道直接推——channel 是烧进二进制里的收件箱,推错渠道对生产包就是精准打击。
--environment与构建 profile 的environment对齐:OTA 内联的EXPO_PUBLIC_*必须来自同一环境集。- 新原生模块 = 必须新构建,OTA 只带 JS;拿不准就
eas fingerprint:generate前后对比。 - 灰度是默认动作,不是可选项:
--rollout-percentage起步,观测崩溃率再拉满;出问题先 revert rollout / rollback,别先 debug。 uploadSourceMaps: true+ Observe:没有符号化堆栈,灰度期间你看到的只有"崩了”,没有"为什么崩"。- 回滚路径提前演练:preview 渠道发个坏包,走一遍
eas update:rollback,真出事时你只有几分钟决策时间。 - 定价、额度、带宽按套餐分级且常变,以 expo.dev/pricing 当期页面为准,不在文档里抄数字。