将开发版和生产版与 app 变体并排安装
别再为了调试而卸载生产环境的 App。App variants 让每个 Expo 构建拥有独立身份,dev、preview 和 production 可以同时装在一台设备上。
中文
复制

你刚把应用发到生产环境,恭喜!用户在装了,但很快第一份 bug 报告就来了。你在手机上打开生产版本,果然,问题就在那儿。你写好修复,装上开发版本,开始排查。
一部手机上同时只能装一个版本的应用。听起来没什么问题,直到你这周第三次为了调试开发版本里的东西而卸载生产版本,再装回去,然后又卸掉。总该有更好的办法。
App variants 解决的就是这个问题:让每个构建有自己的标识符,于是各个变体(比如 dev、preview 和 production)可以并排装在同一台设备上,各自被当作独立的应用。
对我来说这是实打实的开发体验提升,也是我在真实项目里最先配置的东西。
App variant 由什么构成
可以把每个构建都看成有两个互不相关的设置:身份和环境。
第一个是身份,也就是 iOS 上的 bundle identifier 或 Android 上的 package name,比如 com.myapp.app。身份是在原生构建时写死的。它决定什么算是一个独立的应用,以及新安装会不会覆盖旧的。一台设备上每个标识符只能有一个应用,所以如果开发版本和生产版本共用一个标识符,装其中一个就会替换掉另一个。给每个变体各自的标识符,它们就能并排留在设备上。
在 expo start 阶段做的任何事都不会改变身份。原生身份写死在应用的原生代码里,所以本指南假定你用的是自己在本地机器上或通过 EAS Build 创建的自定义构建。它不适用于 Expo Go——Expo Go 下所有项目都跑在同一个身份里。
第二个是环境,也就是求值 app config 时加载的那组变量。EAS 有三个内置环境:development、preview 和 production,API URL、分析密钥这类值就放在环境里。环境决定应用跑起来之后的行为。
第 1 步:让应用配置支持不同变体
你的项目多半已经有一个静态的 app.json。静态文件适合存放稳定的值。前面说过,变体要能并排安装,就得各自有独立的标识符。但标识符写在应用配置里,而静态文件只能存一个。所以你需要一份能根据变量设置取值的配置,也就是动态配置。我一般用 app.config.ts,这样配置有类型;如果你更愿意留在纯 JavaScript 里,app.config.js 也够用。
两个配置文件
两个文件都存在时,Expo 先读取 app.json,把它作为 { config } 交给动态配置,然后采用动态配置返回的结果。可以把 app.json 看作底层,app.config.ts 是叠在上面的一层薄薄的覆盖。要让这套机制生效,动态配置必须导出一个函数。如果它导出的是普通对象,同时又存在 app.json,静态文件会被忽略。
app.json 存放稳定的值和默认值。
{
"expo": {
"slug": "my-app",
"owner": "your-org"
}
}
app.config.ts 存放覆盖值。一个简单的例子:
import { ExpoConfig, ConfigContext } from "expo/config";
const appName = "MyApp";
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: appName,
});
用 APP_VARIANT 做分支
要创建应用变体,就得告诉配置这次构建的是哪一个。为此我们引入 APP_VARIANT,一个配置可以直接读取的普通环境变量。这个名字只是约定,叫什么都行。你也可以按需增加更多或不同的变体。用它做分支来选定身份。
import { ExpoConfig, ConfigContext } from "expo/config";
const APP_ID_PREFIX = "com.myapp";
function getName(base: string) {
switch (process.env.APP_VARIANT) {
case "production":
return base;
case "preview":
return `${base} (Preview)`;
default:
return `${base} (Dev)`;
}
}
function getAppId() {
switch (process.env.APP_VARIANT) {
case "production":
return APP_ID_PREFIX;
case "preview":
return `${APP_ID_PREFIX}.preview`;
default:
return `${APP_ID_PREFIX}.dev`;
}
}
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: getName(config.name ?? "MyApp"),
ios: { ...config.ios, bundleIdentifier: getAppId() },
android: { ...config.android, package: getAppId() },
});
这里每个变体各占一行,配一个 switch,辅助函数保持可读,新增变体只是多一个 case。ID 的公共部分放在 APP_ID_PREFIX 里,每个 case 只设置后缀,将来基址要改也只有一处。
注意我们展开了 ...config,它把 app.json 里的值嵌入动态配置。如果忘了展开 config,app.json 里的内容就全丢了。再往下一层也是同样的道理:设置 bundleIdentifier 时如果不先展开 config.ios,新 ID 会保留,但 ios 下的其他内容都会丢失。
拿不准的时候,运行 npx expo config 看看 Expo 最终解析出的配置。加上 --json 可以得到机器可读的输出;如果你装了 jq,用 npx expo config --json | jq .name 能取出单个字段。
我们选择 development、preview 和 production,是为了与 eas build:configure 创建的默认构建配置保持一致。另外,EAS 环境变量(你的变量最终会放在这里)也自带同样的默认环境。顺带一提,构建配置想加多少都行,但内置的三个之外,自定义环境只在 Production 和 Enterprise 套餐中可用。
这两个概念我们会在下文进一步展开。
为什么两个文件都留着
你完全可以只保留 app.config.ts 作为唯一的配置文件,不少应用就是这么做的。问题在于,Expo 的工具链只会写入静态的 app.json。该文件存在时,eas build:configure 和 eas update:configure 会替你填好值;不存在时,就只能退而求其次,让你手动添加。有些服务要求更严,必须有静态的 app.json。比如 Expo Launch 没有 app.json 文件就跑不起来,因为它会把 name 和 bundleIdentifier 这类身份字段写进你的配置,而动态配置无法接收这些内容。
默认落到 development
你可能已经注意到,在 switch 里我让缺失的 APP_VARIANT 落到 development。这不是硬性要求,但我更希望生产环境的身份只有主动指定时才出现。你在本地运行的命令——expo start、expo run 和 expo prebuild——不会让你选变体,默认是什么就是什么。本地几乎每次都是 dev 构建,那不如就让它当默认值。
APP_VARIANT 覆盖的变体是固定的,所以这个 switch 在设计上就是穷尽的。一旦你的配置根据变体分支,去取名称和标识符之外的值,默认值就变得更重要了。假设你在 getAppId() 旁边加一个 getBaseUrl()(或者任何你想放进去的环境专属值),把结果喂给配置,让每个变体有自己的 API URL。APP_VARIANT 只对配置可见,所以示例要通过 extra 把值传进应用代码。
function getBaseUrl() {
switch (process.env.APP_VARIANT) {
case "production":
return "https://example.com";
case "preview":
return "https://preview.example.com";
default:
return "https://dev.example.com";
}
}
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
// ... rest of your app.config.ts config here
extra: {
...config.extra,
apiUrl: getBaseUrl() // read back via Constants.expoConfig.extra.apiUrl
},
});
这样一来,默认值决定了你打到哪个后端。落到 development,你就留在 dev 后端;落到 production,你就连上真实后端。具体后果因应用而异:可能大张旗鼓地失败,比如认证后端拒绝 dev 的 access token;也可能悄无声息地成功,用测试数据污染生产环境。
之后可以把 APP_VARIANT 挪到 EAS 环境变量里,再拉到本地,到那时它几乎总是有值,默认值很少会派上用场。
第 2 步:在 EAS 上构建各个变体
现在配置会响应 APP_VARIANT,所以 EAS Build 需要为每次构建设置这个变量。最简单的做法是在 eas.json 里为每个 profile 加一个 env 块。
{
"build": {
"development": {
"developmentClient": true,
"env": {
"APP_VARIANT": "development"
}
},
"preview": {
"distribution": "internal",
"env": {
"APP_VARIANT": "preview"
}
},
"production": {
"env": {
"APP_VARIANT": "production"
},
"autoIncrement": true
}
}
}
运行 eas build --profile development,EAS 会在解析配置之前设置好 APP_VARIANT=development。dev 身份随之生效,dev 构建与生产构建并存安装,互不干扰。真要说的话,development profile 并不需要这个变量,因为按第 1 步的设定,development 本来就是默认值。生产环境必须显式设置,而它的 build profile 正是设置的地方。
变量放在哪里
在每个 eas.json profile 里设置 APP_VARIANT 就足以让各个变体构建起来,作为起点也没什么问题。但那个 env 块只对 eas build 生效(比如 eas update 看不到你 eas.json 里的 env 块)。事实上,其他所有会解析配置的命令,包括你本机上的 expo start 和 expo run,都看不到它,而是从本地 shell 读取 APP_VARIANT,所以眼下你得自己设置:像 APP_VARIANT=development npx expo start 那样内联,写进 package.json 脚本,或者放进 .env 文件。
EAS 可以替你保存这些变量。用 EAS 环境变量,变量存放在 EAS 上、归属于某个 environment,而不是放在 eas.json 里。这完全是可选的,但我觉得它让开发体验更好,因为你不用再在 eas.json 和本地的 .env 文件之间来回复制同样的值。每个 environment 只需创建一次变量。
eas env:create --name APP_VARIANT --value development --environment development --visibility plaintext
build profile 随后只需指明要加载哪个 environment,不必逐个写出变量。
{
"build": {
"development": {
"developmentClient": true,
"environment": "development"
}
}
}
本机则通过 eas env:pull 拿到同样的值。你指定 environment,它就把这些变量写进本地的 .env.local 文件。
eas env:pull --environment development
现在 expo start 和 expo run 会从 .env.local 读取 APP_VARIANT,无需内联设置(也不用再手动改 .env 文件里的值)。构建则从 EAS 上同一个 environment 读取。改值的地方只有一处,而不是好几处。
在自己的机器上构建变体
只要变量已经写进 .env.local,本地用 expo run 构建就不需要额外配置。预构建出来的变体就是 APP_VARIANT 解析到的那个,所以先把它设好(或者拉取环境),之后切换变体时用 prebuild --clean 重新生成原生工程,让新的身份写进去。
APP_VARIANT=<variant> npx expo prebuild --clean
npx expo run:[platform]
如果你对 prebuild 或 CNG(Continuous Native Generation) 这类说法还不熟:CNG 会根据你的 app config、package.json 以及其他输入文件按需生成原生工程,而不是把它们提交进版本控制。EAS Build 也是同样的做法(除非你把原生工程提交上去),每次云端构建都从头重新生成。不管是在本地构建还是走 EAS,切换变体都不会留下过期的产物。
本地构建的变体几乎总是 development。preview 和 production 是 EAS Build 的活。真要本地构建,就用 release 配置,这样 JavaScript 和配置都会打进去,不依赖 dev server 就能跑,跟从应用商店装出来的一样。
# iOS
npx expo run:ios --configuration Release
# Android
npx expo run:android --variant release
下次开发之前记得用 development 的 prebuild --clean 切回来,否则 CLI 的二维码会一直指向磁盘上现有的那个变体。
环境是怎么到达运行中的应用的
一旦并行跑多个构建,身份和环境就可能对不上,所以这里我想把环境何时到达应用说清楚。
身份在构建时就固定了,前面已经看到。本地开发时要注意:如果你用 npx expo prebuild 或 npx expo run:android|ios 生成了本地原生工程,expo start 的启动 scheme 取自磁盘上的那个原生工程,所以 APP_VARIANT 并不影响打开哪个已安装的应用。
应用在运行时通过 expo-constants 读到的配置才是关键,因为它来自哪里取决于构建方式:
-
不带
expo-dev-client的普通构建,配置是编译进去的。Constants.expoConfig在构建时固定,在expo start上跑APP_VARIANT对它没有任何影响。只有重新构建才能改变应用报告的配置。 -
开发构建(即带有
expo-dev-client的构建)每次打开项目时都会从开发服务器下载配置。现在Constants.expoConfig反映的是服务器运行所处的环境,而不是你编译时所用的环境。
第二种情况才需要留意。以 preview 启动服务器,打开开发构建,它就会毫无怨言地按预览环境的值运行。开发菜单甚至会在一个身份为 dev 的构建上显示 “MyApp (Preview)”,或者你给该变体起的任何名字,而且不会报任何错。解决办法是让服务器的变体与你打开的构建保持一致,或者当变量已经存在于某个环境中后,在 expo start 之前先运行 eas env:pull --environment development。
还有一个通道的行为不同。EXPO_PUBLIC_ 变量会在打包时内联进你的 JS bundle,因此从开发服务器加载 JS 的构建会读取你本地的环境变量,而发布构建则把它们固化进去。没有该前缀的任何变量,包括 APP_VARIANT,都不会直接进入你的 JS,所以要在运行时读取它,就得通过配置把它暴露出来,通常是下面展示的 extra 字段(或者在这种情况下,比如你可以把它命名为 EXPO_PUBLIC_APP_VARIANT 并直接读取)。其余内容见环境变量指南。
访问额外的值
要了解开发构建如何从服务器获取配置,请把变体添加到配置的 extra 字段中。然后在屏幕上显示这些值:来自 expo-constants 的变体、process.env.APP_VARIANT 和 process.env.EXPO_PUBLIC_APP_VARIANT。
const config: ExpoConfig = {
name: getName(),
slug: "my-app",
extra: {
variant: process.env.APP_VARIANT ?? "unset",
},
// the rest of your app config....
};
import Constants from "expo-constants";
<Text>variant: {Constants.expoConfig?.extra?.variant}</Text>
<Text>APP_VARIANT: {String(process.env.APP_VARIANT)}</Text>
<Text>EXPO_PUBLIC_APP_VARIANT: {String(process.env.EXPO_PUBLIC_APP_VARIANT)}</Text>
APP_VARIANT 渲染为 undefined,因为它缺少 EXPO_PUBLIC_ 前缀,而 EXPO_PUBLIC_APP_VARIANT 会显示它的值。
用不同的 APP_VARIANT(以及匹配的 EXPO_PUBLIC_APP_VARIANT)重启服务器,然后再次打开项目,可以扫描设备上的二维码,也可以在模拟器中使用 i(单纯重新加载可能不会生效)。开发构建会显示新的值。预览或生产安装则仍会显示它编译时所用的变体。
让二维码打开你的开发构建
安装了多个变体后,Expo CLI 生成的二维码可能会打开错误的 app。默认情况下,expo-dev-client 配置插件会向你的原生项目添加一个生成的 scheme,名为 exp+<slug>,由你 app 的 slug 构建而成。slug 在各个变体之间是相同的,因此每个变体都注册同一个 scheme,并响应同一个链接。系统会打开哪一个,你无法依赖。例如,即使最后安装的是开发构建,它也可能每次都打开预览构建。
解决办法是让 dev build 成为唯一响应生成 scheme 的应用。
plugins: [
[
"expo-dev-client",
{
addGeneratedScheme: process.env.APP_VARIANT === "development",
},
],
],
干净地 prebuild 并重新构建之后,只有 development build 会注册 exp+<slug>,二维码也就能打开它。不过,改动之前构建的变体仍然会响应这个链接,所以要么把它们也重新构建,要么把它们从设备上卸掉。
如果你用 expo run 在本地构建其他变体,有一个坑。CLI 会从你最近一次 prebuild 的原生项目里取链接的 scheme,所以在本地构建另一个变体之后,下一次 expo start 可能就指向了错误的应用。下次开 dev session 之前,重新为 development 生成原生项目。
APP_VARIANT=development npx expo prebuild --clean
如果你的变量放在 EAS 上,先运行 eas env:pull --environment development,并跳过内联变量。如果你所有其他变体都在远程构建,本地原生项目始终配置为 development,那就不会遇到这个问题。
addGeneratedScheme 只改生成的 scheme,不会动应用配置里的自定义 scheme,而自定义 scheme 本来也是我很少改的东西。改它有用的场景是:一个外部链接需要在同一个项目构建出的多个应用之间做选择,比如白标应用——每个品牌通过 brandone:// 或 brandtwo:// 这样的 scheme 响应自己的链接。
为每个变体在服务端注册
每个变体成为独立应用之后,还剩一件杂事,而且它和原生身份本身绑定在一起。APP_VARIANT 可以让你的配置返回不同的 bundle identifier 或 package name,但没法创建这些标识符所需的外部注册。
有些服务和 SDK 靠这个标识符识别你的应用。某个构建可能需要你提前注册好的 ID,所以每个变体都得单独配置,要么作为独立应用,要么作为同一个项目下另一个被允许的标识符。variants guide 里举了 Google Maps 和 Firebase Cloud Messaging 的例子。一个明显的信号是:某个服务在配置过程中要求你提供 package name 或 bundle identifier。
每个变体单独注册更费事。但对于任何记录或发送生产数据的服务——比如 analytics、崩溃上报、推送——通常都值得这么做,免得 dev build 污染生产数据。
给每个变体配上自己的图标
同时装了两三个版本时,换个图标是避免点错最快的方法,同事也一直很吃这一套。用动态配置的话,无非是多写一个 helper,而变体图标可以简单到就是同一个图标换个背景色。我的实现里生产环境返回 undefined,所以 app.json 里的图标原样透传,dev 和 preview 则用一张纯色图覆盖掉。
function getIcon() {
switch (process.env.APP_VARIANT) {
case "production":
return undefined; // production keeps the icons from app.json
case "preview":
return "./assets/images/icon-preview.png";
default:
return "./assets/images/icon-dev.png";
}
}
const icon = getIcon();
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
icon: icon ?? config.icon,
ios: {
...config.ios,
icon: icon ?? config.ios?.icon,
},
android: {
...config.android,
icon: icon ?? config.android?.icon,
adaptiveIcon: {
...config.android?.adaptiveIcon,
foregroundImage: icon ?? config.android?.adaptiveIcon?.foregroundImage
},
},
// ... rest of your config
});
ios.icon 和 android.adaptiveIcon 的优先级高于顶层的 icon,所以示例里也把它们一并覆盖了。
让变体在更新中保持一致
前面说的都是构建时如何让变体保持一致。EAS Update 是应用发布之后另一个需要对齐的地方。更新通过 channel 抵达某个变体,而发布时所用的环境(由 --environment 指定)决定它携带哪些值。
channel 来自 build profile,就写在它已经指定的 environment 旁边。
{
"build": {
"preview": {
"environment": "preview",
"channel": "preview"
}
}
}
名字保持一致,整条链路就能对上。你的 preview 变体由 preview profile 构建,加载 preview 环境,并从 preview channel 接收更新,所以在那里发布的更新会准确落到你预期的位置。
eas update --channel preview --environment preview
不过,一旦把它指向错误的环境,你的 preview 变体就会毫无怨言地接受一个携带开发环境值的更新。原理和构建时一样:变体和环境必须匹配,但这里出错的代价是会波及已经装了应用的用户。channel 负责路由更新,environment 决定它的行为,而让两者保持一致是你自己的事。
小结
我知道这看起来挺多,但上手其实很简单。加一个只读一个变量的动态配置,每个环境一个 build profile,再让 channel 对上,你关心的每个构建就都能装在同一部手机上,各自清楚自己是谁。这套配置很少需要改动,以至于我现在把它原样复制到新项目里,只改几处就行。每次在构建之间切换,你都能感受到它的好处。
生产环境出了 bug,而你正写到一半,切过去复现一下,再切回来,不用卸载任何东西,也不会丢任何东西。如果你多年来一直靠覆盖同一个构建来检查另一个构建,这种做法到此为止。
App 变体是开发者体验的重要一环,而这样的开发者体验本就该属于你们所有人。