Flutter 基础体系 · 第 75/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter Android 发布:Gradle、签名、Flavor、AAB、权限和混淆
Flutter Android 发布不是“执行一次 flutter build”这么简单。发布产物至少经过以下链路:
flowchart LR
A[Dart 源码] --> B[Flutter 编译]
C[AndroidManifest.xml] --> D[Android Gradle Plugin]
E[Gradle 配置与依赖] --> D
B --> F[Android APK/AAB]
D --> F
F --> G[签名]
G --> H[本地验证]
H --> I[应用商店或企业分发]
其中,Gradle负责组织 Android 构建,签名证明产物来自同一开发者,Flavor生成不同环境或渠道的变体,AAB是面向应用商店的发布格式,权限决定应用能否访问受保护的系统资源,混淆则在缩小体积的同时提高逆向分析成本。它们相互独立但最终会在同一个构建变体中汇合。
一、发布前必须先区分的几个概念
1. Build mode、Flavor 和 Build variant
Flutter 默认有三种构建模式:
debug:支持调试、热重载,不能作为正式发布产物。profile:用于性能分析,保留部分分析能力。release:关闭调试能力并进行发布构建。
Flavor 是 Android 的产品维度,例如:
dev:开发环境;staging:测试环境;production:生产环境。
一个完整的 Android 构建变体通常是 Flavor 与构建模式的笛卡尔积:
例如定义 dev、production 两个 Flavor,并使用 debug、release 两种 Build Type,就会得到:
devDebug
devRelease
productionDebug
productionRelease
productionRelease 与普通的 release 不是同一个变体。只有当项目没有定义 Flavor 时,release 才表示默认发布变体。
2. APK 与 AAB
APK 是可以直接安装到 Android 设备上的包。
AAB(Android App Bundle) 是提交给 Google Play 等应用商店的发布包,通常不能直接作为普通 APK 安装。
AAB 包含应用及其资源、ABI、语言等信息,商店会根据用户设备生成一个或多个 APK。例如:
同一个 AAB
├── arm64-v8a 的 APK
├── x86_64 的 APK
├── 中文资源 APK
└── 其他按设备条件拆分的 APK
因此:
- 给测试人员直接安装:通常使用 APK;
- 提交 Google Play:通常使用 AAB;
- AAB 本身不能用
adb install app-release.aab安装。
如果需要在本地从 AAB 生成设备可安装的 APK,可以使用 Android SDK 中的 bundletool:
bundletool build-apks \
--bundle=build/app/outputs/bundle/productionRelease/app-production-release.aab \
--output=app.apks \
--mode=universal \
--ks=upload-keystore.jks \
--ks-key-alias=upload
命令需要 Java、bundletool 和有效密钥。--mode=universal 会生成包含全部内容的通用 APK,但它通常比商店按设备拆分的 APK 更大,因此只适合测试,不代表用户最终下载的体积。
二、Gradle:Flutter Android 构建的执行骨架
1. Gradle、Android Gradle Plugin 和 Flutter Gradle Plugin
Gradle 是构建系统;Android Gradle Plugin(简称 AGP)提供 Android 的编译、打包、签名和变体模型;Flutter Gradle Plugin 将 Flutter 工程的 Dart 编译和资源嵌入 Android 构建流程。
一个现代 Flutter 工程通常包含:
android/
├── settings.gradle
├── build.gradle
├── gradle.properties
├── gradle/
│ └── wrapper/
│ └── gradle-wrapper.properties
└── app/
├── build.gradle
└── src/
└── main/
└── AndroidManifest.xml
实际模板可能使用 settings.gradle.kts 或不同的插件版本,因此不能机械复制别的项目配置。首先检查:
flutter doctor -v
flutter --version
cd android
./gradlew --version
这些命令分别验证 Flutter、Dart、Android SDK、Java 和 Gradle Wrapper。Windows 下使用:
flutter doctor -v
cd android
gradlew.bat --version
Gradle Wrapper 会使用项目锁定的 Gradle 版本,而不是机器上任意安装的 Gradle。AGP、Gradle、Java 和 Flutter 模板之间存在兼容关系;如果出现类似以下错误,应先解决工具链版本,而不是修改业务代码:
Unsupported class file major version
Android Gradle plugin requires Java ...
Minimum supported Gradle version is ...
常见原因包括:
- 当前 Java 版本与 AGP 不兼容;
- 手工升级了 AGP,却没有同步升级 Gradle Wrapper;
- 复制了旧 Flutter 项目的
android/目录; - Android SDK 的
compileSdk不满足依赖要求。
2. Gradle 的配置和执行阶段
Gradle 构建可以粗略分成:
- 配置阶段:读取
settings.gradle、项目级和模块级build.gradle,注册插件、模块和变体。 - 任务图计算:根据目标任务及依赖关系决定要执行哪些任务。
- 执行阶段:编译 Kotlin/Java、编译资源、合并 Manifest、编译 Dart、打包并签名。
例如:
flutter build appbundle --release
并不是一个单独动作,而是触发 Flutter 的 Android 构建任务,最终生成类似:
build/app/outputs/bundle/release/app-release.aab
如果执行:
cd android
./gradlew app:tasks --all
可以查看当前项目实际注册的 Android 任务。Flavor 存在时,通常可以看到:
assembleProductionRelease
bundleProductionRelease
任务名中的大小写很重要。Gradle 变体名称采用首字母大写的组合形式。
3. applicationId、namespace 和包名不是同一概念
模块级配置中经常同时出现:
android {
namespace "com.example.shop"
defaultConfig {
applicationId "com.example.shop"
}
}
namespace:Android 源码和生成代码使用的命名空间。applicationId:安装到设备和发布到应用商店时识别应用的唯一 ID。- Dart 文件中的
package:导入路径:由 Dart 包名决定,与 AndroidapplicationId不是同一个配置。
正式发布后,applicationId 应尽量保持稳定。修改它会被 Google Play 视为另一个应用,无法更新原应用。
三、用 Flavor 管理环境,而不是复制整套工程
1. 定义 Flavor
以下是 Groovy DSL 中的典型配置,位置通常是 android/app/build.gradle:
android {
flavorDimensions "environment"
productFlavors {
dev {
dimension "environment"
applicationIdSuffix ".dev"
versionNameSuffix "-dev"
}
staging {
dimension "environment"
applicationIdSuffix ".staging"
versionNameSuffix "-staging"
}
production {
dimension "environment"
}
}
}
这里:
dev applicationId = com.example.shop.dev
staging applicationId = com.example.shop.staging
production applicationId= com.example.shop
applicationIdSuffix 允许开发版与生产版同时安装。它也意味着不同 Flavor 是不同 Android 应用,可能分别拥有:
- 不同的通知渠道;
- 不同的 OAuth 回调地址;
- 不同的 Firebase 配置;
- 不同的应用商店条目;
- 不同的深链接注册关系。
如果没有 flavorDimensions,在较新的 AGP 中可能遇到:
All flavors must now belong to a named flavor dimension
因为 Android 允许多个产品维度,Gradle 必须知道每个 Flavor 属于哪一维。
2. Flavor 对应 Dart 入口
可以为不同环境使用不同入口:
lib/
├── main_dev.dart
├── main_staging.dart
└── main_production.dart
构建命令:
flutter run \
--flavor dev \
-t lib/main_dev.dart
发布测试包:
flutter build apk \
--flavor staging \
--release \
-t lib/main_staging.dart
生成生产 AAB:
flutter build appbundle \
--flavor production \
--release \
-t lib/main_production.dart
这里有两个独立选择:
--flavor production选择 Android 变体;-t lib/main_production.dart选择 Dart 入口。
只写其中一个并不会自动替另一个做决定。若项目只有 lib/main.dart,也可以使用:
flutter build appbundle \
--flavor production \
--release
3. 用 --dart-define 传递编译期配置
可以使用:
flutter build appbundle \
--flavor production \
--release \
--dart-define=API_BASE_URL=https://api.example.com
Dart 代码读取:
const apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.example.com',
);
String.fromEnvironment 读取的是编译期环境常量,不是运行时系统环境变量。它适合传递非秘密配置,例如 API 地址或功能开关;不要把私钥、服务端密码、签名密钥放进 --dart-define,因为它们最终会进入客户端产物,混淆也不能使秘密真正保密。
如果值包含空格或 shell 特殊字符,必须按照当前 shell 的规则引用,例如:
--dart-define='API_BASE_URL=https://example.com/a path'
在 CI 中应确认实际执行日志不会打印敏感参数。
4. Flavor 的资源和 Manifest 覆盖关系
可以按源集放置文件:
android/app/src/main/AndroidManifest.xml
android/app/src/dev/AndroidManifest.xml
android/app/src/staging/AndroidManifest.xml
android/app/src/production/AndroidManifest.xml
Gradle 会根据优先级合并资源。更具体的变体源集通常会覆盖更通用的源集,但最终结果还会受到库 Manifest 和 Build Type 的影响。查看合并报告:
android/app/build/outputs/logs/manifest-merger-<variant>-report.txt
当同一个权限、Provider 或 android:exported 属性出现冲突时,应查看该报告,而不是猜测最终 Manifest。
四、签名:Android 为什么信任一次更新
1. 签名的作用
Android 应用签名使用开发者的私钥对应用进行签名,系统使用对应公钥验证签名。对同一个应用 ID,更新包必须满足签名关系,否则 Android 会拒绝安装更新:
INSTALL_FAILED_UPDATE_INCOMPATIBLE
签名证明的是“这个包由对应密钥持有者发布”,不是证明代码安全,也不是加密整个 APK。任何拿到 APK 的人都可以读取其中的公开资源和代码;签名主要防止未经授权的替换更新。
2. 创建上传密钥
例如使用 JDK 的 keytool:
keytool -genkeypair \
-v \
-keystore upload-keystore.jks \
-alias upload \
-keyalg RSA \
-keysize 2048 \
-validity 10000
参数含义:
-keystore:密钥库文件;-alias:密钥条目的名称;-keyalg RSA:生成 RSA 密钥;-validity:证书有效期天数。
命令会要求输入密钥库密码和条目密码。生产密钥应放在受控的密码管理系统或 CI Secret 中,不能提交到 Git。
3. 在 Gradle 中配置签名
可以创建 android/key.properties:
storePassword=...
keyPassword=...
keyAlias=upload
storeFile=/absolute/path/to/upload-keystore.jks
然后在 android/app/build.gradle 中读取:
def keystorePropertiesFile = rootProject.file("key.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
release {
if (keystorePropertiesFile.exists()) {
keyAlias keystoreProperties["keyAlias"]
keyPassword keystoreProperties["keyPassword"]
storeFile file(keystoreProperties["storeFile"])
storePassword keystoreProperties["storePassword"]
}
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
同时加入 .gitignore:
android/key.properties
*.jks
*.keystore
storeFile 使用绝对路径最容易在本地工作,但不利于 CI。CI 中更常见的方式是把密钥解码到临时目录,并通过环境变量生成 key.properties。无论采用哪种方式,都要验证最终的 release 变体确实引用了生产签名,而不是继续使用 debug 签名。
4. Debug 签名不能用于生产
如果没有配置 release 签名,某些工程会临时复用:
signingConfig signingConfigs.debug
这样可以生成可安装 APK,但不应上传生产商店。debug 密钥通常由本机自动生成,不适合团队共享;换一台机器或清理环境后,它可能不同,导致无法更新原应用。
可以用 Android SDK 的 apksigner 检查 APK:
apksigner verify --verbose app-release.apk
如果工具输出验证成功,说明签名结构有效;但还应进一步确认签名证书是否是期望的证书,而不只是“有签名”。
5. Play App Signing 与上传密钥
Google Play 常见的密钥分工是:
开发者 --上传密钥--> Google Play --应用签名密钥--> 用户设备
- 上传密钥:开发者用于上传 AAB。
- 应用签名密钥:Google Play 用于为最终分发 APK 签名。
两者可以相同,也可以不同。启用 Play App Signing 后,上传密钥泄露时通常可以在商店侧重置上传密钥;应用签名密钥的管理则更严格。企业内部分发或其他商店可能仍要求开发者直接使用应用签名密钥,因此必须以目标分发渠道的要求为准。
五、权限:Manifest 声明只是第一步
1. 权限的三层模型
Android 权限问题至少包含三层:
- Manifest 声明:应用是否请求某权限;
- 运行时授权:用户是否在系统弹窗中允许;
- 系统策略与版本差异:即使用户允许,系统也可能限制访问范围。
例如访问摄像头需要在 android/app/src/main/AndroidManifest.xml 中声明:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="Shop"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
...
</application>
</manifest>
但声明不会自动弹窗。运行时还需要调用 Android 权限 API,或使用与当前 Flutter 版本兼容的权限插件。典型逻辑应包括:
检查状态
├── 已授权:继续访问
├── 未决定:请求权限
│ ├── 用户允许:继续
│ └── 用户拒绝:显示可恢复提示
├── 已拒绝:根据场景重新请求或降级
└── 永久拒绝/不可再询问:引导系统设置或禁用功能
插件的具体 API 会随插件版本变化,因此应以所用插件文档为准;不能只添加 Manifest 权限就认为功能可用。
2. 权限与功能的因果关系
以相机功能为例:
未声明 CAMERA
↓
Android 不允许应用按普通方式申请该权限
↓
相机初始化失败或返回拒绝
已声明但未运行时授权
↓
系统仍可能拒绝打开相机
↓
应用必须请求并处理用户选择
已授权
↓
仍可能因设备无相机、相机被占用、系统策略而失败
因此错误处理不能只捕获“权限拒绝”。还应处理硬件不存在、资源占用和生命周期变化,例如应用从后台回到前台后重新检查权限和资源状态。
3. 权限与 Android 版本
权限名称和行为具有 Android 版本差异。常见边界包括:
- Android 6.0 及以上的危险权限需要运行时申请;
- 通知权限在较新的 Android 版本上存在单独的授权行为;
- 图片和视频访问在新版本中可能提供更细粒度或系统选择器;
- 后台位置、精确位置、蓝牙等权限有额外限制和申请顺序;
targetSdk提升后,系统可能要求新的权限声明或行为适配。
compileSdk 决定编译时可使用的 Android API,targetSdk 表示应用针对哪个 Android 行为版本进行适配,minSdk 决定最低可安装版本。三者不能混为一谈。提高 targetSdk 不等于自动获得权限,反而可能引入新的系统约束。
4. 权限最小化和商店审核
权限应与用户可见功能直接对应。例如一个只扫描二维码的功能优先考虑相机权限;不要因为“以后可能使用”而预先声明通讯录、短信或后台位置。
最终 Manifest 是多个来源合并后的结果,依赖库可能带入权限。可以检查:
cd android
./gradlew app:processProductionReleaseManifest
再查看合并报告,确认最终 AAB 中真实存在的权限。对于不希望被库带入的声明,可以使用 Manifest merger 的移除规则,但必须确认移除后库功能仍然成立,不能只为通过审核而盲目删除。
六、混淆、压缩和调试信息:三个不同目标
1. Dart 混淆
Flutter release 构建可以启用 Dart 符号混淆:
flutter build appbundle \
--release \
--flavor production \
--obfuscate \
--split-debug-info=build/symbols/production
--obfuscate:改变 Dart 符号名称,增加反编译可读性;--split-debug-info:把还原堆栈所需的符号信息保存到指定目录,不放入最终产物。
混淆不是加密。攻击者仍可能观察字符串、资源、网络协议和运行行为。
当线上出现 Dart 崩溃堆栈时,通常需要使用与该版本完全匹配的符号文件进行还原。版本匹配的关键条件包括:
- 同一份源码版本;
- 同一套编译参数;
- 同一份构建产物;
- 符号目录未被覆盖。
因此应按版本保存:
symbols/
└── 1.4.0+120/
└── ...
不能把多个版本的符号文件混放后凭文件名猜测对应关系。
2. Android R8/ProGuard 混淆
Android 原生代码由 R8 处理。模块级 Gradle 配置常见形式为:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile(
'proguard-android-optimize.txt'
), 'proguard-rules.pro'
}
}
}
三者作用不同:
minifyEnabled true:启用代码压缩、优化和混淆;shrinkResources true:删除被判断为未使用的 Android 资源;proguardFiles:指定默认及自定义规则。
R8 根据静态分析删除看似未使用的类或方法。但反射、JNI、序列化、动态注册、第三方 SDK 可能无法被静态分析识别,导致 release 才失败:
debug 正常,release 崩溃
ClassNotFoundException
NoSuchMethodException
反射创建对象失败
此时不能直接对整个包添加无限制保留规则。应先确认调用方式,再为确实需要动态访问的类添加最小规则,例如:
-keep class com.example.sdk.model.** { *; }
-keepclassmembers class * {
@com.example.sdk.SomeAnnotation <fields>;
}
规则必须根据实际 SDK 文档和 release 堆栈编写。过宽的 -keep class ** { *; } 会显著降低压缩和混淆效果,也可能掩盖依赖设计问题。
3. Dart 混淆与 R8 的边界
两套机制处理不同代码:
Dart 代码 → Flutter/Dart 混淆与符号拆分
Kotlin/Java 代码 → R8
Android 资源 → 资源压缩
开启 R8 不会混淆 Dart 类名;开启 Dart --obfuscate 也不会处理 Kotlin/Java。Flutter 插件中的 Android 原生部分可能需要 R8 规则,而插件对应的 Dart 部分又可能需要 Dart 符号文件。
4. 混淆后的验证
不要只验证“构建成功”。至少应安装 release APK 或从 AAB 生成测试 APK,覆盖:
- 首次启动;
- 登录和支付流程;
- 推送点击;
- 深链接;
- 相机、定位、文件选择;
- 后台恢复;
- 关键原生插件功能。
同时上传或保存:
- R8 mapping 文件;
- Dart split debug info;
- 构建提交版本;
- AAB 的校验值;
- 签名和版本信息。
删除这些文件后,线上堆栈可能无法恢复到可读类名和源码位置。
七、一个可复用的发布配置
假设项目:
applicationId: com.example.shop
Flavor: dev、staging、production
入口: lib/main_<flavor>.dart
构建命令可以统一为:
# 开发调试
flutter run --flavor dev -t lib/main_dev.dart
# 测试 release APK
flutter build apk \
--release \
--flavor staging \
-t lib/main_staging.dart
# 生产 AAB,并保存 Dart 符号
flutter build appbundle \
--release \
--flavor production \
-t lib/main_production.dart \
--obfuscate \
--split-debug-info=build/symbols/1.4.0-120
构建前可以清理 Flutter 生成内容:
flutter clean
flutter pub get
flutter clean 会删除构建缓存,可能使下一次构建变慢,但不会删除源码或密钥。它适合排除旧产物、旧插件缓存造成的问题,不应作为所有构建失败的固定答案。
生成后检查输出目录:
build/app/outputs/flutter-apk/
build/app/outputs/bundle/productionRelease/
如果实际文件名、目录或变体与预期不一致,应先确认 Flavor 名称、Build Type 和 Flutter 版本模板,而不是在脚本中硬编码错误路径。
八、发布验证:从“能构建”到“能更新”
1. 验证 APK 的基本属性
aapt2 dump badging app-production-release.apk
可检查:
package是否为生产applicationId;versionCode和versionName是否正确;- 支持的最低 SDK;
- 启动 Activity;
- 声明的权限。
也可以使用 Android Studio 的 APK Analyzer 查看资源、DEX、Manifest 和签名相关信息。
2. 验证升级关系
至少测试两条安装路径:
旧生产版本 → 新生产版本
旧生产版本 → staging 版本
第二条通常应失败或被视为不同应用,原因是 staging 使用了不同的 applicationId 或签名。若 staging 意外覆盖生产版本,说明 Flavor 的应用 ID 隔离配置存在问题。
版本升级需要满足:
new versionCode > old versionCode
applicationId 相同
签名关系有效
versionName 是用户可见版本字符串,versionCode 是商店和系统用于比较新旧版本的整数。只改 versionName 而不增加 versionCode,通常不能上传更新。
3. 常见失败及诊断路径
INSTALL_FAILED_UPDATE_INCOMPATIBLE
可能原因:
- 新包使用了不同签名;
- 新包
applicationId改变; - 设备上残留了另一份同包名应用。
诊断:
adb shell pm path com.example.shop
adb shell dumpsys package com.example.shop
临时测试可卸载旧应用,但这会删除本地数据,不能把“卸载后能安装”当作升级验证。
release 启动后立即崩溃
诊断顺序:
adb logcat
然后区分:
ClassNotFoundException:优先检查 R8/反射规则;- Dart 堆栈不可读:检查对应版本的 split debug info;
- 权限异常:检查最终 Manifest 和运行时授权;
- 资源找不到:检查 Flavor 源集与资源压缩;
- 原生插件异常:检查 Android API、生命周期和 release 配置。
AAB 能上传但用户功能异常
AAB 上传成功只证明格式、签名和商店校验通过,不代表每种设备配置都正确。重点检查:
- ABI 是否包含目标设备;
- 动态资源或语言拆分是否符合预期;
- release 混淆后的插件功能;
- Android 版本差异;
- 商店生成的实际 APK,而不是本地通用 APK。
九、哪些配置不能互相替代
几个容易混淆的错误等式如下:
AAB ≠ 已签名就自动可安装的 APK
Manifest 权限 ≠ 用户已经授权
R8 混淆 ≠ Dart 混淆
debug 可运行 ≠ release 可运行
Flavor ≠ Dart 环境变量
applicationId ≠ Dart package 名
混淆 ≠ 加密
例如,添加:
<uses-permission android:name="android.permission.CAMERA" />
只完成“声明允许申请相机权限”这一步;仍需在运行时检查并请求。又如,执行:
flutter build appbundle --release
默认不会因为命令中出现 release 就自动启用 Dart --obfuscate,也不会替项目正确配置所有 R8 保留规则。
十、面向 Android、iOS、桌面和 Web 的边界
本文的 Gradle、Android Manifest、AAB、Android Keystore、R8 和 Android 运行时权限流程只适用于 Android。
- iOS 使用 Xcode、Apple 的证书与 provisioning profile,发布格式和签名体系不同。
- Web 没有 Android APK/AAB,也没有 Android Manifest;权限由浏览器安全模型和 HTTPS 等条件控制。
- Windows、macOS、Linux 使用各自的打包和签名机制,不使用 Android Gradle Plugin。
- Flutter 的 Dart
--obfuscate是否适合某个平台,要以该平台和当前 Flutter 工具链的支持情况为准,不能把 Android 的命令直接套用到所有目标平台。
最终,一个可交付的 Android 生产版本应同时满足:
目标 Flavor 正确
+ release 构建正确
+ applicationId 正确
+ versionCode 已递增
+ 生产签名正确
+ 最终 Manifest 权限正确
+ AAB/APK 产物经过安装和升级验证
+ R8/Dart 符号文件已安全保存
缺少其中任意一项,都可能出现“本地能运行、商店不能上传”“能安装、不能升级”或“debug 正常、release 崩溃”等发布问题。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter CI/CD:分析、测试、签名、构建、商店上传和回滚
- 下一篇:Flutter iOS 发布:证书、Provisioning、Capability、归档和审核
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论