Skip to content

23 · 打包签名与发布上架 ​

目标:从"本地能跑"到"能交付给用户"——版本号管理、签名配置、混淆与符号表、Android/iOS 双端出包、上架流程与灰度发布。


开篇:前端概念对照 ​

前端Flutter
npm run build 出静态资源flutter build apk/ipa 出安装包
部署到服务器 / CDN上传应用商店审核
无签名概念必须签名(Android keystore / iOS 证书+描述文件)
SourceMap 上传监控平台符号表(debug-info)上传(见 20 篇)
环境变量 .env / vite mode--dart-define / flavor
灰度:按用户分流灰度:应用商店分阶段发布(或自建热更新受限)
改完刷新即生效改完要重新发版审核(这是最大差异)

最重要的认知差异:Web 改完刷新就生效;移动端每次改动都要走"出包 → 提审 → 过审 → 用户更新",周期以小时到天计。这直接决定了两件事:发布前必须充分验证,以及线上出问题要有止血手段(见 20 篇监控 + 商店的"停止分发")。


一、版本号:先定规范再出包 ​

pubspec.yaml:

yaml
version: 1.4.2+38
#        │ │ │  └─ build number(versionCode / CFBundleVersion):只增不减的整数
#        │ │ └──── patch:修 bug
#        │ └────── minor:加功能(向下兼容)
#        └──────── major:不兼容变更
平台字段来源要求
AndroidversionCode = build number;versionName = 版本名versionCode 必须严格递增,否则商店拒绝
iOSCFBundleShortVersionString = 版本名;CFBundleVersion = build number每次提审 build number 必须比上一次大

规则:build number 只增不减、不复用;提审被拒后修复重提,也要 +1。


二、环境隔离:flavor 与 dart-define ​

方式用途场景
--dart-define编译期注入键值对API 地址、Sentry DSN、开关(推荐,够用且简单)
flavor(scheme/productFlavors)完整的多套包名/图标/配置需要同时安装"正式版+测试版"、多品牌包
bash
# 编译期注入(不要硬编码任何密钥)
fvm flutter build apk --release \
  --dart-define=API_BASE=https://api.example.com \
  --dart-define=SENTRY_DSN=https://xxx@yyy/1
dart
const apiBase = String.fromEnvironment('API_BASE',
    defaultValue: 'https://api.example.com');       // 必须有默认值,避免忘记传时崩溃

密钥管理:DSN 这类非高敏可用 --dart-define;真正的私钥(keystore 密码、支付密钥)永远在 CI 的 Secret 里,不进仓库、不进编译产物。对照 09 篇合规要求。

2.1 本项目:没有 flavor,用「三个入口 + dart-define」 ​

本项目不用 productFlavors(android/app/build.gradle 里只有 debug / release 两个 buildType),环境切换靠两件事叠加:

bash
# scripts/build.sh:139-163
ENTRY_POINT="lib/main.dart"          # 默认
...
if [ "$HAIER_ENERGY_ENV" = "prod" ]; then
  ENTRY_POINT="lib/main_prod.dart"   # prod 强制锁定入口
fi

# scripts/build.sh:258-262
fvm flutter build "${FLUTTER_BUILD_ARGS[@]}" \
  --dart-define=HAIER_ENERGY_ENV="$HAIER_ENERGY_ENV" \
  -t "$ENTRY_POINT"

为什么 prod 要单独一个入口:lib/main.dart 暴露 appMain({forcedEnvironment}),环境解析优先级是 入口参数 > SharedPreferences > dart-define > 编译态默认值(lib/config/dependencies.dart:47)。 也就是说 dart-define 说 prod,但手机本地存过 test,运行时仍会走 test——对于正式包这是不可接受的。 lib/main_prod.dart 直接传 forcedEnvironment: Environment.prod 锁死,绕开本地存储。同理 main_test.dart 锁 test。

优先级对应的真实代码(lib/config/dependencies.dart:47-53):

dart
// 入口点强制 > SharedPreferences > dart-define > 默认 dev
final appEnv = forcedEnvironment ?? await EnvironmentConfig.getEnvironment();
RuntimeEnvironment.current = appEnv;

lib/main_prod.dart 全文只有这么点东西:

dart
void main() => appMain(
      forcedEnvironment: Environment.prod,
    );

只要 forcedEnvironment 非空,?? 就直接短路,SharedPreferences 和 dart-define 全部失效——这正是正式包要的「锁死」。

环境入口锁定方式
devlib/main.dart不锁定,走运行时解析
testlib/main_test.dartforcedEnvironment: Environment.test
prodlib/main_prod.dartforcedEnvironment: Environment.prod

AGENTS.md:20-26 的硬性要求:出包统一走 scripts/build.sh,不要直接执行 fvm flutter build—— 因为 dart-define、入口选择、precheck、产物重命名这四件事,手敲命令必然会漏。


三、Android 打包 ​

3.1 签名配置(只做一次, keystore 丢了就完蛋) ​

bash
keytool -genkey -v -keystore ~/upload-keystore.jks \
  -keyalg RSA -keysize 2048 -validity 10000 -alias upload
android/key.properties          # ⚠️ 加入 .gitignore,绝不入库
storePassword=***
keyPassword=***
keyAlias=upload
storeFile=/abs/path/upload-keystore.jks

android/app/build.gradle:

groovy
def keystoreProps = new Properties()
def keystorePropsFile = rootProject.file('key.properties')
if (keystorePropsFile.exists()) {
    keystoreProps.load(new FileInputStream(keystorePropsFile))
}

android {
    signingConfigs {
        release {
            keyAlias keystoreProps['keyAlias']
            keyPassword keystoreProps['keyPassword']
            storeFile keystoreProps['storeFile'] ? file(keystoreProps['storeFile']) : null
            storePassword keystoreProps['storePassword']
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.release
            minifyEnabled true                  // 开启混淆(R8)
            shrinkResources true                 // 移除未使用资源
        }
    }
}

⚠️ 强烈建议使用 Google Play 应用签名(上传密钥由 Google 托管)。本地 keystore 丢失时还能申请重置;自己保管则丢了就无法更新应用,等同于应用死亡。

3.2 出包 ​

bash
# 方式 A:单个安装包(便于分发测试)
fvm flutter build apk --release \
  --split-debug-info=build/debug-info \
  --obfuscate

# 方式 B:按 ABI 分包(体积更小,推荐给测试)
fvm flutter build apk --split-per-abi --release \
  --split-debug-info=build/debug-info --obfuscate

# 方式 C:App Bundle(上架 Google Play 的标准格式)
fvm flutter build appbundle --release \
  --split-debug-info=build/debug-info --obfuscate

--split-debug-info 是必选项:它把符号表抽出来,配合 20 篇的符号化才能在监控平台看到可读堆栈,同时还能缩小包体。build/debug-info 目录要按版本号归档保存。

本项目:只走 scripts/build.sh ​

bash
scripts/build.sh android --release            # 默认 --env test
scripts/build.sh android --release --env prod # 正式包:入口切 main_prod.dart
scripts/build.sh ios --release --env prod
scripts/build.sh ios --debug                  # 注意:实为 profile 包,见 4.2

脚本内部六步(scripts/build.sh):

步骤做什么值得注意的点
1解析 platform / config / env(支持交互式,--env 默认 test)选错环境是最高频事故,脚本把默认值设成 test 而非 prod
2precheck.sh --quick默认不阻塞:PRECHECK_BLOCK=true 才因检查失败中断。CI 里应显式设为 true
3flutter clean + pub get(iOS 额外 pod install,且先删 Pods 与 Podfile.lock)每次全量 clean,牺牲速度换可复现
4flutter build ... --dart-define=HAIER_ENERGY_ENV=... -t <入口>见 2.1
5git checkout -- assets/fonts/iconfont/构建期拉取的远程字体属于临时产物,必须丢弃;i18n JSON 保留作为本地兜底
6产物重命名 haierenergy-<config>-v<version>+<build>.apk,清理残留 app-*.apk版本号从 pubspec.yaml 读,命名自带环境,避免发错包

签名与 ARMS 配置在 android/app/build.gradle:

groovy
defaultConfig {
    applicationId = "com.haier.energy.platform"
    minSdk = 24  // Android 7.0+(阿里云 ARMS SDK 最低要求)
    // 阿里云 ARMS 监控配置
    buildConfigField "String", "ARMS_SERVICE_ID", "\"...\""
    buildConfigField "String", "ARMS_WORKSPACE",  "\"...\""
    buildConfigField "String", "ARMS_ENDPOINT",   "\"...\""
}
buildTypes {
    release {
        // 没有 keystore.properties 时降级用 debug 签名 —— 出正式包前务必确认文件存在
        signingConfig = keystorePropertiesFile.exists()
            ? signingConfigs.getByName("release") : signingConfigs.debug
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
}

⚠️ 两个出包前必查:

  1. release 签名降级到 debug 是静默的——key.properties 缺失时不会报错,只会打出一个用 debug 签名的包。正式出包前确认 keystorePropertiesFile.exists() 为真。
  2. minSdk = 24 是 ARMS 的硬要求,不能为了兼容更低版本而下调;调整前先看 minSdk 注释里的原因(build.gradle:76)。

3.3 混淆的保留规则 ​

开了混淆后,反射、JNI、Platform Channel 相关代码可能失效(对照 21 篇"只在 Release 包出错"的坑):

proguard
# android/app/proguard-rules.pro
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }        # 插件与 Channel 必须保留
-keep class com.haier.energy.** { *; }         # 你的包名(按实际调整)

四、iOS 打包 ​

4.1 前置条件 ​

  • Apple Developer 账号(年费)
  • 证书(Certificate) + App ID + 描述文件(Provisioning Profile)
  • Xcode 中登录账号,勾选 Automatically manage signing(团队开发建议手动管理以保持一致)

4.2 出包 ​

bash
fvm flutter build ipa --release \
  --split-debug-info=build/debug-info \
  --obfuscate
# 产物:build/ios/ipa/YourApp.ipa

或用 Xcode:Product → Archive → Distribute App。

⚠️ 本项目 iOS --debug 打出来的是 profile 包(AGENTS.md:28、scripts/build.sh:13-15)。 原因:iOS 真机禁止 JIT,debug 包无法脱离调试器独立运行,装到测试手机上直接打不开。 所以开发/调试态判断统一用 !kReleaseMode(它同时覆盖 debug 与 profile), 只有 release 专属逻辑才用 kReleaseMode——写成 kDebugMode 会在 profile 包里失效,这是本项目最容易踩的一个坑。 另有 --simulator 模式,出未签名的模拟器 .app 到 build/ios/sim/。

iOS 特有检查项:

项说明
权限描述文案Info.plist 里每个权限都要有 NS*UsageDescription,缺失会直接被拒(对照 15 篇权限)
隐私清单使用敏感 API 需声明 Privacy Manifest,并在商店填写隐私标签
图标与截图各尺寸齐全,无透明通道、无 alpha
上架元数据描述、关键词、年龄分级、隐私政策 URL(必填)

五、上架流程与审核 ​

5.1 两条线 ​

平台流程审核时长
Google Play上传 AAB → 填商店信息 → 提交审核数小时~数天(新账号更久)
App Store上传 IPA(Transporter/Xcode)→ TestFlight 可选 → 提交审核通常 24~48h,节假日更久

5.2 常见被拒原因(提前自查) ​

  • [ ] 崩溃或加载失败(最常见——务必用 release 包完整过一遍主流程)
  • [ ] 权限描述缺失或与功能不符
  • [ ] 隐私政策链接失效 / 未声明数据收集
  • [ ] 应用内购买未走平台支付(虚拟商品)
  • [ ] 元数据、截图与实际功能不符
  • [ ] 使用了私有 API / 热更新违规(iOS 对代码下发限制严格)

5.3 发布策略:分阶段放量 ​

不要一次全量:两家商店都支持分阶段发布(如 1% → 10% → 50% → 100%)。配合 20 篇的崩溃率监控,在 1% 阶段盯 24~48 小时:

观察指标阈值建议
崩溃率明显高于上一版本 → 暂停放量
关键业务成功率(登录/支付)下跌 → 排查
新异常类型首次出现即关注

止血手段:商店支持"暂停分发/停止分发"(已安装用户不受影响);服务端要有开关能降级功能(对照 05 篇稳定性设计的网关思路)。


六、自动化:把出包做成一条命令 ​

项目已有 scripts/ 全家桶(16 篇),发布脚本建议包含:

bash
#!/usr/bin/env bash
set -e
# 1. 检查工作区干净(避免夹带未提交改动)
git diff --quiet || { echo "工作区有未提交改动"; exit 1; }
# 2. 检查版本号已升级
# 3. 跑检查(与 16 篇 precheck 一致)
fvm flutter analyze lib
fvm flutter test
# 4. 出包(含符号表),按版本号归档
VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}')
fvm flutter build appbundle --release --obfuscate \
  --split-debug-info="build/debug-info/$VERSION"
# 5. 上传符号表到监控平台(对应 20 篇)
# 6. 产物归档到发布目录

CI 要点:keystore/证书放 CI Secret;符号表上传纳入流水线(最容易漏的一步,漏了线上堆栈就全是地址)。


七、动手练习 ​

练习 23.1(本地全流程):用 --dart-define 注入一个 API_BASE,debug 与 release 各打一次包,验证两边读到的值不同;再故意漏传,验证 defaultValue 生效不崩溃。

练习 23.2(符号表):打一个带 --obfuscate --split-debug-info 的 release 包,在 build/debug-info 里找到符号文件;思考并写下一个"按版本号归档 + 上传监控平台"的脚本骨架(不用真接监控平台,写清步骤即可)。

练习 23.3(版本管理):模拟一次完整发版——把版本从 1.4.2+38 改成 1.5.0+39,说明每个数字变化的含义;再模拟"提审被拒后修复重提",说明版本号该怎么改。

练习 23.4(进阶):写一个 release 包检查清单脚本,自动校验:版本号已递增、工作区干净、analyze 无 error、测试通过、符号表目录已生成、keystore 未入库(git check-ignore)。


八、自检清单 ​

  • [ ] 能解释版本名与 build number 的区别及各自的递增规则
  • [ ] 知道 Android keystore 的正确保管方式与"应用签名"的意义
  • [ ] 能打出带混淆与符号表的 release 包,并说出符号表为什么必须归档
  • [ ] 知道 iOS 上架的权限描述、隐私清单、隐私政策三项硬要求
  • [ ] 能说出分阶段放量的操作与监控指标,以及出事时的止血手段
  • [ ] 知道本项目没有 flavor,环境靠「三个入口 + --dart-define=HAIER_ENERGY_ENV」,以及 prod 为什么必须用 main_prod.dart
  • [ ] 知道出包只走 scripts/build.sh,以及脚本六步里"丢弃远程字体变更"这一步的意义
  • [ ] 知道 iOS --debug 实为 profile 包,因此判断用 !kReleaseMode 而不是 kDebugMode

导航:上一章 22 · 性能分析与优化实战 | 下一章 24 · 推送通知与深链接