Skip to content
EAS 发布与热更新

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-interactive

submit 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 字段。最直观,代价是你的热更兼容性被商店版本号节奏绑架:每次发商店包前必须记得手动 bump version,忘了 bump 又改了原生代码,新旧二进制的 runtimeVersion 撞车,新 OTA 会被旧二进制当成兼容的收下。docs 推荐的搭配是:商店要求每包版本号递增,所以发版时人工 bump version,构建号交给 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 上也能直接操作百分比。

回滚与"强制更新"

回滚手段按侵入性从小到大:

  1. eas update:rollback [GROUPID]:把该 branch 上此组之前的更新重新发布为新更新(没有更早的就回滚到内嵌更新)——设备侧表现为"收到一次新更新",干净且不依赖商店。
  2. eas update:roll-back-to-embedded --channel <c> --branch <b>:直接推一条"回到二进制内嵌版本"的更新。
  3. 客户端兜底: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 7

eas 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:connect

EAS 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 明说的机制,不是玄学):

  1. 策略选 fingerprint(balanced 默认),或至少纪律化 appVersion:改原生必 bump;别让两套二进制共用一个 runtimeVersion。
  2. 永远先 preview 渠道再 production 渠道,同一 branch 也别跨渠道直接推——channel 是烧进二进制里的收件箱,推错渠道对生产包就是精准打击。
  3. --environment 与构建 profile 的 environment 对齐:OTA 内联的 EXPO_PUBLIC_* 必须来自同一环境集。
  4. 新原生模块 = 必须新构建,OTA 只带 JS;拿不准就 eas fingerprint:generate 前后对比。
  5. 灰度是默认动作,不是可选项:--rollout-percentage 起步,观测崩溃率再拉满;出问题先 revert rollout / rollback,别先 debug。
  6. uploadSourceMaps: true + Observe:没有符号化堆栈,灰度期间你看到的只有"崩了”,没有"为什么崩"。
  7. 回滚路径提前演练:preview 渠道发个坏包,走一遍 eas update:rollback,真出事时你只有几分钟决策时间。
  8. 定价、额度、带宽按套餐分级且常变,以 expo.dev/pricing 当期页面为准,不在文档里抄数字。