把 KMP/Compose App 上架 F-Droid:完整流程与避坑指南

这两天把 TrendingAI(一个 KMP + Compose Multiplatform 的 Android app)提交上架了 F-Droid。最终的状态是:发一个 git tag,F-Droid 会自动发现新版本、从源码自建、用它的 key 签名分发,全程不需要我上传任何产物。

这篇按流程阶段拆解整个上架过程:改造客户端工程 → 编写 metadata → 提 MR 过 CI → 合并上线。每个阶段给出要做什么、怎么验证做对了,以及我在这一步实际撞过的坑(从 0.15.0 一路发到 0.15.3、被 9 道 CI 检查逐个打回换来的,大多文档里查不到)。如果你正带着某条 CI 报错搜到这里,文末有一张坑速查表可以直接对号入座。

全局:F-Droid 是怎么工作的

F-Droid 上架不是「上传 APK」,而是「提一份元数据,让它自己编译」:

fork gitlab.com/fdroid/fdroiddata
    ↓ 写 metadata/<包名>.yml(指向你的 GitHub repo + 某个 commit)
提 Merge Request
    ↓ F-Droid buildserver checkout 你的 tag 源码,跑 gradle 自建 + 用自己的 key 签名
9 个 CI job:lint / schema validation / rewritemeta / fdroid build / check apk / checkupdates ...
    ↓ 全绿 + maintainer review
合并 → 约 24–48h 进 F-Droid 主仓库

动手前要接受的三个前提:

阶段一:改造客户端工程

这一阶段的目标:让你的工程在「拿不到任何 CI 环境变量和 Secrets 的陌生机器」上,也能构建出一个 F-Droid 接受的 APK。

1. 新建无 updater 的构建变体

我新建了一个 fdroid product flavor,里面的 UpdateWrapper 是空实现——直接渲染内容,不挂自建更新逻辑。Play 渠道的 flavor 不受影响。

2. versionCode 改为从 git tag 推导

我们的 versionCode 原本等于 CI 的 github.run_number(GitHub Actions 注入的环境变量)。F-Droid buildserver 没有这个变量,versionCode 直接回落到 fallback 值 1——每个版本都是 1,F-Droid 根本无法识别版本递增。

修复是把版本号改成从 git tag 推导,任何环境从同一个 tag 都能算出同一个 versionCode:

// tag「MAJOR.MINOR.PATCH」→ versionCode = MAJOR*10000 + MINOR*100 + PATCH
// 0.15.3 → 1503,1.0.0 → 10000。业界标准的 6 位编码。
fun versionCodeFromTag(tag: String?): Int { /* 正则解析 + 越界护栏 */ }

// CI 注入的 VERSION_NAME 优先;缺失时(F-Droid 自建/本地)从 git 读
val tag = System.getenv("VERSION_NAME") ?: gitDescribe("--abbrev=0")
versionCode = versionCodeFromTag(tag)

两个细节:读 git 要用 providers.exec(Gradle configuration-cache 下 Runtime.exec 会破坏缓存);公式每段预留 2 位(minor/patch ≤ 99),加一道 require 护栏,越界直接构建失败,免得静默产出和隔壁版本撞车的 versionCode。

3. 签名配置要容忍「被剥离」

F-Droid 构建时会自动剥离 build.gradle 里的 signingConfigs 定义(日志里那行 Cleaned build.gradle.kts of keysigning configs),因为它要用自己的 key 重签。但它只删定义,不删 buildTypes 里的引用,于是:

// 剥离 signingConfigs 定义后,这行的 getByName 找不到 release → 抛异常
val releaseConfig = signingConfigs.getByName("release")

第一次 fdroid build 就挂在这里:SigningConfig 'release' not found。修复是把 getByName 换成 findByName(找不到返回 null 而不是抛错),回落到 debug 占位签名(反正 F-Droid 之后会用自己的 key 重签):

val releaseConfig = signingConfigs.findByName("release")
signingConfig = if (releaseConfig?.storeFile?.exists() == true) releaseConfig
                else signingConfigs.getByName("debug")

4. 去掉 APK 里的依赖元数据签名块

AGP 默认会在 release APK 里塞一个「依赖元数据」签名块——给 Google Play Console 做依赖洞察用的。F-Droid 的 APK scanner 不接受这个额外签名块,报 Found extra signing block 'Dependency metadata'(注意这不是第三方库的问题,第三方统计库标 AntiFeature 就行)。修复一行:

dependenciesInfo {
    includeInApk = false    // F-Droid 用 APK,不能有这个块
    includeInBundle = true  // AAB 保留,Play 那边的依赖洞察照常
}

✅ 本阶段验证:手动模拟 F-Droid 的剥离行为

发 tag 之前,在本地手动模拟剥离——临时把 signingConfigs 的 release 定义删掉,跑 assembleFdroidRelease,确认能回落 debug 签名构建成功。这步省不得:我有一个 tag 就是没做这个验证白发的。

阶段二:编写 metadata

这一阶段的目标:在 fork 的 fdroiddata 仓库里写 metadata/<包名>.yml,让 F-Droid 知道从哪取源码、怎么构建、怎么发现新版本。

几条实测出来的硬规则:

自动更新:动态 versionCode 走 HTTP 模式

让 F-Droid 自动发现新版本,最标准的做法是 UpdateCheckMode: Tags + UpdateCheckData 从 build.gradle 用正则静态读 versionCode/versionName:

UpdateCheckData: app/build.gradle.kts|versionCode = (\d+)|.|versionName = "([\d.]+)"

但这要求 build.gradle 里有字面的 versionCode = 数字。我们在阶段一刚把版本号改成从 git tag 动态推导,build.gradle 里压根没有字面数字,正则读出来是空——checkupdates 直接报 version=None

maintainer 给了思路:发布一个含版本信息的文件到 GitHub Release,用 UpdateCheckMode: HTTP 抓它。让 CI 发版时生成一个小文件传上 Release,F-Droid 从 releases/latest/download/... 这个稳定 URL 读版本号。既保住了 tag 推导,又能自动更新。注意 HTTP 模式的 AutoUpdateMode 要带 pattern——AutoUpdateMode: Version %v,告诉 F-Droid 新版本号对应哪个 git tag(Tags 模式天然知道,HTTP 模式得显式给,否则 lint 报 must have a pattern)。

rewritemeta 的格式规范

fdroid rewritemeta 会检查你的 yml 是否等于它用 ruamel.yaml dump 出来的规范格式,差一个字符都不过。我那行长长的 UpdateCheckData 反复打回好几轮:

  1. 先要求把超长的值换行缩进(单行太长);我换了,还不过;
  2. 我一开始用的是 version.json,正则里带 JSON 的双引号("versionCode":\s*(\d+))。ruamel 折叠这种含引号的超长无空格值时格式很诡异,我怎么调换行和缩进都对不上;
  3. 最后去 fdroiddata 仓库里扒了一个机制完全相同(HTTP UpdateCheck + 发布 version 文件)的已上架 app,对着它的 yml 逐字节比,才找到关键:
# 1) 值改用纯文本 version_code.txt,正则无引号
# 2) UpdateCheckData: 冒号后留一个【尾随空格】再换行
# 3) 值缩进 2 空格
UpdateCheckData: 
  https://github.com/.../releases/latest/download/version_code.txt|versionCode=(\d+)|.|versionName=([\d.]+)

那个冒号后的尾随空格是 ruamel dump 的产物,少了就判不一致。纯文本无引号则彻底绕开了 JSON 引号的折叠问题。

✅ 本阶段验证:抄一个已上架的同类 app

F-Droid 文档对这些边角格式(UpdateCheckData 折叠、AutoUpdateMode pattern、签名块)几乎没讲清,但 fdroiddata/metadata/ 里有几万个真实、已通过审核的例子。写 yml 时先找一个机制相同的已上架 app 逐条抄格式,比对着文档猜快十倍——这是整个上架过程里最有效的一条经验。

阶段三:提 MR,过 9 道 CI

metadata 写好后提 Merge Request,GitLab CI 会跑 9 个 job:lint、schema validation、rewritemeta、fdroid build、check apk、checkupdates 等。前面两个阶段做对了,这里就是收获季;做漏了,就是逐条打回的开始。

两个影响迭代效率的实操细节:

阶段四:合并上线之后

CI 全绿后等 maintainer review,合并后约 24–48 小时进入 F-Droid 主仓库,用户就能搜到了。

之后的日常发版就是闭环自动的:你发 tag → 你的 CI 构建并把 version_code.txt 传上 GitHub Release → F-Droid 的 checkupdates 定期抓这个文件发现新版本 → buildserver 自动 checkout 对应 tag 源码自建、签名、分发。不需要再碰 fdroiddata。

发版前 Checklist

上架前把这几条过一遍,能省掉大部分 CI 往返:

坑速查表

CI 报错 / 症状根因修复章节
每个版本 versionCode 都是 1版本号依赖 CI 环境变量从 git tag 推导阶段一·2
SigningConfig 'release' not foundF-Droid 剥离 signingConfigs 定义但留下引用getByNamefindByName 回落 debug阶段一·3
Found extra signing block 'Dependency metadata'AGP 默认塞的 Play 依赖元数据块dependenciesInfo.includeInApk = false阶段一·4
'AutoUpdateMode' is a required propertyschema 必填字段被删老实写上阶段二
checkupdatesversion=NoneUpdateCheckData 正则读不到动态 versionCodeHTTP 模式 + Release 发版本文件阶段二
rewritemeta 反复判格式不一致yml 不等于 ruamel dump 的规范格式纯文本值 + 冒号后尾随空格 + 2 空格缩进阶段二

最后

回头看,F-Droid 上架真正难的不是「写一个 yml」,而是它从源码自建 + 严格 CI 的模式,会把你工程里所有「只在自己 CI 里才成立的假设」全部暴露出来:版本号从哪来、签名谁来签、APK 里有没有夹带私货。把这些假设逐个拆掉之后,你得到的是一条完全可复现的发布链路——这大概也是 F-Droid 模式本身想让你做到的事。