技术10/9/2026·约 4 分钟

测试用的 APK 并不是我们发出去的那个

所有测试套件全绿,出厂 APK 却一进 3D 场景就崩。R8 把一个 JNI 按名字解析的类改了名,而每一条测试跑的都是 R8 从没碰过的那份构建。

一条任何测试都够不到的崩溃

我们在给 Sigma 的 Android 端 3D 场景写像素测试,于是把出厂的产品 APK 装上模拟器去对照。它一进 SigmaActivity 就死了:

java.lang.UnsatisfiedLinkError: No implementation found for …

覆盖那段代码的所有套件都是绿的。修法最后是九行,其中大部分还是注释。真正有意思的地方在于:为什么没有任何一条测试能看见它。

名字就是 ABI

Sigma 的 Android 层用最朴素的 JNI 把 Kotlin 连到 C。Kotlin 里声明为 external 的方法在运行时按符号名解析,而这个符号名是由类名与方法名拼出来的:

JNIEXPORT jlong JNICALL
Java_com_aio_sigma_SigmaNative_sceneLoadLegacyWithKv(JNIEnv *env, jclass clazz, …)

R8(Android 的裁剪与混淆工具)默认要改名改方法。release 构建里,SigmaNative 被压成了 hr,sceneLoadLegacyWithKv 被压成了 F。运行时于是去找 hr.F 的实现,而从来没有一个符号叫这个名字。native 代码一直都在 APK 里,它只是不再有一个谁还在找的名字。

所以对这种绑定来说,名字不是装饰,而是两种语言之间的链接契约;一个会重写名字的混淆器,改的就是这个 app 的 ABI。

三条绿灯套件,三个不同的程序

每一层测试都是真的,而且每一层绿得都有道理。

  • 宿主 JVM 单测把 Kotlin 编译出来跑在桌面 JVM 上。native 库在那里从不被加载,所以从来没有任何一个 JNI 符号被解析过。
  • native 测试把 C 侧编译到宿主机上,对着假 JNI 环境与假引擎跑。它们能证明绑定的参数编解码是对的,无法证明 JVM 找得到它们。
  • 仪器测试确实跑在真模拟器上、用真的 native 库。但它跑的是自 instrument 的单 APK——由一条从不调用 R8 的规则、用同一份源码构建出来。它的 SigmaNative 还留着每一个原名。

这三个都不是出厂的那个 app。每一个都是我们对一个并不发布的程序的忠实测试,而缺陷只存在于我们真正发布的那个里。

测试规则为什么挑了那份错的构建

仪器测试规则有两种跑法:装上真正的产品 APK 做黑盒测试,或者构建一个测试与应用同进程的单 APK 做自 instrument。它从产品元数据里取一个默认值:

if ship_artifact == None:
    ship_artifact = bool(proguard_specs)

推理本身听着站得住:产品自带了 R8 规则,就去测 R8 的产物;没有的话,自 instrument 的 APK 是等价的。demo 产品声明 proguard_specs = [],于是它拿到了自 instrument 那份。

这行写下来的时候(7 月),它是真的:当时 android_product 只有在产品传了自己的规则时才跑 R8。

9 月,发布编排改了这件事。每个 release 构建都必须产出 R8 mapping——崩溃归因流水线要用它来符号化。于是 android_product 拆掉了 if proguard_specs: 这道门,无条件前置一份基础规则文件。从那天起,构建方对「这个产品过不过 R8」的答案变成了总是,而测试规则的答案仍然是只有自带规则才算。

两个文件在回答同一个问题,而它们的答案已经不再一致。分歧发生时没有任何东西报错,因为测试规则从来没有问过构建方任何东西。它问的是一个代理——产品自己声明的列表——而这个代理准确地待了那么久,长到没有人去改动它所代指的那个东西。

修法,以及它落在哪儿

keep 规则没有落到 demo 应用里,而是落到了 android_product 为每个 release 构建都前置的那份基础文件里:

-keepclasseswithmembernames,includedescriptorclasses class * {
    native <methods>;
}

现在任何声明了 native 方法的类都会保住自己的名字,这些方法也一样。没有任何产品需要记得它,明年新加的产品会在自己都不知道的情况下继承它。

这条规则并不新鲜。它本来就是 Android 默认配置的一部分,这也是大多数 Gradle 工程不会遇到这次崩溃的原因:他们继承到了它。而 Bazel 构建只应用你亲手递给它的规则,我们那份基础文件是凭空写起来的。它覆盖了 Kotlin metadata、注解与泛型签名,唯独漏了这条。自己重写一份平台默认值,就意味着你要接管它以前做的每一件事,包括那些没人列出来的部分。

这条 keep 规则没有关上的那扇门

规则关上了这次崩溃,但没有关上掩盖它的那个缺口。

新的像素测试仍然是自 instrument 的,而且这是有意为之:它要从自己的 Window 上取像素,而对着单独安装的 APK 做黑盒测试做不到这件事。有些测试必须在进程内部,而这样的测试就跑不了出厂那份二进制。

答案不是放弃进程内测试,而是给它配一个跑在出厂产物上的黑盒冒烟,至少每个 native 边界要跨一次。Rifff 的 Android 测试已经是这样分开的——它的信息流条目都是 Sigma 程序:一个是跑在 R8 产物上的 two-APK 冒烟,一个是进程内的运行时套件,而那个套件的 BUILD 文件白纸黑字写着,它「不声称覆盖 R8 产物」。那句话是对的直觉:它点名了这个套件有权代表哪个二进制说话。

一个测试对它跑过的那个程序负责。数一次绿灯之前,先看那是哪个程序。

小结

一次绿灯只对它构建出的那份二进制说话。这里有三条套件是绿的,而它们跑的东西我们都不发布:一台没有 native 库的 JVM、宿主机上的假 JNI,以及一个 R8 从没碰过的 APK。用对的代码、错的构建去覆盖,仍然是对错的构建的覆盖。

当两个地方在回答同一个问题时,让其中一个去问另一个。 测试规则是从「产品自带哪些规则」推断「过不过 R8」的;一次发布改动让 R8 变成无条件,而那个推断就悄悄过期了。代理只有在有人改动它所代指的那个东西之前才保持正确,而那个人通常根本看不到这个代理。

对按名字绑定的 JNI 来说,名字就是 native ABI。把带 native 方法的类混淆掉,等于破坏了链接,而且只在装机之后才发作——那里宿主单测与未裁剪的构建都看不见。keep 规则该放在每个 release 构建都会继承的那份文件里,而不是放在第一个崩溃的产品里。

如果你替换掉了平台默认值,你就是接管了它的全部。 默认配置会保住 native 方法;我们手写的基础文件没有,因为没人写下那一行。你在那样的文件里略去的每一条规则都是一个决定,只是这个决定做得静悄悄,直到某次崩溃替它做出来。

文中提到的产品

继续阅读