适用场景:从单一包名走向可发布的多环境 Android Flutter 项目
很多 Flutter 项目早期只有一个 main.dart、一个接口地址和一个 Android 包名。开发联调时临时改 API,测试要包时再改回来,发布前再手工检查一次。这种方式在原型阶段可以凑合,但只要项目进入持续迭代,就会出现三个稳定问题:测试包覆盖正式包、日志级别打开后忘记关闭、CI 构建命令和本机构建命令不一致。本文面向已经能运行 Flutter Android 项目、已经写过网络请求和本地存储的团队,继续补上“环境分层”这一层工程能力。
本文选择的方案是:Android 原生 productFlavors 负责包名、应用名、图标和签名边界;Flutter 的 --dart-define-from-file 负责 API 地址、日志开关、埋点开关和功能开关;CI 用固定命令构建 dev、staging、prod 三套包。这个主题和站内已有的 Android/Kotlin 离线优先同步:Room、WorkManager 与冲突处理清单 可以衔接:离线同步解决数据一致性,多环境配置解决“同一套代码如何稳定进入不同验证阶段”。
官方 Flutter 文档把 Android flavor 对应到 Android 的 product flavor,并给出 flutter run --flavor、flutter build apk --flavor 等命令;Dart 编译环境变量则通过 String.fromEnvironment 在编译期读取。实际项目不要把两者混成一个概念:flavor 更适合原生层差异,dart-define 更适合 Dart 层配置。分清边界,后续排查日志和构建问题会轻很多。
技术取舍:哪些放 flavor,哪些放 dart-define
先定规则,再写配置。放进 Android flavor 的内容通常有四类:applicationIdSuffix、resValue 应用名、不同环境图标、签名配置。它们属于 Android 包、安装、商店和系统展示层面的差异,改错会直接影响安装和发布。放进 --dart-define-from-file 的内容也有四类:API_BASE_URL、LOG_LEVEL、ENABLE_MOCK、SENTRY_DSN 或其他监控开关。它们属于 Dart 运行逻辑,适合用 JSON 文件随构建命令注入。
不要把敏感密钥当成安全秘密塞进 dart-define。编译进客户端的内容都可能被逆向看到,适合放“环境选择”和“公开配置”,不适合放后台管理 token、支付私钥、云服务主密钥。真正的秘密应该由服务端保管,客户端只拿短期会话、公开 key 或受限 token。
目录建议保持克制:
app/
lib/
main.dart
bootstrap.dart
core/config/app_config.dart
core/logging/app_logger.dart
env/
dev.json
staging.json
prod.json
android/app/build.gradle
.github/workflows/android.yml
这套结构的目标不是“看起来专业”,而是让新成员看到文件名就知道:原生包名在 android/app/build.gradle,Dart 配置在 env/*.json,启动组装在 bootstrap.dart,日志收口在 app_logger.dart。
步骤一:在 Android Gradle 中配置 flavor
下面以 Groovy 版 build.gradle 为例。如果项目已经迁移到 Kotlin DSL,字段名字基本一致,只是写法不同。重点是 flavorDimensions、productFlavors、applicationIdSuffix 和 resValue。
android {
namespace "com.stepnex.shop"
compileSdk flutter.compileSdkVersion
defaultConfig {
applicationId "com.stepnex.shop"
minSdk flutter.minSdkVersion
targetSdk flutter.targetSdkVersion
versionCode flutter.versionCode
versionName flutter.versionName
}
flavorDimensions "env"
productFlavors {
dev {
dimension "env"
applicationIdSuffix ".dev"
resValue "string", "app_name", "Stepnex Dev"
}
staging {
dimension "env"
applicationIdSuffix ".staging"
resValue "string", "app_name", "Stepnex Staging"
}
prod {
dimension "env"
resValue "string", "app_name", "Stepnex"
}
}
}
同时把 android/app/src/main/AndroidManifest.xml 的应用名改成资源引用:
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher">
</application>
执行命令验证三种 flavor 是否能独立安装:
flutter clean
flutter pub get
flutter run --flavor dev --dart-define-from-file=env/dev.json
flutter build apk --flavor staging --dart-define-from-file=env/staging.json
flutter build appbundle --flavor prod --dart-define-from-file=env/prod.json
这里的验证方式很具体:Android 手机上 dev 包和 staging 包应该能与正式包并存;应用列表里名称应能区分;adb shell pm list packages | grep stepnex 能看到不同包名;如果 prod 包名没有后缀,说明正式包边界正确。
步骤二:用 JSON 文件管理 Dart 层配置
在 env/dev.json、env/staging.json、env/prod.json 中只放构建期公开配置。示例:
{
"APP_ENV": "staging",
"API_BASE_URL": "https://staging-api.example.com",
"LOG_LEVEL": "debug",
"ENABLE_MOCK": "false",
"ENABLE_VERBOSE_HTTP_LOG": "true"
}
然后用 String.fromEnvironment 建立单一入口。注意必须是 const,否则它就不是编译期环境读取。
enum AppEnv { dev, staging, prod }
class AppConfig {
const AppConfig({
required this.env,
required this.apiBaseUrl,
required this.logLevel,
required this.enableMock,
required this.enableVerboseHttpLog,
});
final AppEnv env;
final Uri apiBaseUrl;
final String logLevel;
final bool enableMock;
final bool enableVerboseHttpLog;
static const _envName = String.fromEnvironment('APP_ENV', defaultValue: 'dev');
static const _apiBaseUrl = String.fromEnvironment('API_BASE_URL');
static const _logLevel = String.fromEnvironment('LOG_LEVEL', defaultValue: 'info');
static const _enableMock = bool.fromEnvironment('ENABLE_MOCK');
static const _verboseHttp = bool.fromEnvironment('ENABLE_VERBOSE_HTTP_LOG');
factory AppConfig.fromBuildEnv() {
if (_apiBaseUrl.isEmpty) {
throw StateError('Missing API_BASE_URL. Check --dart-define-from-file.');
}
final env = switch (_envName) {
'prod' => AppEnv.prod,
'staging' => AppEnv.staging,
_ => AppEnv.dev,
};
return AppConfig(
env: env,
apiBaseUrl: Uri.parse(_apiBaseUrl),
logLevel: _logLevel,
enableMock: _enableMock,
enableVerboseHttpLog: _verboseHttp,
);
}
}
bootstrap.dart 负责把配置传给网络层、日志层和状态管理容器:
Future<void> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
final config = AppConfig.fromBuildEnv();
final logger = AppLogger(level: config.logLevel);
logger.info('app_env=${config.env.name} api_host=${config.apiBaseUrl.host}');
runApp(AppRoot(config: config, logger: logger));
}
日志只打印环境名和 host,不打印完整 token、用户信息或请求体。生产包建议只保留必要错误日志,调试包可以打开更详细的网络日志。
步骤三:把本机命令收口成脚本
团队里最常见的坑不是 Gradle 配错,而是每个人手里的命令不同。建议增加 tool/build_android.ps1 或 Makefile,把命令固化。Windows 开发者可以这样写 PowerShell:
param(
[ValidateSet('dev','staging','prod')]
[string]$Env = 'dev',
[ValidateSet('apk','appbundle')]
[string]$Target = 'apk'
)
$defineFile = "env/$Env.json"
if (-not (Test-Path $defineFile)) {
throw "Missing define file: $defineFile"
}
flutter pub get
flutter test
flutter build $Target --flavor $Env --dart-define-from-file=$defineFile
这样测试同学要 staging 包时,开发只需要执行:
./tool/build_android.ps1 -Env staging -Target apk
发布正式包时执行:
./tool/build_android.ps1 -Env prod -Target appbundle
验证方式包括三项:命令能在干净工作区运行;构建产物路径可预测;脚本失败时能明确指出缺失的是 JSON 文件、测试失败还是 Flutter 构建失败。
步骤四:CI 中固定产物和日志
CI 不要重新发明一套命令,应该调用同一套脚本。以 GitHub Actions 为例:
name: android-build
on:
workflow_dispatch:
inputs:
env:
type: choice
options: [dev, staging, prod]
default: staging
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter --version
- run: flutter pub get
- run: flutter analyze
- run: flutter test
- run: flutter build apk --flavor ${{ inputs.env }} --dart-define-from-file=env/${{ inputs.env }}.json
- uses: actions/upload-artifact@v4
with:
name: android-${{ inputs.env }}-apk
path: build/app/outputs/flutter-apk/*.apk
如果正式包需要签名,签名文件和密码必须走 CI secret,不要提交到仓库。CI 日志里需要保留 flutter --version、构建 flavor、产物名称和测试结果,方便回溯“某个包到底用哪个环境构建”。
避坑点:多环境配置最容易错在边界
第一,不要让 dev、staging、prod 共用同一个包名,否则测试包可能覆盖正式包。第二,不要把正式环境的 LOG_LEVEL 留成 debug,尤其是网络拦截器会打印请求头时。第三,不要让 mock 开关进入正式包,ENABLE_MOCK 应该在启动时和构建日志里都可见。第四,不要把 flavor 当成权限绕过工具,登录态、权限和服务端数据隔离仍然要靠后端环境与账号体系。
第五,不要在正文或日志里输出完整配置。排查时打印 env、host、build number 足够,多余字段会变成泄漏风险。第六,不要只测 flutter run,还要测 flutter build appbundle,因为发布包的混淆、资源收缩、签名和 target SDK 检查都只会在发布链路里暴露。第七,Android Studio 的 Run Configuration 也要同步配置 --flavor 和 --dart-define-from-file,否则 IDE 启动和命令行启动会表现不一致。
验证清单:发布前看这些信号
flutter analyze、flutter test通过。flutter run --flavor dev --dart-define-from-file=env/dev.json能启动,并显示 dev 应用名。- staging 包与 prod 包能同时安装,包名不同。
- prod 的应用名、图标、包名、签名和版本号符合发布预期。
- 启动日志只出现环境、host 和构建号,不出现 token、用户隐私或完整请求头。
- API 请求实际打到对应环境,可以用代理、服务端 access log 或测试账号验证。
- CI 产物名称包含环境,构建日志包含 Flutter 版本和 flavor。
- 缺失
API_BASE_URL时应用启动失败并给出明确错误,而不是默默打到默认地址。
复盘清单:每次环境事故后怎么收口
如果出现“测试包连到正式环境”或“正式包打开了调试日志”,不要只修当前 JSON。复盘时按四层记录:触发命令是什么,使用的 flavor 是什么,加载的 env/*.json 是什么,启动日志里打印的环境是什么。四层里任何一层缺证据,下一次还会靠猜。
复盘后至少做三件事:把命令收口到脚本,把启动日志固定为一行可搜索格式,把 CI 产物命名改成包含环境和提交号。对于正式包,再加一条发布前检查:从安装包启动一次,截图应用名和关于页版本号,服务端日志确认请求来自正式域名。多环境配置不是为了复杂,而是为了让每一次测试、验收和发布都能被验证、被回滚、被复盘。
落地顺序:先让团队少犯错,再追求自动化
如果项目已经在迭代中,不建议一次性把 flavor、签名、CI、日志和监控全部改完。更稳妥的步骤是分三次落地。第一天只做 dev 和 prod 包名拆分,确认测试包不会覆盖正式包;第二天接入 env/*.json,让 API 地址和日志级别从代码里移出去;第三天才把 CI 构建和产物上传补齐。每一步结束都要保留命令输出、安装截图和一段服务端 access log,这些证据比口头确认更可靠。
多人协作时还要给代码评审增加两条检查:凡是修改 env/prod.json,必须说明为什么影响正式包;凡是修改 android/app/build.gradle 的 flavor 配置,必须附带本机或 CI 的构建命令。这样评审者不用猜改动意图,也能及时发现“staging 指向正式 API”“prod 打开详细日志”这类问题。等流程稳定后,再考虑把应用图标、渠道号、灰度开关和崩溃上报环境继续纳入同一套配置体系。
发布前人工检查也要固定成表格,而不是靠记忆。建议记录构建人、提交号、构建命令、产物文件名、安装设备、登录账号类型、首页接口 host、关键业务接口 host、日志级别和回滚包位置。正式包只要有一项说不清,就先不要进入商店上传或灰度分发。这个表格不需要复杂系统,最初放在发布单、Issue 或团队文档里就可以,关键是让每个环境差异都有可追溯证据。
责任也要拆清:开发负责构建命令和配置文件,测试负责安装与接口验证,运维或后端负责服务端日志确认,产品负责确认展示名称和版本信息。边界清楚,问题出现时才能快速定位,减少返工。