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 与构建模式的笛卡尔积:

Variant=Flavor×BuildType\text{Variant}=\text{Flavor}\times\text{BuildType}

例如定义 devproduction 两个 Flavor,并使用 debugrelease 两种 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 构建可以粗略分成:

  1. 配置阶段:读取 settings.gradle、项目级和模块级 build.gradle,注册插件、模块和变体。
  2. 任务图计算:根据目标任务及依赖关系决定要执行哪些任务。
  3. 执行阶段:编译 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. applicationIdnamespace 和包名不是同一概念

模块级配置中经常同时出现:

android {
    namespace "com.example.shop"

    defaultConfig {
        applicationId "com.example.shop"
    }
}
  • namespace:Android 源码和生成代码使用的命名空间。
  • applicationId:安装到设备和发布到应用商店时识别应用的唯一 ID。
  • Dart 文件中的 package: 导入路径:由 Dart 包名决定,与 Android applicationId 不是同一个配置。

正式发布后,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 权限问题至少包含三层:

  1. Manifest 声明:应用是否请求某权限;
  2. 运行时授权:用户是否在系统弹窗中允许;
  3. 系统策略与版本差异:即使用户允许,系统也可能限制访问范围。

例如访问摄像头需要在 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
  • versionCodeversionName 是否正确;
  • 支持的最低 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 官方文档重新梳理;正文与示例由 WR BLOG 编写。