Flutter 不买 Mac 也能发安卓:写字台客户端的 Google Play 自动发布链路

xiezitai · 2026-10-26 22:36 · 6 次阅读

写字台(xiezitai)是自托管的写作与博客平台,移动端用 Flutter 一份代码同时出 iOS 和 Android。这篇文章只讲 Android 这一侧:没有 Mac、不装 Android Studio、只靠 GitHub 的 Linux runner,怎么把 .aab 稳定构建出来、签好名、自动送到 Google Play。

不讲概念,只讲踩过的地方。


一、先分清两件事:「编译通过」和「能上架」

很多人第一次做 Android 发布,会把这两件事混成一件事,结果是:CI 明明是绿的,产物却传不上 Play。

所以写字台的 Android 侧刻意做成两条独立流水线,而不是一条带条件判断的臃肿流水线:

流水线 文件 触发 签名 目的
验证构建 android.yml push main / PR / 手动 debug key 兜底 证明代码能编译、analyze 和 test 能过
签名发布 android-release.yml 打 v* tag / 手动 上传密钥 产出可上架的 AAB,并推 Play 内部测试轨道

这个拆分的直接收益是:日常提交不需要碰任何密钥。发布流水线才还原密钥,日常 PR 永远不会因为「没配 Secret」而红。

push / PR ──► android.yml ──► analyze + test + release AAB(debug 签名) ──► artifact

tag v* / 手动 ──► android-release.yml
                    ├─ 还原 keystore(来自 Secrets)
                    ├─ release AAB(上传密钥签名)
                    ├─ release APK(split-per-abi,侧载分发用)
                    ├─ 上传 Google Play internal 轨道
                    └─ 删除 keystore(if: always)

二、让构建「没有密钥也能过」

关键在于 android/app/build.gradle 里的一小段分支逻辑:没有 key.properties 就自动退回 debug 签名,而不是直接抛错。

def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
def useReleaseSigning = false
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
    useReleaseSigning = true
}

// ...
buildTypes {
    release {
        signingConfig = useReleaseSigning ? signingConfigs.release : signingConfigs.debug
        minifyEnabled false
        shrinkResources false
    }
}

这几行的价值在上线节奏上体现得很明显:新同事 clone 仓库、flutter build appbundle --release,第一次就能跑通;想看真实签名产物,才需要配密钥。不存在「必须先找运维要密钥才能编译」的阻塞。

代价是必须自己记住一句话:debug 签名的 release 包,永远不能上传 Play。这句话我直接写进了 workflow 的头部注释里,避免半年后有人图省事拿验证产物去发布。


三、密钥:不落盘、不进仓库、用完即删

这是整篇文章里我最想强调的部分。Android 的签名密钥泄漏后果比 API Key 严重得多——拿到 .jks 和密码的人可以给你的应用签任何内容,而 Play 只认这一个证书。

铁律一:仓库里只存「怎么取」,不存「取到什么」

四个核心 Secret:

Secret 名 内容 用途
KEYSTORE_BASE64 上传密钥库的完整 base64 还原成 .jks 文件
KEYSTORE_PASSWORD store 密码 解库
KEY_ALIAS 密钥别名 指定用哪把钥匙
KEY_PASSWORD key 密码 解锁

发布流水线在 runner 上把它们还原成临时文件:

- name: 还原签名密钥(Keystore + key.properties)
  env:
    KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
    KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
    KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
    KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
  run: |
    set -euo pipefail
    printf '%s' "$KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
    printf 'storePassword=%s\nkeyPassword=%s\nkeyAlias=%s\nstoreFile=upload-keystore.jks\n' \
      "$KEYSTORE_PASSWORD" "$KEY_PASSWORD" "$KEY_ALIAS" > android/key.properties

注意这里是 printf 而不是 echo,且密码通过 env 注入而非命令参数——命令参数会出现在 runner 的进程列表和调试日志里。

铁律二:.gitignore 是最后一道闸,不是唯一一道

android/key.properties
**/*.jks

光靠 gitignore 不够,因为它只拦「误提交」,不拦「误打包」。真正兜底的是第三步。

铁律三:用完必须删,失败也要删

- name: 清理密钥
  if: always()
  run: rm -f android/app/upload-keystore.jks android/key.properties

if: always() 是这里唯一不能省的东西。如果构建中途失败、后面的清理步骤被跳过,密钥文件就会留在 runner 的工作目录里——虽然 GitHub 托管的 runner 是一次性的,但如果哪天换成 self-hosted runner,这就是一个长期后门。

顺手加的一道自检

缺 Secret 时最难受的不是报错,而是报错看不懂。所以还原密钥前先做一次显式检查:

if [ -z "$KEYSTORE_BASE64" ] || [ -z "$KEY_ALIAS" ] || [ -z "$KEYSTORE_PASSWORD" ] || [ -z "$KEY_PASSWORD" ]; then
  echo "::error::缺少签名 Secret,需要 KEYSTORE_BASE64 / KEYSTORE_PASSWORD / KEY_ALIAS / KEY_PASSWORD"
  exit 1
fi

一句话换掉半小时排查,非常划算。


四、踩坑实录:前六次全红,第七次才过

从 0 到第一次成功,发布流水线一共跑了 7 次,前 6 次全部失败。这些坑很典型,基本每个 Flutter 项目都会撞上至少一个。

坑 1:Flutter 升级了,Gradle 插件没跟上

现象很唬人:job 直接 failure,连一个 step 都没跑起来,日志里什么都没有。

根因不在 Flutter 代码,在版本矩阵。Flutter 3.47.6 对 Android Gradle Plugin(AGP)、Kotlin 插件、Gradle wrapper 有最低版本要求,而这三者是绑在一起的——升一个必须升另外两个,否则 Gradle 在配置阶段就崩。

当时的分三步修法:

  1. Gradle wrapper 升到 8.14;
  2. AGP 与 Kotlin 插件升到与 Flutter 3.47.6 兼容的版本;
  3. Kotlin 插件版本对齐 Flutter 的最低要求(这一步最容易漏,因为前两步之后报错会变)。

教训:本地 Flutter 版本和 CI 不一致时,先怀疑 Gradle 插件矩阵,而不是代码。写字台现在所有 workflow 都把 FLUTTER_VERSION 钉死在环境变量里,两侧必须一致——版本漂移带来的排查成本远超收益。

坑 2:startup_failure 和 failure 是两种病

有一个 run 的结论是 startup_failure,不是 failure。它意味着 workflow 文件本身没解析通过——比如引用了一个不存在的 secrets.* 表达式、或者 YAML 结构有问题。

这种情况不会产生 job,所以去「日志」里找原因是白找,得回到 workflow 文件本身看。

坑 3:自适应图标引用了不存在的资源(最阴的一个)

这条值得单独说,因为它是静默到编译期才炸的典型。

现象:debug 构建正常,flutter build appbundle --release 必挂,报的是 aapt2 链接错误,指向 mipmap-anydpi-v26/ic_launcher.xml。

根因:那个 xml 里声明了前景层 @mipmap/ic_launcher_foreground,但这张图从来就没进过仓库——当时只提交了 xml 和背景色定义。debug 构建不会严格校验这条引用,release 链接期才会。

修复不是「随便塞张图」,而是想清楚自适应图标的机制:Android 8.0 起,系统会把「前景层 + 背景层」按启动器自己的形状动态遮罩裁切。如果直接把一张已经烤好圆角、带渐变背景的成品方图当图使用,启动器会再套一层自己的遮罩,结果是白底里再套一个白圆角方块、渐变被切成方块——各家 ROM 表现还不一样。

正解是分成两层,并把前景的 inset 显式设为 0:

flutter_launcher_icons:
  image_path: "assets/icon/app_icon.png"
  android: true
  adaptive_icon_background: "assets/icon/app_icon_background.png"
  adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"
  adaptive_icon_foreground_inset: 0

adaptive_icon_foreground_inset 默认是 16,会把前景再缩到 68%,卡片从画布 59% 掉到 40%,跟 iOS 图标完全对不上。这个默认值坑了我一次,第一次生成完看 xml 才发现。

顺带一个副作用要留意:重跑生成器会同时重写 iOS 的 AppIcon。同源同尺寸的情况下字节一致、git 无 diff,但换源图时一定要 diff 确认,别误伤 iOS 那条线。

坑 4:别把「有产物」当成「做完了」

一开始我在 workflow 里只写 flutter build appbundle,绿了就以为完事。实际上还缺三样东西,任何一样缺了都等于没做完:


五、送进 Google Play:服务账号与轨道

构建产物有了,剩下的是把它交出去。写字台用的是 r0adkll/upload-google-play 这个 action,直接推到 internal 轨道:

- name: 上传到 Google Play(内部测试轨道)
  if: ${{ steps.check_play_secret.outputs.has_play_store_secret == 'true' }}
  uses: r0adkll/upload-google-play@v1
  with:
    serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
    packageName: cn.xiezitai.app
    releaseFiles: build/app/outputs/bundle/release/*.aab
    track: internal
    status: completed

前面那个 if 是刻意加的:服务账号 JSON 是可选 Secret。没配的人照样能拿到签名产物手动上传,不会因为少一个 Secret 就整条流水线失败。这类「可选依赖」一定要在 workflow 里显式表达,否则模板给别人用时会有大量无效报障。

首次上传前必须做的两件事

很多人卡在第一次上传,报的是权限或证书错误,其实问题在准备工作:

  1. 在 Play Console 里先把 App 建好。CI 不能帮你创建应用条目,只能往已存在的应用里传包。
  2. 把上传密钥的证书登记到「App 签名」。导出命令:
keytool -export -rfc -file upload-certificate.pem \
  -keystore upload-keystore.jks -alias upload

服务账号则走:Play Console → 用户和权限 → 服务账号 → 下载 JSON,把整段 JSON 存进 PLAY_STORE_SERVICE_ACCOUNT_JSON。这段 JSON 里含 private_key,属于最高级别机密,只放 Secret,不要贴进任何文档、issue 或聊天窗口。

轨道策略

internal(内部测试,最快,最多 100 人)
   └─► closed(封闭测试,需要审核)
          └─► open(公开测试)
                 └─► production(正式)

写字台现在的自动化只做到 internal,后面的轨道故意留给人工点——自动发布到正式轨道是个危险动作,尤其当 versionCode 由 github.run_number 自动递增时,一次误触发就会占用一个版本号且不可回退。


六、一份可以直接抄的检查清单

发布前逐条过一遍,能过滤掉 90% 的翻车:

检查项 为什么
versionCode 是否递增 Play 不接受重复或回退的 versionCode
签名是不是上传密钥 apksigner verify --print-certs 看一眼
applicationId 有没有改过 改了就变成新应用,老用户永远收不到更新
密钥文件是否已清理 本地构建后别忘了删 key.properties 和 .jks
有没有提交过密钥 git log -p 搜 storePassword,历史里提交过就得立刻轮换
Play Console 里证书是否登记 换过密钥就必须重新登记
目标 API 级别是否满足当年要求 Play 每年都会抬门槛

最后一行那个「历史里提交过就得轮换」要展开一句:密钥一旦进过 git 历史,只删文件是没用的,必须认为它已经泄漏,重新生成密钥并提交 Play 支持重置上传密钥。这是很多人踩过的最贵的坑。


七、这套东西到底值不值

算一笔账。手工打包的流程是:开 Android Studio → 等 Gradle 同步 → 填签名密码 → build → 找产物 → 手动上传 Play。一次 15 到 30 分钟,还要求手上有一台配好环境的机器。

流水线化之后,打一个 tag 就去干别的,回来产物和 Play 内部测试版本都已经就位。更重要的是可复现:三个月后要发一个 hotfix,不需要回忆「当初那台电脑上装的是哪个 JDK」。

真正的收益不在省下的那几十分钟,而在于发布这件事从「依赖某个人和某台机器」变成了「依赖一段可 review 的代码」。密钥怎么用、什么时候删、失败会不会漏删,全在 diff 里看得见。

这才是把发布流程写进仓库的意义。

评论

还没有评论,登录后来说两句。