Flutter Android 构建变体配置:校验渠道包的签名与 applicationId

Flutter 项目从一个测试包走向测试、预发布和正式渠道时,最容易出问题的不是 flutter build 命令本身,而是每个产物究竟带了什么 applicationId、拿了哪一个签名、读取了哪份后端配置。本文给已有 Android 工程、需要让多个包可并存安装且可追溯的团队一套小范围做法:只建立一个 env 维度,把差异压在 Gradle、资源和 Dart 配置三处,并在交付前留下可复核的证据。

Flutter 的 flavors 文档说明可以通过 --flavor 运行特定环境;Android 的构建变体由 product flavor 与 build type 组合而成,且 build type 后应用的 applicationIdSuffix 会继续追加到包名。先把这两个事实写进团队约定,比在 CI 失败后猜“为什么安装成了另一个应用”可靠得多。Flutter flavors 文档 与 Android Build Variants 文档 是本次配置的边界依据。

适用范围与先把命名写下来

这套方案适合只有一套 Flutter UI、却要区分 staging 和 production 服务端、图标或应用标识的 Android App。它不替你处理多店铺、多白标或不同业务功能;那些情况应先确认是否真需要第二个 flavor dimension。先在仓库的 docs/release-variants.md 放一张四行表:stagingDebug、stagingRelease、productionDebug、productionRelease,逐格写出 applicationId、签名别名、API 基地址来源、是否允许交付。表是后续测试、日志检索和发布审批的共同输入。

不要把 debug 当成一个渠道。它是 build type;staging 才是环境。这样团队看到 com.stepnex.app.staging.debug 时,能从名称读出两层信息,也能把 debug 包和 release 包同时装到同一台测试机。若正式线上包必须固定为 com.stepnex.app,不要在 production 再加 suffix,避免 Play 商店或深度链接的登记对象被意外改变。

在 Gradle 只声明可解释的差异

在 android/app/build.gradle.kts 中把维度、应用 ID 和 manifest 占位符集中声明。下面代码刻意不把密钥写进仓库;签名读取仍来自本机或 CI 的安全变量。resValue 的用途是让 QA 能在设置页或日志中确认自己安装的环境,而不是拿它存储秘密。

android {
    flavorDimensions += "env"
    productFlavors {
        create("staging") {
            dimension = "env"
            applicationIdSuffix = ".staging"
            versionNameSuffix = "-staging"
            manifestPlaceholders["appLabel"] = "Stepnex Staging"
            buildConfigField("String", "API_BASE_URL", "\"https://staging-api.example.com\"")
        }
        create("production") {
            dimension = "env"
            manifestPlaceholders["appLabel"] = "Stepnex"
            buildConfigField("String", "API_BASE_URL", "\"https://api.example.com\"")
        }
    }
    buildTypes {
        getByName("debug") { applicationIdSuffix = ".debug" }
    }
}

这里的关键不是复制字段,而是每一项都能被检查:stagingRelease 应是 com.stepnex.app.staging,stagingDebug 应是 com.stepnex.app.staging.debug。如果 AndroidManifest.xml 里需要引用包名,请使用 ${applicationId},不要把正式包名硬编码进 provider authorities、回调 scheme 或文件路径;硬编码往往只在第二个渠道安装时才暴露。

让 Flutter 的配置边界清晰可测

Gradle 的 BuildConfig 不能自动成为 Dart 常量。简单项目可以用两个显式入口文件,把可见的环境名通过 --dart-define 传入;复杂项目则把入口统一交给一个 AppConfig。目录不要按“staging 功能”复制 widget,而要把变化集中在配置层:

lib/
  app/app_config.dart
  main_staging.dart
  main_production.dart
  features/
android/app/src/staging/res/values/strings.xml
android/app/src/production/res/values/strings.xml
const apiBaseUrl = String.fromEnvironment('API_BASE_URL');

Future<void> main() async {
  assert(apiBaseUrl.isNotEmpty, 'API_BASE_URL must be supplied by the build command');
  runApp(App(apiBaseUrl: apiBaseUrl));
}

把构建命令固定在脚本或 CI,而不是依赖每位开发者记忆:flutter build appbundle --flavor staging --dart-define=API_BASE_URL=https://staging-api.example.com。CI 日志中应打印 flavor、git commit、输出文件和 SHA-256;日志只记录主机名或环境名,绝不打印 token、完整请求头或 keystore 密码。

签名配置与产物核验分两步做

渠道差异不等于签名差异。多数团队使用同一个 upload key 给 stagingRelease 与 productionRelease 签名,只有在商店、合作渠道或安全策略确有要求时才拆 key。把 key.properties 排除出版本控制,让 CI 从受保护变量写入临时文件;Android 官方发布文档也要求在发布前复核签名和 release 构建配置。Flutter Android 发布文档 可作为签名和 app bundle 的核验入口。

第一步验证 Gradle 实际选择了什么:

./gradlew :app:assembleStagingRelease --info
./gradlew :app:signingReport
flutter build appbundle --flavor staging --dart-define=API_BASE_URL=https://staging-api.example.com

保存 signingReport 中的 variant 名、store alias 与 SHA-256 指纹到本次构建记录。第二步验证最终 AAB,而不是相信配置文件:使用 bundletool 生成测试 APK 后安装,或在受控测试设备打开“应用信息”,记录 package name、versionName、首屏显示的环境标识。此处的验收对象是“安装后的包”,不是 Gradle 文件看起来是否正确。

一次最小回归应该覆盖什么

每个候选产物都走同一张 6 项检查表:

  • 删除旧包后安装 stagingDebug 与 productionDebug,确认可并存,包名没有冲突。
  • 在启动日志记录 variant=... 和脱敏后的 API 主机;测试人员能从日志和设置页得到同一结论。
  • 对 staging 做一个只读 API 请求,确认没有误连生产;对 production 使用批准的测试账号,不造假数据。
  • 检查通知、FileProvider authority、deep link host 等 manifest 属性是否因 ${applicationId} 正确展开。
  • 在 release 变体跑 smoke test,保存截图、构建命令、签名指纹和 AAB 的哈希。
  • 让没有改 Gradle 的同事按文档重跑一遍;若他无法复现,说明流程还依赖口头知识。

测试的失败信息应可定位。例如 INSTALL_FAILED_CONFLICTING_PROVIDER 多半提示 authority 仍然硬编码;安装覆盖了另一个渠道包则先回看 applicationId 拼接;日志显示生产域名则先核对 CI 的 --dart-define。不要为了让测试通过而把多个变体临时改回同一个 applicationId,这会掩盖真正的发布风险。

常见失误、复盘与下一步

最常见的坑有四个:只给 flavor 加 suffix 却忘了 debug 又加一次;将 API 地址同时放入 Gradle、Dart 和 CI 三处;把 release keystore 放进仓库;只验证 APK 没验证准备提交的 AAB。每次发布候选完成后,用 10 分钟复盘:配置差异是否只有一处来源、命令输出是否齐全、安装包是否被实际验证、异常日志是否没有秘密。

当变体已经稳定后,下一步再为每个 flavor 加 CI 矩阵、缓存和测试门禁;不要在基础包名与签名边界未验证前急着并行发布。需要回顾 Android 侧文件授权的团队,可结合站内文章Flutter Android 文件分享实现:校验 URI 授权检查所有 manifest 占位符。

把检查做成可交接的发布记录

发布记录不是一份“已打包”的留言。每次构建在 CI 里生成一个目录,例如 artifacts/release-evidence/<commit>/<variant>/,其中只包含允许保存的四类材料:构建命令、版本控制提交号、AAB 的 SHA-256、signingReport 摘要,以及一次测试设备的验收结果。密钥文件、完整环境变量、访问令牌和用户数据都不进入这个目录。这样线上事故发生时,排查者能知道哪个候选包被测试过,而不必从聊天记录猜测。

可以将变体检查写成机器可读的 JSON,再让 CI 在最后比较预期与实际。关键字段是 variant、applicationId、versionName、apiHostLabel、certificateSha256 和 artifactSha256。比较失败时让任务停止,不要把告警降级为备注。对于 applicationId,比较“完整值”而非只检查它包含 staging;这样生产包意外出现 suffix、或 debug 包少了 suffix,都会在交付前显现。

{
  "variant": "stagingRelease",
  "applicationId": "com.stepnex.app.staging",
  "apiHostLabel": "staging-api.example.com",
  "artifactSha256": "recorded-by-ci",
  "deviceSmokeTest": "passed"
}

这份记录还应写明验证环境:Flutter SDK、Gradle/AGP、JDK 和测试设备 Android 版本。它不是为了追逐版本号,而是为了在升级后能重现差异。若构建升级后某个 variant 消失,先执行 ./gradlew :app:tasks --all 与 ./gradlew :app:signingReport,比较任务名和签名输出;不要直接在 IDE 下拉菜单里重新选一次就当作修复。命令输出、测试截图和简短日志共同构成可复盘的证据。

何时应该停止而不是继续发布

出现以下任一情况应停止候选交付:最终 AAB 没有安装验证;package name 与发布表不一致;签名指纹没有来源;API 主机无法从测试日志确认;两个变体无法并存;或 manifest 中有尚未解释的硬编码 authority。停止并不代表构建失败,而是把未知状态留在可修复阶段。修复后重复同一张检查表,保留旧记录并标记原因,避免把新结果覆盖成无法解释的“最后一次”。

本篇的边界是 Android Flutter 的环境变体和签名证据。它不替代 Play Console 的发布审核、服务端权限设计或安全渗透测试。基础记录稳定后,再考虑按渠道扩展图标、应用名称和远程配置;每增加一个差异,都要回到“配置来源、构建命令、安装验证”三项确认。

发布后留下一个短反馈入口

测试人员不需要填写长报告,只要在构建记录中选择“包可并存”“环境标识正确”“只读请求正确”“签名已记录”四项,并在任一项为否时附上一张截图或一段脱敏日志。这样负责修复的人能先复现问题,再决定是改 Gradle、Dart 入口、manifest 还是 CI 参数。下一次候选构建沿用相同表格,才能看出错误是偶发还是配置回归。

若团队有多个模块,也不要让每个模块各自发明 flavor 名。由 app 模块定义环境维度,库模块只接受构建变体匹配;出现 variant 不匹配时先记录依赖解析输出,确认是维度名、fallback 还是发布配置问题。把这种排查放在 release 前,比把错误留给应用商店审核或最终用户成本低得多。