多环境与 flavors 配置
开发时连测试服、发版时连生产服,如果靠“改一行地址再提交”,迟早有人把测试配置发到线上。多环境配置要解决三件事:编译期确定的环境变量、应用名与图标等外观差异、以及错误配置时尽早失败。本章对比 --dart-define 与 flavors 两种方案,并给出可直接落地的组合用法。
两种实现方式
| 方案 | 配置位置 | 优点 | 局限 |
|---|---|---|---|
--dart-define | 命令行或 IDE 配置 | 零原生改动、跨平台一致、CI 友好 | 无法区分应用名/图标,不支持原生 SDK 差异化 |
| flavors | Android productFlavors + iOS Scheme | 可装多个 App,图标、包名、原生配置都能分开 | 配置繁琐,两端要分别维护 |
| 两者组合 | 同时使用 | 覆盖全部场景 | 命令变长,需要脚本封装 |
建议:只区分接口地址与开关时用 --dart-define;需要“正式包与测试包同时装在手机上”时再上 flavors。
--dart-define 与 String.fromEnvironment
// lib/core/env.dart
class Env {
// fromEnvironment 必须是 const 构造,默认值写在第二个参数里
static const String name = String.fromEnvironment('ENV', defaultValue: 'dev');
static const String apiBase = String.fromEnvironment('API_BASE', defaultValue: 'https://dev.api.example.com');
static const bool enableLog = bool.fromEnvironment('ENABLE_LOG', defaultValue: true);
static bool get isProd => name == 'prod';
}
flutter run --dart-define=ENV=dev --dart-define=API_BASE=https://dev.api.example.com
flutter build apk --release --dart-define=ENV=prod --dart-define=API_BASE=https://api.example.com
关键点:String.fromEnvironment 只在 const 上下文里读取编译期定义,写成 final 或运行期读取会拿到默认值——这是最常见的“配置不生效”原因。多个变量建议写进 --dart-define-from-file=config/prod.json(Flutter 3.7+ 支持),避免命令行过长。
Android product flavors
// android/app/build.gradle
android {
flavorDimensions "env"
productFlavors {
dev {
dimension "env"
applicationIdSuffix ".dev" // 可与正式版共存
resValue "string", "app_name", "商城 Dev"
}
prod {
dimension "env"
resValue "string", "app_name", "商城"
}
}
}
flutter run --flavor dev -t lib/main_dev.dart
flutter build apk --release --flavor prod -t lib/main_prod.dart
flutter build appbundle --release --flavor prod -t lib/main_prod.dart
--flavor 必须与 Gradle 中定义的 flavor 名完全一致,且 -t 指定的入口文件必须存在;漏掉 -t 时会用 lib/main.dart,容易误连环境。
iOS Scheme 配置
- 在 Xcode 里
Product > Scheme > Manage Schemes复制Runner为dev与prod两个 scheme。 - 每个 scheme 的
Build Configuration分别指向Debug-dev/Release-prod这类自定义配置,并在Build Settings里设置不同的PRODUCT_BUNDLE_IDENTIFIER与PRODUCT_NAME。 - 用
flutter run --flavor dev时 Flutter 会去找同名 scheme;scheme 名与--flavor不一致会直接报错。 - iOS 不能在两端共用一个 Bundle ID 安装两份应用,测试包必须改标识符。
外观与标识差异化
| 项目 | 实现方式 | 说明 |
|---|---|---|
| 应用名 | Android resValue;iOS CFBundleDisplayName | 测试包加后缀便于辨认 |
| 图标 | flutter_launcher_icons 分 flavor 配置 | 测试包用灰色或带角标图标 |
| 包名/Bundle ID | applicationIdSuffix、PRODUCT_BUNDLE_IDENTIFIER | 决定能否与正式版共存 |
| 启动图 | flutter_native_splash 按 flavor 生成 | 测试包加“DEV”水印最直观 |
| 环境标识条 | 代码里按 Env.name 判断显示 | 最省事也最有效,建议必备 |
入口文件组织
// lib/main_dev.dart
import 'package:flutter/material.dart';
import 'core/app.dart';
import 'core/env.dart';
void main() {
assert(Env.name == 'dev'); // 防止误用正式入口
runApp(const App(showDebugBanner: true)); // 页面顶部挂一条“测试环境”提示
}
// lib/main_prod.dart
import 'package:flutter/material.dart';
import 'core/app.dart';
void main() => runApp(const App(showDebugBanner: false));
两个入口共享 App 与全部业务代码,差异只留在配置与开关上;不要复制出两套页面代码,否则很快就不一致。
配置校验与 fail-fast
// 启动时校验,缺配置直接抛出,避免带着错地址上线
void validateEnv() {
if (Env.apiBase.isEmpty || !Env.apiBase.startsWith('http')) {
throw StateError('API_BASE 未配置或格式错误: "${Env.apiBase}"');
}
if (Env.isProd && Env.enableLog) {
throw StateError('生产环境不应开启详细日志');
}
}
把这段校验放在 main 的第一行。CI 里构建 prod 包时如果忘记传 --dart-define,构建会立刻失败,比线上发现接口 404 便宜得多。
与 CI 的配合
- 每个环境一套构建命令,写进 Makefile 或脚本,禁止手工拼参数。
- 密钥、签名口令用环境变量或 CI Secrets 注入,不要写进仓库。
- 构建日志里打印
ENV与API_BASE(不要打印密钥),方便排查“发错环境”。 - 用 flavors 时 CI 需要同时构建 dev 与 prod,注意 Android 与 iOS 的 flavor 名必须一致。
常见坑
| 坑 | 现象 | 正确做法 |
|---|---|---|
用 final 读环境变量 | 永远拿到默认值 | 必须用 const + String.fromEnvironment |
--flavor 名与 Gradle 不一致 | 构建报找不到 flavor | 两端命名统一,脚本里集中定义 |
忘传 -t 入口文件 | 测试包连了生产接口 | 命令里显式指定 -t lib/main_xxx.dart |
| 只改地址不改包名 | 测试包覆盖正式包 | 用 applicationIdSuffix 与 Bundle ID 区分 |
| 配置硬编码进代码 | 换环境要改代码再发版 | 全部走编译期注入 |
| 生产包开了日志 | 隐私与性能风险 | 用 Env.isProd 关掉,并在校验里兜底 |
小结:只差接口地址就用 --dart-define 配 String.fromEnvironment(务必 const),需要多个安装包共存再加 Android productFlavors 与 iOS Scheme;应用名、图标、包名通过 flavor 差异化,入口按 lib/main_dev.dart、lib/main_prod.dart 拆分共享同一套业务代码;启动时做配置校验 fail-fast,并把命令固化进 CI,避免人工拼参数发错环境。