CodePush 迁移到 EAS Update 完全指南:OTA 更新、runtime version 与 rollback 实战(2026)

微软 App Center 已于 2025 年 3 月停用 CodePush。本指南基于 Delivery Hero 骑手 App 的真实迁移经验,讲清楚如何切换到 EAS Update:runtime version 与 fingerprint 策略、channel/branch 配置、CI/CD 改造、灰度发布与 rollback 全流程。

更新时间:2026 年 9 月 1 日

如果你还在用 CodePush 做 React Native 的 OTA 热更新,现在应该立刻迁移到 EAS Update。微软已经在 2025 年 3 月 31 日正式停用了 App Center,CodePush 作为其组件一并下线,服务端不再受理任何新的 bundle 上传,历史 bundle 也在陆续清理。EAS Update 是 Expo 官方推出的替代方案,兼容任何 bare React Native 项目,通过 expo-updates 库分发 JS bundle 与静态资源,同时支持 runtime version、fingerprint、分支通道与灰度回滚。本文基于我在 Delivery Hero 骑手 App 完成的 CodePush 迁移经验,把整个流程拆开讲清楚。

  • 微软 App Center(含 CodePush)已于 2025 年 3 月 31 日下线,官方推荐迁移到 EAS Update 或 Expo 自托管方案。
  • EAS Update 通过 expo-updates 分发,支持 runtime version 与 fingerprint 策略,能自动阻止把带原生变更的 bundle 推给旧客户端。
  • 迁移的核心工作在于卸载 react-native-code-push、安装 expo-updates、配置 channel/branch,并把 CI/CD 从 appcenter codepush release-react 换成 eas update
  • EAS Update 免费额度覆盖每月 1000 MAU,超出部分按 tier 计费;企业 tier 支持完全自托管与自定义 CDN。
  • 灰度发布通过 --rollout-percentage 控制流量比例,rollback 使用 eas update:republish 指向上一个稳定 update ID。
  • Apple 与 Google 均允许 JS 层 OTA 更新,但严禁热更新改变 App 的核心功能或绕过审核。

CodePush 为什么被停用?

CodePush 原本是 Visual Studio App Center 的一个功能模块,2017 年前后成为 React Native OTA 热更新的事实标准。2024 年 4 月,微软在 App Center 官方博客发布了 App Center 全面下线公告,明确 2025 年 3 月 31 日之后所有服务(Build、Test、Distribute、Analytics、Diagnostics、Push、CodePush)都会停止运行,服务端返回 410 Gone。这不是慢慢缩减功能,而是整个平台一次性关停。

下线的原因其实是微软内部产品线整合。分析类功能迁到 Azure App Insights,CI/CD 类功能推荐使用 GitHub Actions 或 Azure DevOps,而 OTA 热更新因为原生场景比较垂直,微软没有做官方接盘方案,只列出了几个第三方替代品:EAS Update、Shorebird、Cloudflare 加自托管 expo-updates。

说个真实情况:我在 Delivery Hero 时管着 30 多个市场的骑手 App,CodePush 上有 45 个 deployment key 混着 staging/production/beta 三种环境。2024 年 Q4 开始就必须动手迁,不然 2025 年 4 月之后任何 hotfix 都要走完整的应用商店审核。高峰期一个 P0 修不上去,可能就是几十万单的损失。这也是为什么我强烈建议:即便你现在还在用某个 CodePush 私服镜像临时续命,也应该把迁移排进本季度的路线图。

EAS Update 是什么?工作原理与核心概念

EAS Update 是 Expo Application Services 的一部分,通过 expo-updates 客户端库把新的 JS bundle 和静态资源(图片、字体等)打包发布到 Expo 的 CDN,App 启动或后台唤醒时按配置策略拉取并应用。它和 CodePush 的分发模型很像,但底层协议是开源的 Expo Updates Protocol v1,你完全可以用自己的 CDN 或 Cloudflare Worker 自托管。

需要理解四个核心概念:

  • Update:一次发布的 JS bundle + assets 集合,由 eas update 命令生成,有唯一 update ID。
  • Branch:类似 git 分支,同一个 branch 内后发布的 update 会覆盖前一个,通常对应 staging、preview 等环境。
  • Channel:客户端打包时通过 eas build --channel production 绑定的分发通道,服务端根据 channel → branch 的映射决定推送哪条分支的最新 update。
  • Runtime version:定义客户端与 update 的兼容性契约,只有 runtime version 匹配的客户端才会收到对应 update。

分离 channel 和 branch 的设计比 CodePush 的 deployment key 灵活得多。举个真实例子:你可以在 production channel 上先指向 rollout-v3 branch 做灰度,验证一周后再把映射切回 production-stable branch。这一切全部在 EAS 控制台点几下就能完成,客户端不需要重新打包。

CodePush vs EAS Update:功能对比表

下面这张对比表是我在做迁移评审时给团队做的技术选型依据。除了 EAS Update,也把 Shorebird 放进来作为参考。Shorebird 主打的是 Dart/Flutter 侧的 OTA,但也在做 React Native 支持。

维度CodePush(已停用)EAS UpdateShorebird
服务状态2025 年 3 月已下线正常运营正常运营
免费额度N/A每月 1000 MAU 免费10000 MAU 起(RN 尚在 preview)
兼容 bare RN是(预览)
兼容 Expo 项目需自行集成原生支持需自行集成
runtime 兼容性检查手动维护 appVersionruntime version + fingerprint 自动基于 patch number
灰度发布rollout 百分比rollout 百分比 + branch 切换track 分级
自托管选项官方私服镜像(社区维护)协议开源,可用 Cloudflare 自建企业版
CLI 工具appcenter-clieas-clishorebird CLI
与 EAS Build 集成N/A无缝,共享 channel需要额外配置

对于 95% 的团队,EAS Update 是零决策成本的选择,尤其是你已经在用 EAS Build 打包和上架。只有当你有极强的自托管需求(比如金融、政务合规要求资产必须落在自有 CDN),才需要考虑走 Cloudflare Worker 加 expo-updates 的自托管路线。

迁移准备:runtime version 与 fingerprint 策略

迁移前最关键的一步是想清楚 runtime version 策略。这个字段决定了「哪些 App 客户端能收哪个 update」,配置错了,轻则 update 完全推不下去,重则把带原生代码变更的 bundle 推给了旧客户端,直接导致启动即闪退。我在上一个项目就吃过这个亏:一个手滑改了 Podfile,然后照常发了 OTA,第二天早高峰监控里的 iOS 崩溃率直接翻了十几倍。

expo-updates 支持三种 runtime version policy:

  • appVersion:把 runtime version 绑定到 app.jsonversion 字段。适合从不改原生代码、每次版本号只涨 patch 的场景。这也是从 CodePush 迁移最容易理解的模型,因为 CodePush 就是靠 targetBinaryVersion 做兼容性检查。
  • nativeVersion:把 runtime version 绑定到 version + buildNumber。这个是 CodePush appVersion 语义的严格版。
  • fingerprint:EAS Update 0.28 之后引入的策略。CLI 会扫描原生工程的哈希(Podfile.lock、build.gradle、iOS/Android 目录内容、native config plugins),只要有一处变化就生成新的 runtime version。这是我目前给所有新项目的默认推荐,不用再靠人肉记「这次改了原生要不要涨版本号」,工具帮你判断。

app.json 里的配置示例:

{
  "expo": {
    "runtimeVersion": {
      "policy": "fingerprint"
    },
    "updates": {
      "url": "https://u.expo.dev/YOUR-PROJECT-ID",
      "requestHeaders": {
        "expo-channel-name": "production"
      }
    }
  }
}

启用 fingerprint 之后,任何一次纯 JS 修改(组件、样式、业务逻辑)都会保持 runtime version 不变,OTA 推送畅通无阻。而只要你动了 ios/Podfile、加了 native module 或改了 AndroidManifest.xml,fingerprint 会自动更新,EAS Update 就会拒绝把新 bundle 推给还没升级到新 native 版本的旧客户端,避免了 CodePush 时代常见的「JS 引用了一个新 native 方法但旧客户端没有」类型的闪退。

从 CodePush 迁移到 EAS Update:分步指南

下面是我在 Delivery Hero 骑手 App 上做的完整迁移流程,适用于 bare React Native 项目(不是 Expo managed)。整套流程实际耗时约 3 个工作日,其中 2 天在测试原生兼容性。

第 1 步:安装 EAS CLI 与 expo-updates

npm install -g eas-cli
eas login

# 初始化项目,会生成 projectId 并写入 app.json
eas init

# 安装 expo-updates(会同时处理 iOS/Android 原生桥接)
npx expo install expo-updates

如果项目还没有 app.jsoneas init 会引导你创建一个最小配置。bare RN 项目也可以只用 app.json 来给 expo-updates 提供元信息,不影响原有的原生工程结构。

第 2 步:卸载 react-native-code-push

npm uninstall react-native-code-push
cd ios && pod deintegrate && pod install && cd ..

然后手动清理 iOS 侧的 AppDelegate.mm,把 [CodePush bundleURL] 相关行删掉,恢复成默认的 [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]。Android 侧删除 MainApplication.kt 里的 CodePushPackage 引用以及 getJSBundleFile() override。

expo-updates 会通过 autolinking 自动接管 JS bundle 的加载路径,你不需要写任何 native 桥接代码。

第 3 步:配置 channel 并做首次 build

eas.json 里为每个环境定义 channel:

{
  "build": {
    "production": {
      "channel": "production",
      "distribution": "store"
    },
    "preview": {
      "channel": "preview",
      "distribution": "internal"
    }
  }
}

接下来跑 eas build --profile production --platform all,产出的 App 会同时嵌入 channel 元信息与初始 runtime version。这一步的 build 必须走完整的应用商店审核,EAS Update 只能给已经装了新版本 expo-updates 客户端的用户推送。

第 4 步:发布第一个 update

# 发布到 production channel 对应的默认 branch
eas update --channel production --message "Migration from CodePush"

# 或者显式指定 branch
eas update --branch production-stable --message "Fix delivery ETA calculation"

首次发布之后,可以在 Expo Dashboard 看到这次 update 的详情、runtime version、命中人数与 rollout 状态。所有历史 update 都可查、可回滚,不会像 CodePush 那样只保留最近的几个版本。

第 5 步:改造 CI/CD 流水线

把原来的 appcenter codepush release-react -a Org/App -d Production 换成:

- name: Publish OTA update
  env:
    EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
  run: |
    npx eas-cli@latest update \
      --channel production \
      --message "Release ${{ github.sha }}" \
      --non-interactive

建议在 CI 上加一步 fingerprint diff 校验。如果原生 fingerprint 变化,直接拒绝发 OTA,转成触发完整 eas build。这一步能挡下 90% 因误发 OTA 导致的线上事故。

灰度发布与 rollback 实战

OTA 出问题的成本远高于应用商店发版,因为它绕过了商店的最后一道保险。所以灰度和回滚流程必须提前跑通。

灰度发布

EAS Update 从 CLI v10 开始支持 --rollout-percentage 参数:

eas update \
  --channel production \
  --rollout-percentage 10 \
  --message "v2.8.3 hotfix rollout"

# 观察 30 分钟到 1 小时后放量
eas update:edit --rollout-percentage 50
eas update:edit --rollout-percentage 100

放量比例是基于客户端的 installationId 做稳定哈希,同一台设备要么在灰度组,要么在对照组,不会因为反复重启在两组之间横跳。这个细节 CodePush 是没有的,CodePush 的百分比是每次请求随机命中。

回滚(rollback)

发现新 update 有问题时不要慌,也不要试图删除有问题的 update。正确做法是把上一个稳定 update 重新发布一遍:

# 列出最近的 updates
eas update:list --branch production

# 把某个稳定 update 重新发布到指定 branch
eas update:republish \
  --branch production \
  --group <GROUP_ID_OF_STABLE_UPDATE> \
  --message "Rollback to v2.8.2"

为什么用 republish 而不是删除?因为客户端可能已经缓存了坏 update 的 manifest,只有一个更新的 update ID 才能触发新的下载。删除有问题的 update 只影响新装机用户,对已经在用的存量用户没有作用。这个坑我第一次做 rollback 时踩过,直接把 update 删了,结果发现只有还没打开过 App 的人受影响,已经中招的用户还在继续崩。

在 Delivery Hero 我们把「灰度 10% → 观察 30 分钟 → 放量 50% → 观察 1 小时 → 放量 100%」写成了 GitHub Action 里的 workflow_dispatch 手动任务,只有值班工程师才能触发放量。这套流程上线以后,OTA 相关事故从每月 2-3 起降到了半年 1 起。

App Store 与 Google Play 的 OTA 合规底线

OTA 热更新在 iOS 和 Android 上都是允许的,但都有明确的边界。

Apple 的 App Store Review Guidelines 3.3.2 规定:只有解释型代码可以从服务端下载,且下载的代码不能实质性改变 App 的主要用途和功能,不能提供违反商店政策的服务。React Native 的 JS bundle 属于「解释型代码」(Hermes bytecode 也归类在内),所以只要你不做违规内容,OTA 是完全合规的。红线在哪?简单说,不能通过 OTA 把 App 从「记账工具」变成「赌博平台」,也不能绕过 IAP 引导用户走外部支付。

Google Play 的政策更宽松,只要满足 Play 政策关于 Device and Network Abuse 中「不能通过更新引入危害用户或规避政策的行为」这条基线即可。此外,Meta 官方在 React Native 官方文档里也重申过:合规的 JS 层热更新是被鼓励的实践。

我个人的实操建议是:任何涉及计费、支付、鉴权、隐私声明的功能变更,都不要走 OTA,老老实实过一次商店审核。审核花 24 小时,事故后果可能是账号被封。这笔账很好算。

生产环境踩坑与最佳实践

迁移到 EAS Update 之后,还有一些容易踩到的坑:

  • Hermes bytecode 不兼容旧客户端:如果你升级了 React Native 版本,Hermes bytecode 格式可能会变。fingerprint 策略会正确处理,但 appVersion 策略要手动涨版本号。参考我们 启动性能优化指南里关于 Hermes bundle 格式的说明。
  • update 检查时机会影响冷启动:默认 checkAutomatically: ON_LOAD 会在每次冷启动时同步检查 update,弱网下可能阻塞 500ms 以上。生产环境推荐改成 ON_ERROR_RECOVERY 或用 fetchUpdateAsync() 手动后台拉取。
  • 静态资源要单独考虑:字体、大图片如果通过 require() 引入会被打进 update bundle,第一次拉取会明显变大。大资源建议放 CDN 或用 expo-asset 做增量分发。
  • expo-dev-client 与 update 是两码事:开发时用 expo-dev-client 加载本地 Metro bundle,生产才走 expo-updates。别在 dev client 里测 OTA,测不出来的。
  • 不要把 CodePush 私服和 EAS Update 并存太久:过渡期可以同时发,但客户端里保留两套 SDK 会导致 bundle 加载路径优先级混乱,最长过渡期建议不超过 4 周。

常见问题

CodePush 现在还能用吗?

不能。微软 App Center 已在 2025 年 3 月 31 日全面下线,CodePush 服务端不再受理任何 bundle 上传或下发请求。目前市面上有几个社区维护的 CodePush 私服镜像可以临时续命,但缺乏长期支持,不建议用于生产环境。

EAS Update 是免费的吗?

EAS Update 提供每月 1000 MAU 的永久免费额度,超出后按 tier 计费。Production tier 起价 $29/月,覆盖 10000 MAU 与更高的分发流量。企业级项目可以联系 Expo 商务谈自托管方案。

EAS Update 可以用在非 Expo 的 bare React Native 项目上吗?

完全可以。expo-updates 支持任何 React Native 0.71+ 的 bare 工程,只需要安装库、写一份最小 app.json 提供 projectId 和 updates 配置,autolinking 会处理原生桥接。你不必迁到 Expo managed workflow。

如何回滚一个有问题的 EAS Update?

使用 eas update:republish --branch <branch> --group <stable-update-id>,把已知稳定的旧 update 以新的 update ID 重新发布。不要直接删除有问题的 update,因为存量客户端已经缓存了它的 manifest,只有比它更新的 update ID 才会触发下载。

OTA 热更新会被 App Store 拒审吗?

不会,只要遵守 App Store Review Guidelines 3.3.2:只下载解释型代码(JS bundle 或 Hermes bytecode),且更新内容不改变 App 的主要用途、不违反商店政策、不绕过 IAP。React Native 的合规 OTA 使用属于苹果明确允许的范围。

runtime version 用 appVersion 还是 fingerprint?

新项目一律推荐 fingerprint,工具会自动扫描原生工程哈希并阻止不兼容 update。老项目从 CodePush 迁过来可以先用 appVersion 保持语义一致,稳定运行 1-2 个大版本后再切到 fingerprint

关于作者 Marcus Adeyemi

Marcus is a senior React Native engineer based in Berlin with twelve years in mobile, the last seven of them in JavaScript-driven cross-platform work. He spent three years at SoundCloud rewriting the listener app's playback queue and offline-download stack on top of TrackPlayer, and another two and a half years at Delivery Hero leading the rider app's reliability work across 30+ markets. He started out as a native iOS developer at a smaller agency shipping white-label banking apps, which still shows up in his writing whenever the topic turns to bridging Swift code or wrangling Xcode build settings. Most of his posts here cover Reanimated 3 and Gesture Handler internals, CodePush versus EAS Update tradeoffs, and the kind of Hermes crash reports that only show up in production. He occasionally speaks at React Native EU.