开发、测试、预发、生产——一个正经的 App 至少要面对两套后端地址,成熟的产品往往有四到五套。如果这些环境靠「提交前手动改常量、打包前记得切回来」来管理,出事只是时间问题:某次发版把测试环境的 API 地址带上了生产,或者测试包和正式包因为签名相同而无法共存安装。多环境构建(Build Flavors)就是把这些差异从「人的记忆」搬到「构建系统」里。
Flutter 的 flavor 机制横跨三层:Dart 层的编译期常量、Android 层的 Gradle productFlavors、iOS 层的 Xcode Scheme + xcconfig。三层必须对齐,否则会出现「Android 打的是 staging 包,iOS 打的是 prod 包」这种隐蔽事故。本文逐层拆解配置方式,讲清 --dart-define 与 --flavor 的配合、多环境图标与签名隔离,以及如何在 CI 里跑 flavor 矩阵。这类「构建配置即代码」的思路,与 特性开关(Feature Flags)
的运行时差异化可以互补使用。
一、环境差异的三种注入方式
在动手配置之前,先想清楚「环境差异」到底要注入什么。它通常分三类,对应的技术手段完全不同:
| 差异类型 | 举例 | 注入方式 | 生效时机 |
|---|---|---|---|
| 编译期常量 | API base URL、日志级别、埋点开关 | --dart-define / String.fromEnvironment | 编译时确定,可被 tree-shaking |
| 原生层配置 | applicationId 后缀、应用名、图标、签名 | Gradle flavor / iOS xcconfig | 打包时确定 |
| 运行时开关 | 灰度功能、AB 实验 | 远程配置 + feature flag | 运行时可改 |
关键认知:编译期常量优先。能用 --dart-define 表达的,就不要塞进运行时读取的 JSON 文件——前者在编译期就被内联,未使用的分支会被 Dart 编译器摇树优化掉,既省包体又不会泄漏。下面从 Dart 层讲起。
二、Dart 层:dart-define 与编译期常量
Flutter 通过 --dart-define 在编译期注入键值对,Dart 侧用 String.fromEnvironment 读取:
// lib/config/app_config.dart
class AppConfig {
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.dev.example.com',
);
static const String environment = String.fromEnvironment(
'ENVIRONMENT',
defaultValue: 'dev',
);
static const bool enableLogging = bool.fromEnvironment(
'ENABLE_LOGGING',
defaultValue: true,
);
static bool get isProd => environment == 'prod';
}
构建时注入:
flutter run --dart-define=API_BASE_URL=https://api.staging.example.com \
--dart-define=ENVIRONMENT=staging \
--dart-define=ENABLE_LOGGING=true
flutter build apk --dart-define=ENVIRONMENT=prod \
--dart-define=API_BASE_URL=https://api.example.com \
--dart-define=ENABLE_LOGGING=false
参数一多就难维护。Flutter 3.7+ 支持从文件读取,把每个环境的变量集中管理:
// config/staging.json
{
"API_BASE_URL": "https://api.staging.example.com",
"ENVIRONMENT": "staging",
"ENABLE_LOGGING": "true"
}
flutter build ipa --dart-define-from-file=config/staging.json
注意 --dart-define-from-file 的值会被统一转成字符串,数字和布尔要从字符串再解析。所有环境文件里必须保持键名完全一致,缺一个键就会回退到 defaultValue——这是最容易埋雷的地方,建议在 CI 里加一条校验脚本比对各环境文件的键集合。
# scripts/check_env_keys.py — CI 里校验各环境文件键集合一致
import json, glob, sys
keysets = {f: set(json.load(open(f, encoding='utf-8')).keys())
for f in sorted(glob.glob('config/*.json'))}
base = next(iter(keysets.values()))
for f, ks in keysets.items():
if ks != base:
print(f'{f} 键集合不一致: 缺 {base - ks} / 多 {ks - base}')
sys.exit(1)
--dart-define 只影响 Dart 层,无法改变原生层行为(比如 applicationId),所以它必须与 flavor 配合使用,而不是替代 flavor。
三、Android 层:productFlavors 配置
Android 侧的环境差异由 Gradle 的 productFlavors 定义。打开 android/app/build.gradle(或 .kts):
// android/app/build.gradle
android {
namespace "com.example.myapp"
compileSdk 34
defaultConfig {
applicationId "com.example.myapp"
minSdk 21
targetSdk 34
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
flavorDimensions "environment"
productFlavors {
dev {
dimension "environment"
applicationIdSuffix ".dev" // 与正式包共存
versionNameSuffix "-dev"
resValue "string", "app_name", "MyApp Dev"
}
staging {
dimension "environment"
applicationIdSuffix ".staging"
versionNameSuffix "-staging"
resValue "string", "app_name", "MyApp Staging"
}
prod {
dimension "environment"
// 正式包不加后缀,保持干净
resValue "string", "app_name", "MyApp"
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
几个必须理解的点:
flavorDimensions是 flavor 的分类维度。你可能有「环境」(dev/staging/prod)和「渠道」(googlePlay/appStore)两个维度,组合后就是devGooglePlay、prodAppStore等。维度顺序影响构建变体命名,务必固定。applicationIdSuffix让不同环境的应用能同时安装在一台设备上——测试同事装.dev包和.staging包互不覆盖。代价是每个环境的推送证书、OAuth 回调、深链域名都要分别注册。resValue把应用名写成资源,Dart 侧无法直接读,但原生启动器图标下的名字会跟着变。
Flutter 构建时用 --flavor 指定:
flutter build apk --flavor dev --dart-define-from-file=config/dev.json
flutter build appbundle --flavor prod --dart-define-from-file=config/prod.json
flutter run --flavor staging --dart-define-from-file=config/staging.json
--flavor 的名字必须与 Gradle 里的 flavor 名完全一致(大小写敏感)。写错时 Flutter 会报 Could not find flavor。
当「环境」与「渠道」两个维度组合时,变体数量是乘积。3 个环境 × 2 个渠道 = 6 个变体,每个变体都要单独配置签名与资源。因此维度不是越多越好——只对真正需要差异化的维度建 flavor,其余用 --dart-define 或远程配置表达。
四、iOS 层:Scheme、xcconfig 与多目标
iOS 没有 Gradle 那样的 flavor 概念,它的等价物是「多个 Xcode Scheme + 多个 xcconfig 文件 + 多个 Build Configuration」。手工配置步骤繁琐,用 Xcode 打开 ios/Runner.xcworkspace:
- 在 Project → Info → Configurations 里复制
Debug/Release为Debug-dev/Release-dev、Debug-prod/Release-prod。 - 在
ios/Flutter/下创建Debug-dev.xcconfig、Release-prod.xcconfig等文件。 - 在 Build Settings 里为每个 Configuration 指定对应的 xcconfig。
- 在 Product → Scheme → Manage Schemes 里复制 Scheme,为每个 Scheme 绑定 Build Configuration。
xcconfig 文件里可以定义构建变量,再通过 Info.plist 引用:
// ios/Flutter/Debug-dev.xcconfig
#include "Debug.xcconfig"
BUNDLE_ID_SUFFIX = .dev
APP_DISPLAY_NAME = MyApp Dev
API_BASE_URL = https:/$()/api.dev.example.com
// ios/Flutter/Release-prod.xcconfig
#include "Release.xcconfig"
BUNDLE_ID_SUFFIX =
APP_DISPLAY_NAME = MyApp
API_BASE_URL = https:/$()/api.example.com
注意 https:/$()/ 这个写法——xcconfig 里 // 会被当成注释,必须用 $() 隔开。这是 iOS 多环境配置最经典的坑。
Bundle Identifier 的后缀通过 PRODUCT_BUNDLE_IDENTIFIER 引用 $(BUNDLE_ID_SUFFIX),应用名通过 CFBundleDisplayName 引用 $(APP_DISPLAY_NAME)。构建时:
flutter build ipa --flavor prod --dart-define-from-file=config/prod.json
这里 --flavor 对应的是 Scheme 名(小写)。Scheme 名与 Android flavor 名建议保持同名(dev/staging/prod),减少心智负担。
iOS 侧的常见坑清单:
| 现象 | 原因 | 解决 |
|---|---|---|
flutter build 找不到 Scheme | Scheme 未共享(Shared) | Manage Schemes 勾选 Shared |
| URL 被截断 | xcconfig 里 // 当注释 | 用 $() 转义 |
| 应用名不生效 | Info.plist 未引用变量 | 改用 $(APP_DISPLAY_NAME) |
| 打包后仍连测试环境 | xcconfig 未绑定到对应 Configuration | 检查 Build Settings 的 xcconfig 引用 |
五、多环境图标与启动图
不同环境用不同图标,能让测试同事一眼区分装的是哪个包。Flutter 生态用 flutter_launcher_icons 按 flavor 生成:
# pubspec.yaml
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/icon/prod.png"
# 每个 flavor 单独配置
flavors:
dev:
image_path: "assets/icon/dev.png"
android: true
ios: true
staging:
image_path: "assets/icon/staging.png"
dart run flutter_launcher_icons
Android 侧的 flavor 图标会自动生成到 android/app/src/dev/res/mipmap-*/ 下;iOS 侧则需要手动在 Asset Catalog 里为每个 Build Configuration 指定不同的 AppIcon Set——这一步工具无法完全代劳,因为 iOS 的 AppIcon 是按 target 而非 configuration 区分的,多环境通常要建多个 Target 或依赖 Build Settings 切换。
一个更省事的替代方案:只改应用名后缀,不改图标。用 Gradle 的 resValue "string", "app_name" 和 iOS 的 CFBundleDisplayName 让名字带 [Dev] 标记,图标保持一致。对内部测试包足够了,还省去维护多套图标的成本。
启动图(Splash)同理,用 flutter_native_splash 按 flavor 生成:
flutter_native_splash:
color: "#FFFFFF"
image: assets/splash/splash.png
android_12:
image: assets/splash/splash_android12.png
color: "#FFFFFF"
六、签名与密钥隔离
安全上最重要的一条:测试包和生产包必须用不同的签名密钥。如果共用同一个 keystore,测试包可以被覆盖安装到生产包上(只要 applicationId 相同),而如果 applicationId 不同,共用密钥又会带来密钥泄漏面扩大的风险。
Android 侧按 flavor 配置签名:
android {
signingConfigs {
dev {
storeFile file("../keystore/dev.jks")
storePassword System.getenv("DEV_STORE_PASSWORD")
keyAlias "dev"
keyPassword System.getenv("DEV_KEY_PASSWORD")
}
prod {
storeFile file("../keystore/prod.jks")
storePassword System.getenv("PROD_STORE_PASSWORD")
keyAlias "prod"
keyPassword System.getenv("PROD_KEY_PASSWORD")
}
}
buildTypes {
release {
// 按 flavor 选择签名,而不是写死
productFlavors.dev.signingConfig signingConfigs.dev
productFlavors.prod.signingConfig signingConfigs.prod
}
}
}
密码一律从环境变量读取,绝不写进 build.gradle 或提交进仓库。CI 里通过 secrets 注入。keystore 文件本身也应放在仓库外,或加密后由 CI 临时解密。
# 用 openssl 加密 keystore 后随仓库保存(解密密钥放 CI secrets)
openssl enc -aes-256-cbc -salt -in prod.jks -out prod.jks.enc -k "$KEYSTORE_KEY"
# CI 中解密
openssl enc -d -aes-256-cbc -in prod.jks.enc -out prod.jks -k "$KEYSTORE_KEY"
iOS 侧同理:为每个环境创建独立的 Provisioning Profile 与 Distribution Certificate,生产证书只授予发布流水线。这些凭据与应用安全加固的原则一致——测试凭据可以宽松,生产凭据必须严格隔离,密钥轮换要有流程。
七、CI 中的 flavor 矩阵构建
CI 上构建多环境,用矩阵(matrix)一次跑完所有组合最高效:
# .github/workflows/build.yml(节选)
jobs:
build:
strategy:
matrix:
flavor: [dev, staging, prod]
platform: [android, ios]
runs-on: ${{ matrix.platform == 'ios' && 'macos-latest' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v4
- name: Build
run: |
flutter build ${{ matrix.platform == 'ios' && 'ipa' || 'appbundle' }} \
--flavor ${{ matrix.flavor }} \
--dart-define-from-file=config/${{ matrix.flavor }}.json
矩阵构建的几个实践要点:
- flavor 与 config 文件同名,
config/${flavor}.json自动对应,减少映射错误。 - dev/staging 可以只跑 Android,节省昂贵的 macOS runner 时间;prod 才跑 iOS。
- 产物命名带 flavor 前缀,如
app-prod-release.aab,避免上传时混淆。 - 构建前加一步校验各环境 config 文件的键集合一致,防止漏键回退到默认值。
- fail-fast 关闭:
fail-fast: false让一个 flavor 失败不影响其他 flavor 继续构建,一次拿到完整结果。
产物分发可以配合 Android 发布流程 的轨道(track)机制,把 staging 包推到 internal testing 轨道、prod 包推到 production 轨道;iOS 侧则对应 App Store 发布 的 TestFlight 与正式审核。整套流程接入 https://plumephp.com/flutter-ci-cd/ 后,一次提交可以自动产出所有环境的可分发包。
一个完整的构建脚本,把「校验 → 构建 → 上传符号 → 分发」串成一条命令:
#!/usr/bin/env bash
set -euo pipefail
FLAVOR="${1:?用法: ./build.sh <dev|staging|prod>}"
CONFIG="config/${FLAVOR}.json"
# 1. 校验环境文件存在且键集合一致
python3 scripts/check_env_keys.py
# 2. 构建(带符号剥离,便于崩溃还原)
flutter build appbundle \
--flavor "${FLAVOR}" \
--dart-define-from-file="${CONFIG}" \
--obfuscate \
--split-debug-info=build/symbols/${FLAVOR}
# 3. 归档符号(崩溃还原必需)
mkdir -p build/symbols-archive && \
cp -r build/symbols/${FLAVOR} build/symbols-archive/
# 4. 重命名产物,带 flavor 前缀
mv build/app/outputs/bundle/${FLAVOR}Release/app-${FLAVOR}-release.aab \
build/app/outputs/bundle/app-${FLAVOR}-release.aab
echo "构建完成: app-${FLAVOR}-release.aab"
这套脚本把 flavor 差异收敛到「一个参数 + 一个 config 文件」,任何人执行 ./build.sh prod 都能得到一致的产物,杜绝了「凭记忆改常量」的事故。
常见故障排查表:
| 报错 | 原因 | 解决 |
|---|---|---|
Could not find flavor | flavor 名拼写/大小写不符 | 与 Gradle/Scheme 名逐字核对 |
No matching variant | Gradle 变体名组合错误 | 检查 flavorDimensions 顺序 |
| iOS 打包后连测试环境 | xcconfig 未绑定 | 检查 Build Settings |
| 配置回退到默认值 | config 文件缺键 | 跑 check_env_keys 脚本 |
小结
多环境构建的复杂度来自「三层对齐」:Dart 层管编译期常量(--dart-define / --dart-define-from-file),Android 层管 flavor 与签名(productFlavors + applicationIdSuffix),iOS 层管 Scheme 与 xcconfig。三条实用准则:能用编译期常量就不要用运行时读取(可摇树、不泄漏);测试与生产必须用不同签名密钥(凭据走环境变量、keystore 加密保存);flavor 名在 Dart、Gradle、Scheme 三处保持同名。先把 dev/staging/prod 三套跑通,再考虑加「渠道」维度,否则 flavor 组合爆炸会让 CI 时间失控。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。