把 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 都拿不到。下面一半的坑都源于此。
- 必须有一个不含自建 updater 的构建变体。F-Droid 政策禁止 app 自行下载可执行文件更新(更新由 F-Droid 客户端统一管理)。
- 本地没 Docker 就只能远程验证。完整跑 F-Droid 构建要它的 Docker buildserver,我没本地装,直接提 MR 让 GitLab CI 跑——代价是每个问题都得「改 → 发 tag/commit → 等 CI 几分钟 → 看日志」,反馈环很长。所以下文每个阶段都标了能在本地先验证的部分,能省一次远程往返就省一次。
阶段一:改造客户端工程
这一阶段的目标:让你的工程在「拿不到任何 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 知道从哪取源码、怎么构建、怎么发现新版本。
几条实测出来的硬规则:
- commit 用完整 hash,不用 tag。maintainer 明确要求:tag 可以被移动,commit hash 不可变、可复现,对「从源码自建」是硬要求。
AutoUpdateMode是 schema 必填字段。我一度为了配合「手动维护版本」把它整个删了,schema validation 直接报'AutoUpdateMode' is a required property。不能省。- AntiFeatures 如实标,别藏。用了匿名统计就标
Tracking,依赖闭源后端就标NonFreeNet。我一度想不标 NonFreeNet,但查了个情况几乎一样的已上架 app,人家标了——主动标更诚实,也过审更顺。
自动更新:动态 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 反复打回好几轮:
- 先要求把超长的值换行缩进(单行太长);我换了,还不过;
- 我一开始用的是
version.json,正则里带 JSON 的双引号("versionCode":\s*(\d+))。ruamel 折叠这种含引号的超长无空格值时格式很诡异,我怎么调换行和缩进都对不上; - 最后去 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 等。前面两个阶段做对了,这里就是收获季;做漏了,就是逐条打回的开始。
两个影响迭代效率的实操细节:
- job 日志在你的 fork 里,API 拿不到。MR 的 pipeline 实际跑在你 fork 的仓库,job trace 用 GitLab API 拉会 401,得在已登录的浏览器里看。
- 先发一个版本,让 CI 把所有问题一次性暴露出来,而不是猜一个改一个。每轮修复都要「改 → 发新 tag 或改 MR → 等 CI 几分钟 → 看日志」,把问题攒齐了一起修,能少跑好几轮。
阶段四:合并上线之后
CI 全绿后等 maintainer review,合并后约 24–48 小时进入 F-Droid 主仓库,用户就能搜到了。
之后的日常发版就是闭环自动的:你发 tag → 你的 CI 构建并把 version_code.txt 传上 GitHub Release → F-Droid 的 checkupdates 定期抓这个文件发现新版本 → buildserver 自动 checkout 对应 tag 源码自建、签名、分发。不需要再碰 fdroiddata。
发版前 Checklist
上架前把这几条过一遍,能省掉大部分 CI 往返:
- 有一个不含自建 updater 的构建变体(flavor)
- versionCode/versionName 不依赖任何 CI 环境变量,从 tag 可复现推导
- 本地模拟剥离 signingConfigs 后,
assembleFdroidRelease能构建成功 -
dependenciesInfo.includeInApk = false - metadata 的 commit 字段用完整 hash
-
AutoUpdateMode已填(HTTP 模式带Version %vpattern) - AntiFeatures 如实标注(Tracking / NonFreeNet)
- yml 格式对照一个机制相同的已上架 app 逐条核过
坑速查表
| CI 报错 / 症状 | 根因 | 修复 | 章节 |
|---|---|---|---|
| 每个版本 versionCode 都是 1 | 版本号依赖 CI 环境变量 | 从 git tag 推导 | 阶段一·2 |
SigningConfig 'release' not found | F-Droid 剥离 signingConfigs 定义但留下引用 | getByName → findByName 回落 debug | 阶段一·3 |
Found extra signing block 'Dependency metadata' | AGP 默认塞的 Play 依赖元数据块 | dependenciesInfo.includeInApk = false | 阶段一·4 |
'AutoUpdateMode' is a required property | schema 必填字段被删 | 老实写上 | 阶段二 |
checkupdates 报 version=None | UpdateCheckData 正则读不到动态 versionCode | HTTP 模式 + Release 发版本文件 | 阶段二 |
| rewritemeta 反复判格式不一致 | yml 不等于 ruamel dump 的规范格式 | 纯文本值 + 冒号后尾随空格 + 2 空格缩进 | 阶段二 |
最后
回头看,F-Droid 上架真正难的不是「写一个 yml」,而是它从源码自建 + 严格 CI 的模式,会把你工程里所有「只在自己 CI 里才成立的假设」全部暴露出来:版本号从哪来、签名谁来签、APK 里有没有夹带私货。把这些假设逐个拆掉之后,你得到的是一条完全可复现的发布链路——这大概也是 F-Droid 模式本身想让你做到的事。