构建与发布
代码合进 main 只是完成了三分之二,剩下的部分是"把它变成商店里可下载的包,并保证能持续发出去"。移动端发版和 Web 最大的区别是:产物要过两套完全不同的平台体系(Google Play 与 Apple),签名、版本号、审核、回滚各有各的规矩。这一章把 bare(RN CLI)和 Expo(EAS)两条路线从构建讲到上架,最后给出 OTA 时代"商店包 + 热更新"的混合节奏。
1. 发布全景:两条路线
| 维度 | bare(RN CLI) | Expo + EAS |
|---|---|---|
| 原生工程 | ios/、android/ 自己维护,进 git |
expo prebuild 生成,可进可不进 git(见 4.1) |
| 构建方式 | 本机/自建 CI 跑 xcodebuild + gradlew | eas build 云端出包(也支持本地构建) |
| 签名管理 | 自管 keystore / 证书描述文件 | eas credentials 托管或自配 |
| 提交商店 | 手动/脚本传后台 | eas submit 直传 Play Console / App Store Connect |
| OTA 热更 | 需自接(CodePush 类方案) | eas update 原生支持 |
| 适合 | 已有深度原生定制、要完全掌控构建链 | 大多数新 App,把平台杂务外包 |
两条路线不是对立关系:Expo 底层就是 bare 工程 + 托管服务,理解本章 Android/iOS 原生部分对 Expo 项目同样重要——eas build 出问题(原生构建日志报错)时,看懂 gradle/xcodebuild 输出是唯一的自救手段(原生排障回看 09-原生模块与平台代码.md)。
OTA 是两条路线共用的补充手段,但它有硬边界(只更新 JS/资源,见 4.4),本手册的默认节奏是:商店包承载原生变更,OTA 承载日常 JS 迭代。
1.1 构建环境:先对齐工具链再谈发布
bare 构建失败的第一大来源不是业务代码,而是本机工具链版本错配。0.88 模板给定的锚点(00-安装与配置 侧的环境清单为准,这里是模板实测值):
- Android:Gradle 9.4.1、AGP 9、NDK 27.1、Kotlin 2.2.0;对应的 JDK 主版本以当期模板/官方环境文档要求为准(近几代 AGP 的地板是 JDK 17,不要凭旧教程装旧 JDK)。Android SDK 通过 sdkmanager 装齐 compileSdk 对应 platform 与 build-tools。
- iOS:Xcode 大版本要跟 RN 版本要求走(升级 Xcode 前先查 RN 当期支持),CocoaPods 用 Ruby 环境独立安装(Apple Silicon 上架构混装是历史高频坑,见
10-调试测试与性能.md第 8 节的报错表)。 - Node/包管理:Node 版本与
packageManager字段固定,CI 与本地同一个 lockfile,npm ci不用npm install。
一条经验法则:工具链版本永远从模板抄,不要从博客抄。RN 每月一个小版本,模板是当期唯一保证互洽的组合;手写"我机器上能跑"的版本组合,会在下一次 RN 升级时全部还债。
2. Android:从签名到 Play
2.1 签名:release keystore
调试包用 SDK 自带的 debug keystore 自动签,release 必须有自己的密钥。生成一个(JDK 自带 keytool):
keytool -genkeypair -v -storetype PKCS12 \
-keystore release.keystore -alias myapp \
-keyalg RSA -keysize 2048 -validity 10000凭据不要硬编码在 build.gradle 里,标准做法是放一个 android/keystore.properties 并加进 .gitignore:
storePassword=***
keyPassword=***
keyAlias=myapp
storeFile=../release.keystoreandroid/app/build.gradle 里读取并挂到 release 构建:
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.canRead()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
release {
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}Warning:release keystore 就是你在 Google Play 上的身份。它丢了,原包名的更新就永久断供——Play App Signing 允许你重置"上传密钥",但签名密钥本身不可重置,历史用户包无法再更新,只能换新包名重来(等于用户重装、评价归零)。keystore 至少两处备份(密码管理器 + 离线介质),并且保证团队里不止一个人知道在哪。
CI 里通过 secrets 注入:把 keystore 做 base64 存 secret,构建前解码成文件再跑 gradle;Expo 路线则可直接用 eas credentials 让 EAS 托管。
2.2 模板关键配置读一遍
0.88 bare 模板 android/build.gradle 顶部的 ext 值值得过目(均为模板实测),后续所有版本策略建立在它们之上:
minSdkVersion = 24、compileSdkVersion = 37、targetSdkVersion = 36;- NDK 27.1、Kotlin 2.2.0、Gradle 9.4.1、AGP 9;
gradle.properties中newArchEnabled=true、hermesEnabled=true、edgeToEdgeEnabled=true——新架构、Hermes、edge-to-edge 全屏三件套在模板里默认开启,改动前先确认自己为什么关。
注意:模板显式带
android.builtInKotlin=false、android.newDsl=false两个开关(注释称 AGP 10 将移除它们)。这是模板在 AGP 新实验特性上的保守位——升级 AGP 大版本前不要自行打开或删掉,跟着 RN 官方模板走。
各 SDK 号的现实含义:
- minSdk 24 = Android 7.0(2016 年的系统)。这是 Hermes/RN 运行时的地板,也决定了"用户覆盖 vs 依赖兼容"的平衡;低于 7.0 的设备只剩边角存量,多数依赖链(含 Google Play services)也早已不照顾。
- targetSdk 36(Android 16)是行为开关:系统的新限制(前台服务、通知、权限模型)按 targetSdk 对 App 生效。Google Play 逐年上收新提交/更新允许的目标版本下限,模板值就是贴着政策给的,别主动调低,也别在没读适配清单时抢跑新版。
- compileSdk 37 只决定"编译时能用多新的 API",不影响运行时行为,可以比 targetSdk 超前。
2.3 版本策略
defaultConfig {
applicationId "com.example.myapp"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 42 // 整数,每次上传必须严格递增
versionName "1.4.2" // 用户可见
}两条线各司其职:versionCode 是给系统的,Play 拒绝同码或降码上传,CI 里最好由环境变量注入(versionCode System.getenv("BUILD_NUMBER") as Integer ?: 1)并保证只增不减;versionName 是给人看的,语义化版本即可,通常与 package.json 的 version 对齐,发版打 tag 时两边一起动。iOS 对应两套:CFBundleShortVersionString(marketing 版本,对齐 versionName)与 CFBundleVersion(build number,对齐 versionCode 的角色),见 3.3。
2.4 R8 / ProGuard
模板的 release 默认开启 R8(ext 里 enableProguardInReleaseBuilds = true),配合 hermesEnabled=true。RN 模板已经把 Hermes 字节码、资源压缩相关的 keep 规则处理好了,你要知道的是它的边界:
- 引入依赖反射/注解处理/序列化(Gson 类模型)的库时,通常需要往
proguard-rules.pro补 keep 规则; - 这类问题只在 release 暴露:debug 不跑 R8,一切正常;release 白屏、某功能崩溃、栈里全是
a.b.c。所以合入任何原生依赖后,验收动作是打一个 release 包跑核心路径(流程见10-调试测试与性能.md3.5),而不是 debug 点点就算过。 - 每次构建归档
mapping.txt(3.2 of ch10),丢了它,线上混淆栈无法还原。
2.5 构建命令与产物
cd android
./gradlew assembleRelease # APK → app/build/outputs/apk/release/app-release.apk
./gradlew bundleRelease # AAB → app/build/outputs/bundle/release/app-release.aabGoogle Play 上架要求 AAB(Android App Bundle,公开事实;APK 直传通道早已对新建应用关闭)。APK 产物仍有用途:企业内部分发、没有 Play 的地区、以及给测试群直装。AAB 不能直接安装,它是"由 Play 按设备拆分包"的上游格式,体积话题见第 6 节。
2.6 Google Play 与测试轨道
流程骨架(后台 UI 逐年改版,这里只给稳定结构):开发者账号(一次性注册 + 企业/个人验证)→ 创建应用(包名绑定)→ 上传 AAB 到对应轨道 → 填商店素材与合规表单(数据安全表单如实申报采集项)→ 分阶段放量。
轨道梯度是发版节奏的基础:
- 内部测试:传 AAB 即可用,不走完整审核,几十分钟内可装到受邀邮箱名单的设备上——团队的日常验收轨道;
- 封闭/公开测试:扩大人群,公开测试入口可被任何人搜到,注意版本口碑;
- 生产:完整审核,支持分阶段放量(staged rollout,按百分比推全量)。
实操含义:把"内部测试轨道"当作移动端语境下的预发环境——每个候选版本先滚内部测试(配合 OTA 预览通道,见 4.4),过了验收门槛再进生产。
另外建议新应用直接启用 Play App Signing(Google 托管签名密钥,你上传的只是"上传密钥"):它缓解的是"上传密钥泄露/丢失"的日常风险,但签名密钥一旦在 Google 侧丢失就是永久事故,2.1 的备份纪律照样不能省。
2.7 Flavor 与构建类型(按需再上)
模板默认只有 debug/release 两个 build type。国内分发或环境隔离常见"一套代码多产物",用 product flavor 而不是复制工程:
flavorDimensions "env"
productFlavors {
dev {
dimension "env"
applicationIdSuffix ".dev" // 与正式包共存安装,图标/名称也建议区分
versionNameSuffix "-dev"
}
prod { dimension "env" }
}applicationIdSuffix 让内测包和正式包在同一台手机上并存,配合 2.6 的内部测试轨道非常实用。注意两点代价:CI 矩阵翻倍(每个 flavor × 构建类型都要出包才覆盖得到);原生 SDK 的按包名注册(推送、地图 key)要为每个包名各配一份。没有多环境诉求就别提前上 flavor,app.json/环境变量分层通常够用。
3. iOS:证书、构建与 TestFlight
3.1 先建立证书心智
新手在 iOS 签名上卡住的时间普遍超过写代码的时间,值得先把四个概念的关系摆正:
- Apple 开发者账号 / Team:一切的根;
- 证书(开发证书 / 发布证书):证明"这台机器/这个流程有权代表你的 Team 出包";
- App ID:登记你的 bundle identifier 及其能力(推送、Sign in with Apple 等 entitlement);
- 描述文件(Provisioning Profile):把上面三者打包,并限定可用于哪些设备(开发)或哪种分发方式(App Store/Ad Hoc)。
日常路径是 Xcode 的 Signing & Capabilities 勾选自动管理,选好 Team,上面这套由 Xcode 悄悄生成维护。它的限制在于:自动签名绑定的是这台 Mac 上登录的 Apple 会话——多人各自自动签、或 CI 无人值守构建时,常见"证书缺失 / No matching profiles"的互相踩脚。团队化解决方向二选一:指定一个人(一台机器)统一负责签名产物,或引入 fastlane match 这类"把证书描述文件加密存进 git 仓库共享"的方案(命令和配置以 fastlane 文档为准,此处不展开)。
3.2 命令行构建骨架
Xcode GUI 出包之外,命令线的稳定骨架:
cd ios
pod install # 新增 node_modules 依赖后必跑;Expo 项目由 prebuild/run 自动处理
xcodebuild -workspace MyApp.xcworkspace -scheme MyApp \
-configuration Release -archivePath build/MyApp.xcarchive archive
xcodebuild -exportArchive -archivePath build/MyApp.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-allowProvisioningUpdatesExportOptions.plist 声明导出方式(app-store 上传、ad-hoc 内测、enterprise 等)与 Team ID;-allowProvisioningUpdates 允许 xcodebuild 登录态下自动补描述文件。产出 .xcarchive 后,用 Xcode Organizer 或 Transporter 上传到 App Store Connect。
3.3 TestFlight 与两个版本号
App Store Connect 的元数据里有两个号,关系必须记死:
- 营销版本号(CFBundleShortVersionString):面向用户,如 1.4.2,对应 Android 的 versionName;
- build 号(CFBundleVersion):同一次上传必须递增。同一个营销版本号可以反复传多个 build 给 TestFlight 迭代,只有最后选定的那个才会提交审核。
TestFlight 流程方向:上传 build → 出现于 App Store Connect 的 TestFlight 页 → 补导出合规答案(加密使用问题;只用系统标准加密的 App 可在 Info.plist 声明 ITSAppUsesNonExemptEncryption=false 一劳永逸免答)→ 内部测试员(团队成员,即时)/外部测试员(公开链接,需经过一轮相对宽松的 beta 审核)。TestFlight 是 iOS 侧唯一现实的"内部测试轨道",把 2.6 的预发节奏平移到"TestFlight 常驻 + 生产分阶段发布"。
3.4 ATS、权限文案与隐私清单
上架被拒的 iOS 高频原因里,签名技术占不到,材料占大头:
- ATS(App Transport Security):Apple 默认要求 HTTPS。本地开发给 Metro/局域网后端开的明文例外(
NSAllowsLocalNetworking一类)不要带上线,上架前清掉全局NSAllows ArbitraryLoads;网络层细节回看05-数据获取与网络.md。 - 权限用途文案:相机、相册、定位、通知等每一个 usage description(
NSCameraUsageDescription等)都必须说清"什么场景、用来干什么"。只写"需要使用相机"这类空话是被拒经典原因;文案与交互入口要一致(申请定位前界面上得看得见定位功能),表单侧的触发时机见06-表单与交互.md。 - Privacy Manifest(PrivacyInfo.xcprivacy):Apple 对特定第三方 SDK 强征"数据收集与用途"清单。近年的 RN 运行时与主流库已随包自带,Expo 项目会在构建阶段被处理到基本不用操心;自己手写的原生代码或小众库若触发采集 API,需要按 Apple 文档补清单,并在后台填好隐私"营养标签"。
3.5 Entitlements、推送与两个分发形态
签名的近亲是 entitlements(权限声明文件):推送(APNs)、Sign in with Apple、后台模式、App Groups 这类能力,描述文件里开了还不算,entitlements 与代码里的申请必须三处一致,否则表现为"签名过了、运行时功能就是不好使"。几个高频点:
- 远程推送需要 APNs 密钥/证书且区分 development 与 production 环境——TestFlight/商店包走 production 通道,开发包走 development,混了就是"模拟器能收到、真机 TestFlight 收不到"的经典事故;Expo 项目可让 EAS 托管这一层。
- 能力开关在 Xcode 的 Signing & Capabilities 里加,它同时改 entitlements 文件和 App ID 配置——手动只改其中一处是常见的"本地好使、上传就炸"来源。
- 分发形态记三个就够:App Store(TestFlight/正式分发共用此构建方式)、Ad Hoc(绑定注册设备、限额,给少量外部用户直装)、Enterprise(需企业账号,常规团队不碰)。日常全部走 TestFlight 即可,不需要 Ad Hoc。
4. Expo 路线:prebuild、EAS Build / Submit / Update
4.1 expo prebuild 与原生工程进不进 git
npx expo prebuild --clean # 依据 app.json + config plugins 重新生成 ios/ android/
npx expo run:android / run:ios # 生成并直接构建安装(开发期日常)Expo 官方推荐的姿势叫 CNG(Continuous Native Generation):原生工程被当构建产物——不进 git,每次构建/升级时重新生成,定制全部收口到 app.json、plugins 和 config plugin 里。取舍要看清:
- 进 git 之前先想代价:不签入意味着任何直接改
android/app/build.gradle、Info.plist的手工改动都会被--clean抹掉,可复用的改法只有写成 config plugin 一条路; - 好处在升级时兑现:RN/Expo SDK 小版本滚动时,重生成工程远比在两套陈旧原生工程上解决合并冲突省力(版本线见
01-核心概念与架构.md); - 有存量原生资产(手写原生模块、既有工程迁移)的团队,把 prebuild 产物签进 git 当作 bare 来用完全可以,只是从此"重新生成"的自由度换成了手工维护责任。
4.2 EAS Build:profile 三件套
eas.json 的 profile 结构是长期稳定的公开格式:
{
"cli": { "version": ">= 13.0.0", "appVersionSource": "remote" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"distribution": "internal",
"channel": "preview"
},
"production": {
"autoIncrement": true,
"channel": "production"
}
},
"submit": { "production": {} }
}npx eas build --profile production --platform all # 双端正式包,云端构建
npx eas build --profile preview --platform android # 内测 APK 直装
npx eas build --profile development --platform ios # development build(见 10 章)三个 profile 对应三种用途:development 出"能连 Metro 的开发包";preview 出给 QA/内测用户的包(Android 可直接下载 APK,iOS 走 TestFlight 分发);production 出商店包,autoIncrement: true 让 EAS 替你维护版本递增(对应 ext 的 appVersionSource: remote,版本号存在云端不再手填 buildNumber)。云端排队慢或合规不允许外传代码时,--local 用 EAS 流程在本机构建。SDK 58 起 EAS 侧还带了 Expo Observe(原生崩溃记录,AppMetrics 的后继),发版验收直接看它的崩溃率(背景见 10-调试测试与性能.md 3.5)。
4.3 EAS Submit 与凭证
npx eas credentials # 查看/生成 keystore、证书、描述文件,可选 EAS 托管
npx eas submit -p android # 把 production 构建产物传到 Play Console
npx eas submit -p ios # 传到 App Store ConnectAndroid 侧 eas credentials 可以把 keystore 存进 EAS(也可导出自管,导出后记得 2.1 的备份纪律);iOS 侧同样支持托管证书。eas submit 只搬运产物到后台,商店素材、定价、隐私表单仍在各家后台填——它是"上传器"不是"发布会导演"。
4.4 EAS Update:OTA 的能力与硬边界
npx eas update --channel preview # 把当前 JS/资源发布到 preview 通道
npx eas update --channel productionEAS Update(expo-updates 客户端库支撑)是发版节奏里真正的提速器,但边界必须先背下来:
- 只更新 JS bundle 和静态资源。任何"原生面"的变化——加/升原生依赖、改
app.json里影响原生工程的字段、升 RN/Expo SDK、改权限文案——必须重出商店包,OTA 送不过去; - channel 语义:更新发布到 branch,branch 绑定到 channel;商店包里烙进了它可监听的 channel 与自己的 runtime version。旧商店包只会收到 runtime 匹配的更新——这是防止"新 JS 依赖新原生 API、老包 OTA 后当场崩"的保险丝。给灰度用户开 preview channel,验证后合入 production,就是按这条通道线切换;
- 支持按比例放量(rollout),出问题把通道指回上一个 update 组即可回滚,秒级生效——这比商店审核周期短了三个数量级(对比表见第 5 节)。
这条防线的开关在 app.json 里,典型形态:
{
"expo": {
"updates": { "url": "https://u.expo.dev/<projectId>" },
"runtimeVersion": { "policy": "native" },
"channels": [
{ "name": "preview" },
{ "name": "production" }
]
}
}runtimeVersion 决定"这个 JS 包认哪些原生壳",常见 policy 取 native(跟着原生依赖面走)、appVersion(跟营销版本走)、fingerprint(对工程内容做指纹)三种;团队要选定一种并全程不换,换了 policy 等于所有历史包的匹配关系重来。policy: appVersion 的隐患值得点破:两个营销版本相同、但原生依赖不同的商店包会被判成同一 runtime,新 JS 推给老壳照样崩——所以多数团队用 native 起步。
客户端侧还有两条行为纪律值得写进约定:
- 检查时机:expo-updates 支持"启动后台检查 / 仅手动检查"。多数 App 用启动后台检查、下次冷启动生效就够了;追求"当次会话立即生效"要显式做 check-and-restart,代价是用户偶发"App 自己重启了"的困惑——选哪种都行,全团队统一、写进文档。
- 商店包与 channel 的绑定在构建时烙死:preview 包监听 preview channel、production 包监听 production,测试时看到"preview 收到了线上推送"或反之,先怀疑包本身(用错 profile 构建),而不是怀疑平台。
注意:最常见的 OTA 事故不是发错代码,而是发完 JS 忘了它隐含原生前提。规则定死:写 OTA 之前先回答"这个改动碰没碰原生",答案存疑就当碰了,走商店包。
4.5 OTA 与商店审核政策
Apple 允许热更新的范围是"以解释方式执行的代码"(JS 层),不允许下载改变执行能力的原生代码、不允许 OTA 大幅改变 App 上架时的核心功能;Google Play 相对宽松。合规实操就三条:JS/资源修补与文案调整随便走 OTA;新增需要权限或原生能力的大功能走商店包;被 Apple 联系质询时能解释清楚某次 OTA 只是 bug fix。这是政策的方向描述,具体细则以 Apple/Google 当期文档为准。
5. 混合策略:版本管理、灰度与回滚
RN 版本线 ≠ 你的 App 版本线。 RN 保持约一到两个月一个小版本的发布节奏(0.88 对应 2026-10,映射与升级策略见 01-核心概念与架构.md),而 App 的 versionName 按产品节奏走。混在一起的后果是:一次 RN 升级被迫绑进商店大版本。稳定做法是独立维护"升级分支",一次只升 RN/SDK 并回归,不夹带业务功能。
两条发布通道的分工,值得贴在发布 runbook 首页:
| 维度 | OTA(EAS Update) | 商店发布 |
|---|---|---|
| 能改什么 | JS、静态资源 | 一切(含原生) |
| 触达速度 | 发布后秒级~分钟级 | iOS 审核小时~天;Play 内部轨分钟级、生产需审核 |
| 回滚速度 | 通道指回上一版,秒级 | 重新提交 + 审核,天级;Play 可紧急停止放量 |
| 灰度方式 | rollout 百分比 / channel 分流 | Play staged rollout;App Store 分阶段发布 |
| 主要风险 | JS 与原生预期错位 | 审核被拒打乱节奏 |
节奏模板:日常迭代全部走 OTA(preview channel 灰度半天 → production 全量 → 保留上个 update 组 48 小时作回滚弹药);原生变更攒成"平台版本",每月或每双月一个,走商店,发版后把 OTA 的 runtime 基线切到新版。注意 OTA 也有覆盖盲区:商店强制更新、App 被下架审核期间,OTA 救不了你——别把它当成唯一发布通道。
6. 包体积
体积直接影响商店转化(用户流量下载)和低端机内存占用,按大头排序处理:
- Android 用 AAB 天然减重:AAB 由 Play 按设备生成拆分产物(ABI、屏幕密度、语言各自拆),用户下载量远小于全量包。RN 的
.so原生库按 ABI 打包(libhermes 等),具体每 ABI 的数字随版本和依赖变——以构建分析报告与 Play Console 的设备尺寸表为准,不要拍脑袋报数。仍要直装 APK 分发的场景,考虑按 ABI 拆多包或明确接受全量体积。 - 图片是 JS 之外的头号增量:
@3x滥用(给只需要 1x 的装饰图配三套资源)是看不见的体积税,密度资源策略见02-核心组件.md。大插画改远程加载或 WebP,图标走向量(react-native-svg 类),拍照产物一律压缩再存(08-存储与文件.md)。 - JS bundle:Hermes 字节码本身就比裸 JS 小且启动快,模板默认已启用。对 tree-shaking 保持现实预期:Metro + Babel 管线对"摇掉未用 export"的能力有限,最有效的减重是人肉砍依赖——为一个小函数 import 整个库、装两个功能重叠的库,是 bundle 膨胀的两大病根。
- 字体与杂项资源:中文全量字体动辄数 MB,做子集化或远程加载;assets 目录里的"设计稿直接拖进来"的 PNG 往往比业务图还大,发版前把资源目录当依赖一样审一遍。
- iOS:App Store 的 App Thinning 自动按设备裁剪,流程等价,注意后台显示的"下载大小"与上传体积的差。
量体积用官方工具而不是猜:Android 的 apkanalyzer(SDK cmdline-tools 自带)能按 dex/资源/库拆构成,Xcode 的 Report Navigator 和 App Store Connect 的 App Size 报告给出 iOS 侧的真数。每个发版把这两个数字记进发布记录,体积曲线环比恶化比绝对值更有行动意义。
7. 实操:从开发机到双端商店
一次完整发版的走法(双路线通用,括号里标 Expo 差异):
- 版本三连 bump:
package.jsonversion → AndroidversionCode/versionName→ iOS 营销版本/build 号(Expo 用appVersionSource: remote时由eas build自增);打 tag,发布记录落到版本说明。 - 门禁全绿:
npm run lint+npx tsc --noEmit+npm test -- --ci;发版分支上跑一遍 Maestro 冒烟(10-调试测试与性能.md4.3、6)。 - 出 release 产物(bare:
./gradlew bundleRelease与 3.2 的 xcodebuild archive;Expo:eas build --profile production --platform all)。 - release 包真机验收:低端 Android 真机装 APK,TestFlight 装 iOS build——跑 3.5 of ch10 的四步(R8 路径、错误上报符号化确认)。
- Android 进内部测试轨道(
eas submit -p android或 Play Console 上传 AAB),内测名单验证核心流程;iOS 等 TestFlight 构建处理完成 + 补导出合规,内部测试员装测。 - 双端商店素材过一遍截图/文案(iOS 对截图与元数据一致性更严格)。
- 合规表单:Play 数据安全表单如实填、iOS 隐私营养标签 + 权限文案核对(3.4),两边内容分级问卷更新。
- 提交生产审核:iOS 先提交审核再开分阶段;Android 用 staged rollout 从低百分比起。
- 审核期并行准备 OTA:把审核通过的期望 JS 基线发布到 preview channel,商店包放量到位后切 production runtime。
- 放量观察:崩溃率/ANR 率对照 baseline(Play vitals、Expo Observe 或自建崩溃平台),异常则回滚——OTA 秒级回滚,商店包 Android 可停放量、iOS 走"此版本暂停自动更新"。
- 归档本次构建的
mapping.txt/ source map / dSYM(10 章 3.1-3.2),发版才算闭环。
时间窗纪律附在清单末尾:iOS 提审避开周四之后(审核横跨周末等于白等两天),Android 放量从低百分比起步并给自己留观察窗,OTA 全量切换放在有人值守的工作时段——任何"发完就走"的动作都会在夜里变成事故。
注意:iOS 审核被拒后重提,若营销版本号不变则 build 号必须再 +1;Android 内部测试包与生产包可能因轨道不同共享 versionCode 空间,同一码重复上传会被拒——版本号规划永远只增不减。
7.1 CI 上跑原生构建的骨架
10-调试测试与性能.md 第 6 节的 PR 门禁只跑 JS;原生产物构建放发布流水线,典型形态:
# .github/workflows/release-build.yml(骨架,secrets/签名按团队约定注入)
name: Release Build
on:
workflow_dispatch:
jobs:
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17 # 以当期模板/官方环境要求为准,可能随 AGP 上移
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- name: Decode keystore
run: echo "$KEYSTORE_B64" | base64 -d > android/release.keystore
env: { KEYSTORE_B64: "${{ secrets.KEYSTORE_B64 }}" }
- run: cd android && ./gradlew bundleRelease
- uses: actions/upload-artifact@v4
with: { path: android/app/build/outputs/bundle/release/*.aab }
ios:
runs-on: macos-14 # xcodebuild 只能跑在 macOS runner 上
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci && npx pod-install
- run: >
xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp
-configuration Release -archivePath build/MyApp.xcarchive archive三个现实提醒:iOS job 的签名复杂度(钥匙串、描述文件)在 CI 上会放大,自建 runner 建议配 fastlane(match 共享证书、lane 把 3.2 那串命令编排成一句),或者干脆让 EAS 云端代管——小团队的最优解通常是后者,自建 macOS 农场是成本黑洞。产物要么进对象存储,要么直接由 eas submit/fastlane 送后台,别停在"下载自 CI 日志"的阶段。
原生构建的提速靠三级缓存,缺一个都会让 CI 慢到没人愿意等:setup-node 的 npm 缓存(lockfile 为键)之外,Android 加 Gradle 分布式缓存(actions/cache 命中 ~/.gradle/caches 与 build 目录),iOS 加 CocoaPods 与 DerivedData 缓存。缓存键要带上 lockfile、gradle-wrapper.properties、Podfile.lock 三者的哈希——任何一个变了都必须失效重建,否则命中一个陈旧缓存会产出"和本机不一致"的包,比慢更可怕。
7.2 发布后 48 小时看什么
发出去不是终点,验收窗口内的四块面板:
- 崩溃率与 ANR:Play vitals、Expo Observe(SDK 58 起替代 AppMetrics)或自建崩溃平台,与上一版本 baseline 对比,异常抬升即触发回滚预案(OTA 秒级回滚 JS 问题,商店问题停放量);
- OTA 覆盖:各 channel 上更新的下发/安装成功数与失败原因,卡住的量通常指向旧 runtime 不匹配(4.4);
- 核心业务漏斗:发版前后同口径对比一次,防止"技术指标全绿但用户路径断了";
- 商店评分与反馈关键词:审核材料之外的真实用户信号,iOS 评分骤降要当天看评论是崩溃还是功能争议。
8. 常见构建报错速查
构建期的报错同样模式化,按症状查(JS 红屏类见 10-调试测试与性能.md 第 8 节):
| 症状 | 侧 | 原因 | 修复 |
|---|---|---|---|
SDK location not found |
Android | 本机没配 Android SDK | 设 ANDROID_HOME 或 android/local.properties 写 sdk.dir |
Could not resolve com.facebook.react:react-android |
Android | 仓库配置/公司代理 | 核对 settings.gradle 的 repositories;代理配到 gradle.properties |
Gradle OutOfMemoryError: Java heap space |
Android | 原生编译吃内存 | android/gradle.properties 的 org.gradle.jvmargs 加大 -Xmx |
Manifest merger failed |
Android | 依赖间权限/占位符冲突 | 看 merger 报告定位到冲突依赖,在其源头修,不硬删自己的配置 |
No matching provisioning profiles found |
iOS | 自动签名绑定本机 Apple 会话 | 重选 Team;CI 统一走 fastlane match 或 EAS 托管凭证 |
Build input file cannot be found(升级 RN 后) |
iOS | 构建缓存与 Pods 状态错位 | Xcode Clean Build Folder,pod deintegrate && pod install;Expo 项目 prebuild 重生成 |
CocoaPods could not find compatible versions |
iOS | podspec 与 RN/依赖版本冲突 | pod repo update;按 RN 版本兼容矩阵对齐依赖 |
bundle 阶段 Cannot find module 'react-native-...' |
双端 | 依赖没装、monorepo hoisting 与 Metro 配置不符 | 重装并锁 lockfile;monorepo 检查 Metro watchFolders(见 09-原生模块与平台代码.md) |
eas build 报 credentials/keystore 无效 |
Expo | 托管凭据与实际文件不匹配 | eas credentials 重新核对上传 |
| 构建成功但装完即崩 | 双端 | release 与 debug 行为差异(R8/签名/环境配置) | 回到 10-调试测试与性能.md 3.5 的 release 验收流程 |
9. 发版流程与团队协作规范
双端发版翻车的另一半原因不在命令,在流程。这一节是给团队的"发布 runbook 骨架"。
9.1 Release 分支与列车节奏
推荐"发布火车"而不是"随时发版":
- 日常 PR 全部合入 main,功能按是否完成进 flag 或延后,不做"为发版攒分支"的长命 feature branch;
- 固定节奏开一班车:cut
release/1.5→ 打 RC 包(Android 内部测试轨道 + TestFlight 内部组)→ 冻结窗口内只允许 bug fix 以 cherry-pick 方式进 release 分支 → 验收过线即提交商店; - tag 规范
v1.5.0,商店"新功能介绍"文案从 commit 规范(Conventional Commits 风格)聚合初稿,人工润色——两端商店的变更说明都是用户可见承诺,别让 CI 原文照上。
列车节奏对移动端的特殊价值:iOS 审核周期不可控,固定班车 + 预留 buffer 远比"做完了再提交、被拒了再等"省心脏。
9.2 Hotfix 路径决策
线上出事的修复路径按"改动碰没碰原生"分叉:
| 事故类型 | 通道 | 生效时间 | 备注 |
|---|---|---|---|
| JS 逻辑/文案/资源 bug | OTA | 分钟级 | 走 preview 验证后全量,保留回滚组 |
| 后端接口打挂核心路径 | 服务端/开关 | 分钟级 | 与 App 版本解耦,第一顺位 |
| 原生/SDK 层 bug | 商店包 | 天级(iOS 审核) | Android 立即停止放量,iOS 按 9.1 预留 buffer |
注意:业务开关(feature flag / 服务端下发配置)常被低估——它是唯一"不依赖商店和 OTA、对已崩路径即时止血"的层。核心入口都应该有开关可关,这比任何回滚手段都快。
9.3 值守与权限分配
- 每次发版指定一个 release owner(执行第 7 节清单的人)+ 一个审批人(版本三连与商店文案双人过目);
- keystore/证书、两个商店后台账号、OTA 平台账号,任何一项都要保证至少两人可及——发版事故最坏的形态是"唯一知道密码的人休假";
- 发布记录(版本号、产物哈希、mapping/dSYM/source map 归档位置、放量节奏、观察窗结论)落到固定模板,一年后复盘全靠它。
9.4 首次上架的预留周期
新 App 第一次上双端,工作量和后续版本完全不同量级:Apple 开发者账号审核(个人/企业、D-U-N-S)、Play 开发者政策核验与应用验证问卷、隐私标签与截图素材、内容分级问卷,每一项都可能带几个工作日的外部等待。给首次上架留两周 buffer,并从第一版就按第 7 节清单把合规材料做成模板,后续版本只改差异。
10. 实战建议
- 签名是身份不是配置:keystore/证书进密码管理与双人可及备份,永不进 git;CI 用 secrets 或平台托管(EAS credentials),本地开发不持有 release keystore 才是常态。
- 两条路线按团队选,别混着猜:没有存量原生工程的新 App 直接 Expo + CNG + EAS 全套;坚持 bare 就把 2、3 章的原生命令练熟,EAS 只当可选的云端构建机。
- 每次 release 都真机验收:debug 通过 ≠ release 通过,R8 与签名差异只在真 release 包暴露(ch10 3.5)。
- 版本号纪律:versionCode/build 号只增不减、由 CI 或 EAS 自增;versionName 跟
package.json对齐打 tag;RN 升级单独开分支,不搭业务版本的车。 - OTA 当补丁通道,商店当能力通道:碰原生的改动一律重出商店包;发布前必答"这个改动碰没碰原生",channel 与 runtime 版本是防错位事故的安全带,不是摆设。
- 回滚预案先写再发:OTA 通道的上一版本保留 48 小时,Android 用分阶段放量可随时停,iOS 审核周期不可压缩——发版窗口避开节假日,给自己留出被拒重提的缓冲。
- 体积从依赖和图片下手:AAB 交给 Play 拆分,人工盯住新增依赖与图片资源这两条增量曲线,每个发版记一次包体积数字,恶化超过几个百分点就立案。
- 合规材料是发版工作量的大头:权限用途文案、隐私清单/营养标签、数据安全表单在 iOS 侧能直接卡死上架——把它们当代码评审(PR 里新增权限字段要人 review),而不是发版前一晚的填空。
- 工具链版本从模板抄,签名复杂度能外包就外包:环境错配是 bare 构建的头号事故源(1.1);CI 上自建 macOS runner + 钥匙串管理对小团队是负资产,EAS 云端构建 + 托管凭证是更现实的默认值(7.1)。
- 发布记录是一份 runbook:版本三连、runtimeVersion policy、keystore 备份位置、mapping/dSYM 归档位置、回滚触发阈值——全部写进发布模板,每次发版 48 小时观察窗(7.2)按表执行。人员会流动,runbook 不会。
- 窗口与值守是发版的一部分:iOS 提审避开周四之后,OTA 全量切换放在有人值守时段,首次上架预留两周外部审核 buffer(9.4)——发版排期表上,“等审核"和"观察窗"要占正式格子,不是脚注。