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:
version: 1.4.2+38
# │ │ │ └─ build number(versionCode / CFBundleVersion):只增不减的整数
# │ │ └──── patch:修 bug
# │ └────── minor:加功能(向下兼容)
# └──────── major:不兼容变更| 平台 | 字段来源 | 要求 |
|---|---|---|
| Android | versionCode = build number;versionName = 版本名 | versionCode 必须严格递增,否则商店拒绝 |
| iOS | CFBundleShortVersionString = 版本名;CFBundleVersion = build number | 每次提审 build number 必须比上一次大 |
规则:build number 只增不减、不复用;提审被拒后修复重提,也要 +1。
二、环境隔离:flavor 与 dart-define
| 方式 | 用途 | 场景 |
|---|---|---|
--dart-define | 编译期注入键值对 | API 地址、Sentry DSN、开关(推荐,够用且简单) |
| flavor(scheme/productFlavors) | 完整的多套包名/图标/配置 | 需要同时安装"正式版+测试版"、多品牌包 |
# 编译期注入(不要硬编码任何密钥)
fvm flutter build apk --release \
--dart-define=API_BASE=https://api.example.com \
--dart-define=SENTRY_DSN=https://xxx@yyy/1const 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),环境切换靠两件事叠加:
# 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):
// 入口点强制 > SharedPreferences > dart-define > 默认 dev
final appEnv = forcedEnvironment ?? await EnvironmentConfig.getEnvironment();
RuntimeEnvironment.current = appEnv;lib/main_prod.dart 全文只有这么点东西:
void main() => appMain(
forcedEnvironment: Environment.prod,
);只要 forcedEnvironment 非空,?? 就直接短路,SharedPreferences 和 dart-define 全部失效——这正是正式包要的「锁死」。
| 环境 | 入口 | 锁定方式 |
|---|---|---|
| dev | lib/main.dart | 不锁定,走运行时解析 |
| test | lib/main_test.dart | forcedEnvironment: Environment.test |
| prod | lib/main_prod.dart | forcedEnvironment: Environment.prod |
AGENTS.md:20-26 的硬性要求:出包统一走 scripts/build.sh,不要直接执行 fvm flutter build—— 因为 dart-define、入口选择、precheck、产物重命名这四件事,手敲命令必然会漏。
三、Android 打包
3.1 签名配置(只做一次, keystore 丢了就完蛋)
keytool -genkey -v -keystore ~/upload-keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 -alias uploadandroid/key.properties # ⚠️ 加入 .gitignore,绝不入库
storePassword=***
keyPassword=***
keyAlias=upload
storeFile=/abs/path/upload-keystore.jksandroid/app/build.gradle:
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 出包
# 方式 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
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 |
| 2 | precheck.sh --quick | 默认不阻塞:PRECHECK_BLOCK=true 才因检查失败中断。CI 里应显式设为 true |
| 3 | flutter clean + pub get(iOS 额外 pod install,且先删 Pods 与 Podfile.lock) | 每次全量 clean,牺牲速度换可复现 |
| 4 | flutter build ... --dart-define=HAIER_ENERGY_ENV=... -t <入口> | 见 2.1 |
| 5 | git checkout -- assets/fonts/iconfont/ | 构建期拉取的远程字体属于临时产物,必须丢弃;i18n JSON 保留作为本地兜底 |
| 6 | 产物重命名 haierenergy-<config>-v<version>+<build>.apk,清理残留 app-*.apk | 版本号从 pubspec.yaml 读,命名自带环境,避免发错包 |
签名与 ARMS 配置在 android/app/build.gradle:
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'
}
}⚠️ 两个出包前必查:
release签名降级到 debug 是静默的——key.properties缺失时不会报错,只会打出一个用 debug 签名的包。正式出包前确认keystorePropertiesFile.exists()为真。minSdk = 24是 ARMS 的硬要求,不能为了兼容更低版本而下调;调整前先看minSdk注释里的原因(build.gradle:76)。
3.3 混淆的保留规则
开了混淆后,反射、JNI、Platform Channel 相关代码可能失效(对照 21 篇"只在 Release 包出错"的坑):
# 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 出包
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 篇),发布脚本建议包含:
#!/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 · 推送通知与深链接