...
Back

在 Google Play 之外发布安卓应用,早在你动手之前就定了

安卓应用能不能通过 F-Droid 或直接下载 APK 分发,很早就被两件事决定了:签名密钥在谁手里,以及依赖树里有没有闭源二进制。

在 Google Play 之外发布安卓应用,早在你动手之前就定了

在 Google Play 之外发布安卓应用,早在你动手之前就定了

9 月 24 日,F-Droid 发布了 F-Droid 2.0。按官方的说法,这是对官方客户端的一次彻底重新设计,也是十年来最大的一次应用更新:关键组件用 Kotlin 和 Jetpack Compose 重写,在经历 14 个测试版之后,将在未来几周内陆续推送给用户。

对发布安卓应用的人来说,文中有两处细节值得留意。其一,F-Droid 说,主要得益于欧盟《数字市场法》(DMA)和各地反垄断行动带来的压力,Android 现在为所有应用商店提供了更顺畅、更自动化的安装与更新方式,新版的统一安装器正是建立在这之上。其二,用户现在可以按"反特性"(anti-features)筛选应用,文中举的例子恰好是排除那些依赖非自由网络服务的应用。

公告正文上方还挂着一条横幅:"F-Droid is under threat. Google is changing the way you install apps on your device. We need your help."(F-Droid 正受到威胁。Google 正在改变你在设备上安装应用的方式。我们需要你的帮助。)横幅只说了这么多,我们也不替它补充。

不过它让我们对自己在 Google Play 上架的两个安卓应用问了一个很具体的问题。CoreCall 是一个拨号应用,通过客户自己的 Twilio 或 Telnyx 账号打电话;CoreTalk 是一个面对面翻译应用,在一部共用的手机上完成。如果要把其中任何一个放到 F-Droid,或者在下载页上直接提供 APK,做得到吗?

答案基本是:做不干净。而且两个原因都不是发一个新版本能修好的 —— 它们早就定了,只是大多数团队从不回头再看。


第一个决定:签名密钥在谁手里

Android 只有在签名证书一致时,才会把新 APK 当作已安装应用的更新装上去。换渠道的一切麻烦都从这里来。

我们两个应用的正式版都用上传密钥(upload key)签名,而应用签名密钥由 Google Play 持有,也就是 Play App Signing。于是,我们为其他渠道自己签出来的任何 APK,签名都和 Play 版不同,Android 不会让它覆盖安装在 Play 装的那一份上。用户只能先卸载,而卸载会清掉应用的本地数据。验证这一点只要一分钟:

# 你自己签的 APK 用的是哪张证书……
apksigner verify --print-certs app-release.apk | grep SHA-256
# ……再和 Play Console 里显示的应用签名证书指纹对一下。
 
# 或者直接覆盖安装到从 Play 装的那一份上:
adb install -r app-release.apk
# 会失败,报 INSTALL_FAILED_UPDATE_INCOMPATIBLE

F-Droid 也绕不开这件事。它通常从源码构建应用,再用自己的密钥签名 —— 这是第三种签名,跟 Play 的和我们的都对不上。例外是可复现构建:当 F-Droid 从你的源码构建出的结果与你签名的 APK 一致时,它可以直接分发你这份开发者签名的 APK。走这条路的前提是开发者自己握着密钥。而要让 Play 用户不卸载就换到 F-Droid 的版本,开发者手里那把密钥还必须正是 Play 用来签名的那一把。

所以对我们这两个应用来说,换渠道永远意味着重装和丢数据。这件事在应用接入 Play App Signing 的那一刻就定下了,之后发的任何一个版本都改变不了它。它不是哪次构建配置写错了,也不是哪个版本漏了一步,而是一个早期的、一次性的选择 —— 这类选择在当时往往看起来只和 Play 有关。


第二个决定:依赖树里有什么

F-Droid 从源码构建,它的主仓库要求应用本身及其依赖都是自由开源软件。依赖树里任何一处出现闭源 SDK,应用就进不去,你自己的代码再开放也没用。

我们的两个应用正好落在这条线的两侧。

CoreTalk 依赖的是 AndroidX 系列库(core、appcompat、lifecycle、activity-compose、security-crypto,以及带 Material 3 的 Jetpack Compose)和 OkHttp,全部开源。单看这一条标准,它的依赖清单里没有拦路的东西。

CoreCall 用了 OkHttp 和 Telnyx 的 WebRTC 库(3.7.1),但它还依赖 Twilio 的 Android Voice SDK,版本 6.10.4。Twilio 以编译好的二进制形式、按它自己的条款分发这个 SDK,并不开源。光这一个依赖,就足以让现在这样构建出来的 CoreCall 进不了 F-Droid 主仓库。而且这不是一行能随手删掉的依赖:客户带着 Twilio 账号来用的时候,电话正是靠它打出去的。

换句话说,两个应用在签名上的处境一模一样,在依赖上却完全不同:CoreTalk 这一关没有问题,CoreCall 则多了一道硬门槛。

检查要看解析后的实际类路径,而不是你印象里声明过的那几行,因为传递依赖同样算数:

./gradlew :app:dependencies --configuration releaseRuntimeClasspath

不算拦路的那一项:如实说明网络依赖

两个应用在设计上都依赖网络服务。CoreCall 连接的是用户自带的通话服务商账号;CoreTalk 的翻译服务需要访问密钥,从商店下载后没有密钥,只能进入演示模式(Demo Mode)。

这本身并不构成排除理由。F-Droid 会给应用标注"反特性"(Anti-Features),比如依赖非自由的网络服务,而 F-Droid 2.0 正好让用户可以按这一项筛选。我们认为这个思路适用于所有渠道,而不只是 F-Droid:应用的介绍页应该直白地写清楚,它要依赖什么才能用,缺了会怎样。"需要你自己的 Twilio 或 Telnyx 账号"、"输入访问密钥之前只有演示模式",这些话应该在用户安装之前就读到,而不是装完才发现。筛掉这类应用的用户不会损失什么;真正会失望的,是那些不知情就装上的人。


如果重新开始,我们会怎么做

先说清楚,这不是要上架 F-Droid 的预告。这是这次梳理告诉我们的:把这条路留着,项目刚开始时成本很低,越往后越贵。

保留一个不含专有 SDK 的构建变体。 用一个 product flavor 就够,前提是 SDK 的类型永远不越出这个变体:

android {
    flavorDimensions += "distribution"
    productFlavors {
        create("play") { dimension = "distribution" }
        create("foss") { dimension = "distribution" }
    }
}
 
dependencies {
    implementation(libs.okhttp)
    "playImplementation"(libs.twilio.voice) // 闭源二进制
}

共享代码只面向 src/main 里的接口编程,基于 SDK 的实现只放在 src/play。一旦有厂商类型漏进共享代码,foss 变体就会编译失败 —— 这个失败本身就是检验。对 CoreCall 来说,这样的变体会失去 Twilio 支持,而剩下的部分仍然要逐个依赖去核查。

有意识地决定密钥由谁保管。 Play App Signing 允许你让 Google 生成应用签名密钥,也允许你提供自己的。如果将来想让别的渠道也能更新同一份安装,你就得持有 Play 版所用签名密钥的副本,同时承担起保管它的责任。两种选择都可能是对的,错的是在没想清楚的时候按默认选项走过去。

让正式版构建可复现。 这是 F-Droid 分发你的签名、而不是它自己签名的唯一途径。落到实处就是:锁定每一个依赖的版本,把 Gradle wrapper 提交进仓库,别让时间戳和机器相关的值进入构建产物,并记下发布时用的 JDK 版本。然后从一份干净的检出重新构建,确认两次产出的内容一致。


一份简短的检查清单

  • 用户手里装着的那个版本是哪把密钥签的?你手上有没有它的副本?
  • releaseRuntimeClasspath 里有没有只以编译好的二进制、按非开源条款分发的东西?
  • 去掉它的变体,今天能编译通过吗?
  • 你的应用介绍有没有写明依赖哪种网络服务,以及没有它时还能用什么?
  • 同一个提交的两次干净构建,除签名外产出的 APK 内容是否一致?

在第一次发布之前回答这些问题,渠道这扇门就一直开着。等到之后再回答,你可能会像我们一样发现,它早就关上了。