Flutter 基础体系 · 第 22/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 应用发布:签名、Flavor、商店、Web/桌面、灰度和回滚

1. 先确定“发布物”是什么

Flutter 项目通常不是只生成一个文件。一次发布至少包含以下维度:

目标 典型产物 发布渠道 是否需要平台签名
Android .aab.apk Google Play、企业分发、其他商店
iOS .ipa App Store、TestFlight、企业或特定分发渠道
Web build/web 静态文件 CDN、对象存储、Web 服务器 没有应用商店签名,但需要 HTTPS 和部署安全
Windows .exe、MSIX 等 Microsoft Store、官网、企业分发 通常应进行代码签名
macOS .app.dmg.pkg Mac App Store、官网 通常需要代码签名和公证
Linux 二进制、tar、deb、rpm 等 官网、软件仓库、发行版渠道 没有统一的 Flutter 商店签名模型

Flutter 的 release 模式只表示“针对生产运行时优化并关闭调试能力”,不等于已经具备商店要求的签名、版本号、隐私声明、权限说明和分发配置。

一个较完整的发布链路可以表示为:

flowchart LR
    A[源代码和依赖锁定] --> B[CI 构建]
    B --> C[Flavor 与环境配置]
    C --> D[编译和打包]
    D --> E[签名]
    E --> F[自动验证]
    F --> G[内部测试]
    G --> H[商店审核或企业分发]
    H --> I[灰度发布]
    I --> J[全量发布]
    I --> K[暂停/回滚]
    J --> L[监控与复盘]
    K --> M[兼容性修复版本]

这里有三个容易混淆的概念:

  1. 构建:把 Dart、Flutter 框架和平台代码编译成目标平台产物。
  2. 签名:证明产物来自某个受信任的发布者,并允许平台验证其完整性和升级关系。
  3. 发布:把签名后的产物提交给商店或服务器,并控制哪些用户可以获得它。

只有完成三者,才形成可交付的软件版本。


2. 版本号、构建号与升级关系

Flutter 项目通常在 pubspec.yaml 中声明版本:

version: 1.4.2+87

这里:

  • 1.4.2 是面向用户的版本名称;
  • 87 是构建号;
  • + 后面的值不直接展示给用户,但平台用它判断构建是否更新。

常见映射如下:

Flutter 版本字段 Android iOS
1.4.2 versionName CFBundleShortVersionString
87 versionCode CFBundleVersion

平台通常要求同一个应用的新上传构建号严格递增。因此,下面的构建通常无效:

已上传:1.4.2+87
再次上传:1.4.2+86

即使代码内容不同,商店也可能拒绝它,因为构建号不能倒退。

版本号和构建号解决的是“识别和排序”,不解决回滚。回滚时也不能简单地重新上传旧构建号;通常需要:

  • Android:暂停当前灰度,或发布一个更高版本号的修复版本;
  • iOS:停止当前版本的进一步发布,重新提交一个新的构建或版本;
  • Web:切换服务器指向,但必须处理浏览器和 CDN 缓存;
  • 桌面:重新分发一个更高版本的安装包,或切换下载渠道。

3. Release、Profile 与 Debug

Flutter 常见的构建模式有三种:

  • debug:用于开发,包含断言、调试服务和较多诊断能力;
  • profile:用于性能分析,保留部分分析能力;
  • release:用于生产分发,关闭调试服务并进行优化。

典型命令:

flutter run
flutter run --profile
flutter run --release

flutter build apk --release
flutter build appbundle --release
flutter build ipa --release
flutter build web --release
flutter build windows --release
flutter build macos --release
flutter build linux --release

默认情况下,flutter build 多数目标会使用发布配置,但在 CI 中显式写出 --release 更容易审查,也避免脚本默认值变化造成误解。

发布构建需要特别验证:

flutter analyze
flutter test
flutter build appbundle --release

这三步分别验证静态分析、Dart/Flutter 测试和 Android 生产产物。它们不能互相替代:测试通过不代表 release 编译通过,release 编译通过也不代表真实设备上的权限、深链接和商店安装流程正确。


4. Flavor:同一套代码生成不同应用

4.1 Flavor 的含义

Flavor 是一组命名的构建变体。它可以改变:

  • 应用 ID;
  • 应用名称和图标;
  • API 基地址;
  • Firebase 或其他平台配置;
  • 日志、崩溃上报和功能开关;
  • 是否允许调试菜单;
  • Android/iOS 的原生资源和权限。

例如:

staging:测试环境,com.example.app.staging
production:生产环境,com.example.app

Flavor 不是“运行时切换环境”的同义词。它主要在构建时决定原生资源和编译输入;运行时远程配置则是另一种机制。

4.2 Dart 入口与 --dart-define

可以为不同环境建立不同入口:

lib/
  main.dart
  main_staging.dart
  app.dart

lib/main_staging.dart

import 'app.dart';

void main() {
  runApp(const App());
}

lib/main.dart

import 'app.dart';

void main() {
  runApp(const App());
}

环境参数可以使用编译时常量:

class BuildConfig {
  static const apiBaseUrl = String.fromEnvironment(
    'API_BASE_URL',
    defaultValue: 'https://api.example.com',
  );

  static const appEnvironment = String.fromEnvironment(
    'APP_ENV',
    defaultValue: 'production',
  );

  static const enableDebugPanel = bool.fromEnvironment(
    'ENABLE_DEBUG_PANEL',
    defaultValue: false,
  );
}

构建命令:

flutter build apk \
  --release \
  --flavor staging \
  -t lib/main_staging.dart \
  --dart-define=APP_ENV=staging \
  --dart-define=API_BASE_URL=https://staging-api.example.com \
  --dart-define=ENABLE_DEBUG_PANEL=true

如果使用文件集中管理参数,可以使用 Flutter 支持的 --dart-define-from-file

{
  "APP_ENV": "staging",
  "API_BASE_URL": "https://staging-api.example.com",
  "ENABLE_DEBUG_PANEL": "true"
}
flutter build apk \
  --release \
  --flavor staging \
  -t lib/main_staging.dart \
  --dart-define-from-file=config/staging.json

这里的值会进入编译产物。即使配置文件没有提交到 Git,最终 APK、IPA、Web JavaScript 或桌面程序也可能包含这些值。因此以下内容不能放入 dart-define

  • API 私钥;
  • 云服务管理员凭据;
  • 数据库密码;
  • 对所有客户端都相同的签名密钥;
  • 能绕过服务器授权的令牌。

dart-define 适合放公开的环境标识和接口地址,不适合保存秘密。

4.3 Android Flavor 配置

Android 端需要在 Gradle 中声明 flavor。以 Groovy DSL 为例,android/app/build.gradle 可以包含:

android {
    namespace "com.example.app"
    compileSdk flutter.compileSdkVersion

    defaultConfig {
        applicationId "com.example.app"
        minSdk flutter.minSdkVersion
        targetSdk flutter.targetSdkVersion
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
    }

    flavorDimensions "environment"

    productFlavors {
        staging {
            dimension "environment"
            applicationIdSuffix ".staging"
            versionNameSuffix "-staging"
        }

        production {
            dimension "environment"
        }
    }
}

构建:

flutter build appbundle --release --flavor production -t lib/main.dart
flutter build apk --release --flavor staging -t lib/main_staging.dart

执行成功后,Android 产物通常位于:

build/app/outputs/bundle/productionRelease/
build/app/outputs/flutter-apk/

具体目录会随 Flutter 和 Android Gradle Plugin 模板变化,CI 不应盲目假设所有版本的路径完全相同。更可靠的做法是构建后显式查找 .aab.apk,并把文件复制到统一的 artifacts 目录。

Android flavor 的关键约束是:发布到同一个商店应用的构建必须使用同一个 application ID。如果 staging 使用 com.example.app.staging,它会被商店视为另一个应用,不能覆盖 com.example.app 的生产安装。

4.4 iOS 的 Flavor 实际上依赖 Scheme 和 Build Configuration

iOS 没有 Android productFlavors 的同名机制。Flutter 项目通常通过 Xcode 的以下对象组合实现环境:

  • Build Configuration,例如 Debug-stagingRelease-staging
  • Scheme,例如 stagingproduction
  • 不同的 Bundle Identifier;
  • 不同的 .xcconfig 或原生资源文件。

在 Xcode 中为每个环境创建 Scheme,并让 Scheme 选择对应的 Build Configuration。生产环境还必须配置正确的:

  • Apple Developer Team;
  • Bundle Identifier;
  • 签名证书;
  • Provisioning Profile;
  • Associated Domains、Push Notifications 等 Entitlements。

构建命令形式为:

flutter build ipa \
  --release \
  --flavor production \
  -t lib/main.dart

这里的 --flavor production 需要对应 Xcode 中存在的 Scheme。若 Scheme 不存在、名称大小写不一致,常见失败表现是 Flutter 找不到目标配置或 Xcode 构建失败。

一个实用的区分方式是:

  • Dart --dart-define:传递跨平台的编译时参数;
  • Android Gradle flavor:决定 Android 应用变体;
  • iOS Scheme/Configuration:决定 iOS 应用变体;
  • 远程配置:应用安装后动态决定部分功能。

四者可以组合,但不能把其中一个误认为其他三个。


5. 签名:平台如何确认发布者和升级关系

5.1 签名的基本过程

数字签名可以抽象为:

  1. 对产物内容计算摘要 h = Hash(package)
  2. 使用私钥 sk 对摘要签名,得到 s = Sign(sk, h)
  3. 把签名和证书链附加到产物;
  4. 设备或商店使用公钥 pk 验证:
    • 签名是否由对应私钥产生;
    • 证书是否受信任;
    • 产物内容是否被修改;
    • 新版本是否允许覆盖旧版本。

如果产物内容变为 package',通常有:

Hash(package') != Hash(package)

原签名就无法验证。因此“重新压缩一下 APK/IPA 再上传”可能破坏签名。

签名不是加密:

  • 用户仍然可能反编译客户端;
  • 签名不能隐藏 Dart 常量;
  • 签名不能阻止用户分析公开 API 地址;
  • 签名主要保证来源、完整性和平台升级规则。

5.2 Android 签名

Android 发布至少涉及两个概念:

  • 上传密钥:开发者用来向 Google Play 上传构建;
  • 应用签名密钥:Google Play 用来为最终分发 APK 签名。

使用 Play App Signing 时,上传密钥泄露通常可以通过重新注册上传证书来恢复;应用签名密钥的处理则更关键。不同商店的托管和签名规则可能不同,不能把 Google Play 的流程直接套到其他渠道。

生成上传密钥示例:

keytool -genkeypair \
  -v \
  -keystore android/upload-keystore.jks \
  -alias upload \
  -keyalg RSA \
  -keysize 2048 \
  -validity 10000

命令会要求输入密码和证书信息。android/upload-keystore.jks 不应提交到公共仓库。

可以把路径和密码放在本地或 CI 的安全变量中,例如 android/key.properties

storePassword=...
keyPassword=...
keyAlias=upload
storeFile=/secure/path/upload-keystore.jks

Gradle 中读取并应用签名配置时,应避免在没有密钥的机器上悄悄回退到 debug 签名。典型逻辑是:

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
        }
    }
}

生产 CI 中还应增加显式检查:如果 release 没有有效签名配置,直接失败,而不是生成一个看似成功但无法上架的包。

验证 APK 签名:

$ANDROID_HOME/build-tools/<version>/apksigner verify --verbose app-release.apk

预期结果应包含验证成功的信息。对 AAB,可以先用 Google Play 的测试轨道验证,或使用 Android 工具检查其签名信息;不能把 AAB 当作可直接安装的 APK。

常见 Android 失败:

  • INSTALL_FAILED_UPDATE_INCOMPATIBLE:新旧包签名不一致,设备无法覆盖安装;
  • 商店提示证书不匹配:上传了错误 keystore 或应用 ID 不对应;
  • versionCode 已存在:构建号没有递增;
  • 本地能构建、CI 失败:密钥路径是本机绝对路径,CI 中不存在。

5.3 iOS 签名与 Provisioning Profile

iOS 签名不仅是给 IPA 加一个证书。Provisioning Profile 还描述了:

  • 哪个 App ID 可以运行;
  • 哪个 Team 签发;
  • 哪些设备或分发方式适用;
  • 哪些 Entitlements 被允许。

因此,即使 Bundle Identifier 正确,Push Notifications、Associated Domains 等能力没有正确写入 Entitlements,运行或审核仍可能失败。

常见分发类型包括:

  • Development:开发设备;
  • Ad Hoc:登记设备的有限分发;
  • App Store:上传 App Store 或 TestFlight;
  • 企业分发:需要满足 Apple 的企业计划和用途要求,不能当作普通公网分发手段。

使用 Xcode 自动签名时,机器需要登录有权限的 Apple 开发者账号,并且 CI 还需要处理证书和 profile 的安全导入。使用手工签名时,必须让证书、Profile、Bundle ID 和 Entitlements 成套匹配。

构建 IPA:

flutter build ipa --release --flavor production -t lib/main.dart

验证 .app 的签名可使用:

codesign --verify --strict --verbose=2 path/to/Runner.app

如果要在 macOS 分发到非 App Store 渠道,还通常需要配合 Developer ID 签名、公证和 Gatekeeper 验证。一个命令成功只说明当前检查通过,不代表 Apple 商店审核一定通过,因为隐私用途描述、数据收集声明和权限使用仍是独立要求。


6. 商店发布不是“上传文件”这么简单

6.1 Android 商店

Google Play 常用 .aab,因为商店可以针对设备生成更合适的 APK。发布前应检查:

  • applicationId 是否为生产值;
  • versionCode 是否高于已发布版本;
  • target SDK 是否满足当前商店政策;
  • 64 位和 ABI 配置是否符合目标设备;
  • 权限和数据安全表单是否真实;
  • 签名是否使用正确的上传密钥;
  • 深链接、通知和应用内购买是否在发布包中可用。

签名验证、安装验证和商店验证是三个不同层次:

本地签名验证通过
    ≠
真实设备安装和升级通过
    ≠
Google Play 审核通过

例如,应用可能签名正确,但因为生产服务器拒绝了新版本的 API 请求,升级后启动即报错。

6.2 iOS App Store 和 TestFlight

iOS 构建上传到 App Store Connect 后,通常经历:

  1. 上传构建;
  2. Apple 处理构建;
  3. TestFlight 内部或外部测试;
  4. 提交 App Review;
  5. 手动或自动发布;
  6. 可选的分阶段发布。

TestFlight 测试的是构建在 Apple 分发链路中的行为,但不等于生产用户规模。仍需验证:

  • App Store 构建使用的 Bundle Identifier;
  • 推送环境;
  • App Tracking Transparency 等权限流程;
  • 隐私清单和数据收集声明;
  • 生产后端和第三方 SDK 配置;
  • 首次安装、升级安装和恢复安装。

iOS 的应用版本通常不能像服务器部署那样瞬间替换所有用户。已经安装旧版本的用户可能继续使用旧客户端;已经安装新版本的用户也可能无法被商店强制降级。因此客户端必须尽量向后兼容服务器。


7. Web 发布:没有签名,但缓存和安全成为核心

7.1 构建与部署

构建 Web:

flutter build web --release

产物位于:

build/web/

典型目录包含入口 HTML、JavaScript、资源文件和字体等。部署时应把整个目录上传到静态服务器或 CDN,而不是只上传 index.html

如果应用使用路径式路由,例如:

/orders/123

服务器需要配置 SPA fallback:当静态文件不存在时,将请求回退到 index.html。否则用户直接刷新深层 URL 时会得到 404。

Web 还有一个与移动端不同的失败路径:

用户访问 index.html
    -> index.html 引用旧或新的 JS
    -> CDN 缓存组合不一致
    -> JS 找不到对应资源
    -> 白屏或加载失败

因此应区分缓存策略:

  • index.html:短缓存或不缓存;
  • 带内容哈希的 JS、字体、图片:可以长缓存;
  • 发布时原子更新资源目录,避免入口文件先指向尚未上传的资源。

如果 CDN 的旧节点仍返回旧入口,而资源已被删除,就可能造成部分用户白屏。回滚 Web 时,不能只恢复入口文件,还要确保入口引用的资源仍然存在。

7.2 Web 没有客户端秘密

Web 应用的 JavaScript、配置和资源最终都会发给浏览器。以下写法并不能保护秘密:

const secret = String.fromEnvironment('API_SECRET');

它只是在构建时注入,最终仍可能被用户下载和分析。真正的秘密必须留在服务器,通过服务端鉴权、短期令牌和最小权限接口使用。

Web 发布还应验证:

  • HTTPS;
  • CSP 和资源来源;
  • CORS;
  • Service Worker 或浏览器缓存策略;
  • OAuth 回调地址;
  • 文件上传和下载权限;
  • WebView 与浏览器行为差异;
  • 不把用户隐私数据写入 URL、日志或前端持久化存储。

Flutter Web 的渲染器、浏览器支持和包兼容性会随 Flutter 稳定版本变化。不能仅依据移动端测试结果宣称 Web 等价可用;应在目标浏览器、低性能设备和受限网络中执行发布验证。


8. 桌面发布:平台差异比 Flutter 命令更重要

8.1 Windows

构建:

flutter build windows --release

输出通常在:

build/windows/x64/runner/Release/

Windows 桌面应用可以通过压缩包、安装器、MSIX 或 Microsoft Store 分发。不同方式对以下内容的要求不同:

  • 安装和卸载;
  • 应用数据目录;
  • 自动更新;
  • 文件关联;
  • 防火墙和网络权限;
  • 代码签名;
  • SmartScreen 信任;
  • CPU 架构。

未签名的 .exe 可能被系统或杀毒软件提高风险提示。签名证书应由 CI 安全使用,不能把私钥随安装包分发。

8.2 macOS

构建:

flutter build macos --release

macOS 官网分发通常需要:

  1. Developer ID 签名;
  2. .app 或安装包进行封装;
  3. 提交 Apple 公证;
  4. Staple 公证票据;
  5. 在干净机器上用 Gatekeeper 验证。

验证示例:

codesign --verify --deep --strict --verbose=2 path/to/Runner.app
spctl --assess --type execute --verbose path/to/Runner.app

--deep 可以帮助诊断嵌套代码,但生产签名策略仍应对每个嵌套组件正确签名,而不是依赖最后一步“递归修补”。

沙盒权限、摄像头、麦克风、文件访问和网络权限需要在 macOS 原生配置中声明。Flutter 层调用成功不代表系统一定授予权限。

8.3 Linux

构建:

flutter build linux --release

Linux 没有一个覆盖所有发行版的官方统一应用商店签名流程。发布时要考虑:

  • glibc 或系统库兼容性;
  • deb、rpm、tar 或其他包格式;
  • 桌面文件和图标;
  • 依赖安装;
  • 自动更新;
  • 发行版安全策略。

Linux 的“可执行”不等于“可在所有 Linux 发行版运行”。应在目标发行版或容器化构建环境中验证。

8.4 桌面 Flavor 的边界

Android 和 iOS 的 --flavor 有明确的平台构建概念;Flutter 桌面端并不天然提供完全相同的 flavor 模型。桌面环境通常使用:

  • 不同的 --dart-define
  • 不同的原生工程配置;
  • 不同的签名和打包脚本;
  • 不同的安装包名称和资源目录。

因此,CI 不应假设下面的命令在所有桌面目标上都等价有效:

flutter build windows --flavor staging

应先确认当前 Flutter 版本和目标平台工具是否支持该参数,再决定采用原生配置还是编译时变量。


9. 灰度发布:控制“谁拿到哪个版本”

9.1 灰度的形式化定义

灰度发布是让用户集合 U 被划分为不同发布组:

U = U_old ∪ U_new
U_old ∩ U_new = ∅

其中:

  • U_old 使用旧版本;
  • U_new 使用新版本;
  • 两者并集覆盖当前参与发布的用户。

最简单的随机分配是:

group = hash(stable_user_id) mod 100
if group < p:
    使用新版本
else:
    使用旧版本

p 是灰度比例。例如 p = 5 表示理论上约 5% 的用户进入新版本。

必须使用稳定标识。若每次启动都随机分配,用户可能在新旧版本之间反复切换,导致:

  • 同一用户的行为数据无法比较;
  • 数据库迁移重复触发;
  • 问题复现困难;
  • 版本体验不稳定。

移动商店灰度与服务端功能灰度是两种不同层次:

  • 商店灰度:控制哪些用户可以下载新客户端;
  • 服务端灰度:同一个客户端中,控制哪些用户启用新功能。

二者可以组合。例如先把新客户端投放给 5% 用户,再只对其中 20% 开启新支付流程。此时真实受影响比例不是简单地看某一个开关,而要计算交集。

9.2 Android 与 iOS 的灰度差异

Android 商店通常支持测试轨道和分阶段发布。发布比例提高前,应比较:

  • 崩溃率;
  • ANR;
  • 启动失败;
  • 登录失败;
  • 支付成功率;
  • 关键接口错误率;
  • 机型和系统版本分布。

暂停灰度只能阻止更多用户获得新版本,不能让已经安装新版本的用户自动回到旧版本。真正的回滚可能需要:

  • 服务端关闭新功能;
  • 发布兼容性修复;
  • 引导用户更新到修复版本;
  • 在极端情况下通过商店或企业渠道重新分发旧版本,但这受平台限制。

iOS 支持 TestFlight 测试和商店分阶段发布,但已经安装新版本的用户同样不会因为停止发布而自动降级。iOS 商店通常不能把已安装用户静默退回旧版本。

9.3 Web 灰度

Web 灰度可以在 CDN、边缘网关或服务端路由层完成:

请求
  -> 根据 cookie、用户 ID 或地域计算 cohort
  -> cohort=A 返回 release-2025-01
  -> cohort=B 返回 release-2025-02

关键是入口文件、资源目录和 API 兼容性必须成套。不能让用户拿到新 index.html,却从缓存中加载旧资源,除非资源命名和兼容策略明确支持这种组合。

Web 灰度常用 cookie 保持用户分组。若按照 IP 分组,移动网络、企业代理和 NAT 会导致多个用户共享分组;若按照每次请求随机分组,则刷新页面可能在不同版本之间跳转。


10. 回滚不是“把版本号改回去”

10.1 回滚对象

一次发布至少有四个可能的回滚对象:

  1. 客户端安装包;
  2. Web 静态资源;
  3. 服务端 API;
  4. 远程配置或功能开关。

它们的恢复速度和风险不同:

对象 通常能否立即回滚 主要限制
服务端开关 通常可以 新旧客户端都必须理解该状态
Web 入口和资源 通常较快 CDN、浏览器缓存和资源完整性
API 服务 可以切换版本 数据库迁移和已写入数据
Android/iOS 客户端 不能保证立即降级 商店审核、用户安装状态和构建号规则

因此,生产回滚优先设计为“关闭风险功能并恢复服务端兼容路径”,而不是依赖商店让所有客户端降级。

10.2 一个完整故障例子

假设版本 2.0.0+120 引入了新的订单接口,旧客户端使用:

POST /orders

新客户端使用:

POST /v2/orders

若服务端先删除旧接口,再发布新客户端,仍在使用 1.9.0 的用户会失败。正确顺序应是:

  1. 服务端先支持 /orders/v2/orders
  2. 发布客户端 2.0.0+120
  3. 灰度观察新客户端;
  4. 确认旧版本占比足够低;
  5. 再考虑废弃旧接口。

数据库迁移也有类似约束。假设旧版本读取字段 name,新版本读取字段 display_name。直接删除 name 会使旧客户端崩溃。更安全的迁移过程是:

阶段 1:增加 display_name,保留 name
阶段 2:服务端双写 name 和 display_name
阶段 3:发布读取 display_name 的客户端
阶段 4:确认旧客户端比例下降
阶段 5:停止双写
阶段 6:最后删除 name

这称为向后兼容的渐进式迁移。其因果关系是:客户端升级不是瞬时完成的,所以服务端和数据库必须在一段时间内同时支持多个协议版本。

10.3 回滚状态机

可以把灰度发布抽象成状态机:

stateDiagram-v2
    [*] --> Built: 构建
    Built --> Signed: 签名
    Signed --> Verified: 自动验证
    Verified --> Internal: 内部测试
    Internal --> Review: 提交商店/分发
    Review --> Canary: 小比例灰度
    Canary --> Expanded: 扩大比例
    Expanded --> Full: 全量
    Canary --> Paused: 指标异常
    Expanded --> Paused: 指标异常
    Full --> Paused: 生产事故
    Paused --> Mitigated: 关闭开关/恢复服务端
    Mitigated --> FixedRelease: 发布修复版本
    FixedRelease --> Canary

每次状态转换都应有验证条件。例如:

Verified -> Internal:
  签名有效,安装成功,启动成功,关键冒烟测试通过

Canary -> Expanded:
  新版本崩溃率、登录成功率、支付成功率没有超过阈值

Paused -> Mitigated:
  新功能关闭,旧 API 恢复,错误率回落并持续观察

如果没有定义“什么情况下扩大灰度”和“什么情况下暂停”,灰度就只是慢速全量发布,而不是风险控制。


11. 发布验证:验证产物,不只验证源代码

11.1 构建前验证

flutter doctor -v
flutter pub get
flutter analyze
flutter test

如果项目使用代码生成,还应执行对应生成命令,并确认生成结果已纳入构建。依赖应使用锁定文件构建,避免 CI 在同一个提交上解析出不同版本。

发布前应确认:

  • Flutter SDK 版本;
  • Dart SDK 版本;
  • Android SDK、NDK 和 Gradle 工具链;
  • Xcode 和 macOS 版本;
  • 桌面打包工具;
  • 依赖是否包含平台不支持的插件。

11.2 产物验证

Android:

flutter build appbundle --release --flavor production

验证内容:

  • 包名;
  • 版本名和版本号;
  • 签名证书;
  • 资源和图标;
  • release 模式下的 API 地址;
  • AndroidManifest 权限;
  • 真实设备安装和升级。

iOS:

flutter build ipa --release --flavor production

验证内容:

  • Bundle Identifier;
  • 签名和 Profile;
  • Entitlements;
  • 推送环境;
  • 真实设备安装;
  • 从旧版本升级;
  • 卸载重装和恢复数据。

Web:

flutter build web --release

验证内容:

  • 根路径和深层路由;
  • 刷新和浏览器前进后退;
  • 资源缓存;
  • 旧入口与新资源是否混用;
  • Chrome、Safari、Firefox 等目标浏览器;
  • 网络中断和重新加载。

桌面:

  • 全新安装;
  • 覆盖升级;
  • 卸载;
  • 用户数据保留;
  • 无开发环境机器运行;
  • 签名和系统安全提示;
  • 目标 CPU 架构。

11.3 运行时观测

发布后的指标应按版本、平台、系统版本、设备型号和 Flavor 维度拆分。至少要能够回答:

哪个版本开始出错?
哪些用户受影响?
错误发生在启动、登录、支付还是升级?
新版本错误是否显著高于旧版本?
关闭功能开关后错误是否回落?

Flutter 层可以通过错误上报工具记录异常,但要注意:

  • 不记录密码、访问令牌和完整身份证明;
  • 对用户 ID 做脱敏或哈希;
  • 区分 Dart 异常、Flutter 框架异常、原生崩溃和 ANR;
  • 发布模式下不要依赖 print 作为唯一日志;
  • 记录构建版本、平台和环境,避免无法定位构建来源。

观测系统本身也应经过验证:人为制造一个受控异常,确认它能够带上正确的版本和环境标签,并且隐私过滤有效。


12. 常见误解与失败边界

误解一:用了 --release 就可以上传

错误。--release 不会自动完成:

  • Android 上传密钥配置;
  • iOS 证书和 Provisioning Profile;
  • 商店版本号;
  • 隐私声明;
  • 应用图标和启动图;
  • API 环境切换;
  • 灰度策略;
  • 回滚准备。

误解二:Flavor 可以保护生产密钥

错误。Flavor 只决定构建变体。客户端中的值都可能被提取。真正的授权必须由服务器验证,客户端只能持有低权限、可撤销的凭据。

误解三:暂停商店发布等于回滚

错误。暂停通常只阻止新用户继续获得该版本,不能保证已经安装的用户降级。已经执行的数据迁移也不会自动撤销。

误解四:Web 回滚只需替换 index.html

错误。如果旧入口依赖的 JavaScript、字体或图片已经删除,回滚入口后仍会加载失败。静态资源必须保留与多个入口版本兼容的生命周期。

误解五:签名能阻止客户端被逆向

错误。签名主要保护发布来源和完整性。敏感业务规则、授权判断和秘密必须放在服务端;客户端混淆也不能替代服务端安全控制。

误解六:新客户端发布后,服务端可以立即删除旧接口

错误。用户升级具有延迟,旧客户端会长期存在。服务端接口和数据库迁移应采用兼容窗口,并用实际版本分布决定何时清理旧路径。


13. 一个可审计的 CI 发布流程

一个最小但完整的生产流水线可以是:

set -euo pipefail

flutter --version
flutter pub get
flutter analyze
flutter test

flutter build appbundle \
  --release \
  --flavor production \
  -t lib/main.dart \
  --dart-define-from-file=config/production.json

# 随后执行:
# 1. 检查 AAB 文件存在
# 2. 检查版本号和 applicationId
# 3. 检查签名
# 4. 上传内部测试轨道
# 5. 执行安装、启动、升级冒烟测试
# 6. 再进入人工批准的灰度阶段

这里的生产配置文件不应直接包含秘密;签名文件和密码应由 CI 的秘密存储注入。构建过程还应保存:

  • Git commit;
  • Flutter 和 Dart 版本;
  • 依赖锁定文件摘要;
  • 构建参数;
  • 产物摘要;
  • 签名证书指纹;
  • 测试报告;
  • 发布审批记录。

这样发生故障时,才能证明“线上这个包究竟由什么源代码、什么依赖和什么工具链生成”。

最终,可靠发布的核心不是某一个 Flutter 命令,而是建立一条可验证的因果链:

确定版本和环境
    -> 使用锁定工具链构建
    -> 使用正确身份签名
    -> 验证产物和升级路径
    -> 通过目标渠道灰度
    -> 按版本观测故障
    -> 优先关闭风险功能和恢复兼容服务
    -> 发布更高版本的修复构建

Android 和 iOS 的签名与商店升级规则、Web 的缓存模型、桌面的代码签名和安装方式都不同。Flutter 负责跨平台代码和构建入口,但不会消除这些平台边界;发布系统必须把它们分别建模,才能在灰度失败时真正恢复服务。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。