背景
Android 多组件发布时,调用方组件和被调用方组件经常不是同一时间重新编译。如果 BOM 只升级了被调用方组件,而调用方仍然使用旧版本编译产物,就可能出现“编译能通过,但运行时崩溃”的二进制链接问题。
典型崩溃包括:
NoSuchMethodErrorNoSuchFieldErrorAbstractMethodError
这类问题的根因不是源码语义不兼容,而是 JVM/ART 在运行时按字节码中记录的完整签名查找目标成员时,当前依赖组合里已经没有对应签名。
检测目标
检测脚本关注已经编译完成的二进制产物,而不是源码:
- APK/AAR 中的
classes.dex - BOM 组合中各组件 artifact 内的
.class文件
核心目标是发现:
- 方法被删除
- 方法参数个数变化
- 方法参数类型变化
- 方法返回值类型变化
- 字段被删除
- 字段类型变化
- Kotlin primitive/boxed 变化,例如
Int与Int? - data class 属性变化导致的 getter、component、copy 等 JVM 签名变化
核心原理
检测逻辑可以概括为:
扫描调用方字节码中实际引用的 JVM 签名,再检查当前包或 BOM 组合中被调用方是否仍然定义该 JVM 签名。
JVM 方法签名由以下部分组成:
类名 + 方法名 + 参数类型列表 + 返回值类型
JVM 字段签名由以下部分组成:
类名 + 字段名 + 字段类型
只要其中任意一部分变化,运行时都会被视为不同成员。
例如 Kotlin 中:
fun check(value: Int)
对应 JVM 签名类似:
check(I)V
如果改为:
fun check(value: Int?)
对应 JVM 签名会变为:
check(Ljava/lang/Integer;)V
源码上只是可空性变化,但 JVM 层面参数类型已经从 primitive int 变成 boxed java.lang.Integer。如果旧调用方没有重新编译,仍然调用 check(I)V,而当前运行时只有 check(Ljava/lang/Integer;)V,就会触发 NoSuchMethodError。
字段同理:
val count: Int
对应字段类型:
count:I
变为:
val count: Int?
对应字段类型:
count:Ljava/lang/Integer;
旧字节码如果仍访问 count:I,运行时当前类里只有 count:Ljava/lang/Integer;,就存在 NoSuchFieldError 风险。
APK/Dex 检测实现
脚本位置建议:
<主工程>/scripts/check_dex_linkage.py
该脚本面向最终 APK/AAR 内的 dex 产物,主要流程如下:
- 读取 APK/AAR 中的
classes.dex。 - 解析 dex 结构中的
type_ids、proto_ids、field_ids、method_ids。 - 解析
class_defs和class_data,收集当前 dex 中实际定义的:defined_methodsdefined_fields
- 解析每个方法的 code item,扫描字节码指令。
- 遇到
invoke-*指令时,记录调用的方法引用。 - 遇到
iget/iput/sget/sput等字段访问指令时,记录访问的字段引用。 - 对每个方法引用检查目标方法是否存在。
- 对每个字段引用检查目标字段是否存在。
- 如果不存在,则输出缺失项、调用来源、目标组件、最相似候选签名和签名差异。
方法调用检测关注 dex 中的 invoke-* 系列指令,例如:
invoke-virtual
invoke-direct
invoke-static
invoke-interface
invoke-super
字段访问检测关注 dex 中的字段读写指令,例如:
iget / iput
sget / sput
iget-object / iput-object
sget-object / sput-object
iget-boolean / iput-boolean
sget-boolean / sput-boolean
BOM 检测实现
脚本位置建议:
<BOM工程>/scripts/check_bom_linkage.py
BOM 检测不直接扫描 APK,而是扫描当前 BOM 组件组合中的 jar/class 产物。
主要流程如下:
- 根据 BOM 当前声明解析组件 artifact。
- 下载或定位每个 artifact。
- 解压 artifact,读取其中的
.class文件。 - 解析 class constant pool。
- 收集当前 class 自己定义的方法和字段。
- 从 constant pool 中提取:
MethodrefInterfaceMethodrefFieldref
- 建立 class 到 artifact 的归属关系。
- 对 BOM 当前组合中的跨组件引用做链接校验。
- 发现目标签名不存在时,输出风险提示,但不阻断 BOM 发布。
示例输出:
缺失方法: Lcom/example/api/ExampleService;->check(Ljava/lang/String;I)Lkotlinx/coroutines/flow/Flow;
可读签名: com.example.api.ExampleService.check(java.lang.String, int): kotlinx.coroutines.flow.Flow
目标组件: com.example.group:provider-module:1.2.0
调用来源: Lcom/example/feature/ExampleCaller; (com.example.group:caller-module:1.1.0)
当前 BOM 中最接近的方法:
...check(Ljava/lang/String;Ljava/lang/Integer;)...
签名差异: param[2]: expected int, found java.lang.Integer
为什么编译期不一定发现
如果调用方和被调用方源码一起重新编译,方法删除、参数个数变化、参数类型变化通常会在编译期被发现。
但组件化发布场景中经常是:
A 组件已经用 B:1.0 编译完成
A 的字节码中记录了 B.foo(I)V
B 升级到 1.1
B.foo(I)V 被删除或改为 B.foo(Ljava/lang/Integer;)V
BOM 中只升级 B 到 1.1
A 没有重新编译
最终 App 同时打入 A 旧产物和 B 新产物
这种情况下:
- A 的源码没有重新编译,所以编译器没有机会报错。
- App 打包时通常只合并二进制产物,不会重新类型检查 A 调用 B 的源码。
- 运行到对应调用点时,ART 按
B.foo(I)V查找目标方法。 - 当前运行时只有新签名,没有旧签名。
- 最终触发
NoSuchMethodError。
因此该检测主要解决的是“二进制组件组合不兼容”,不是普通源码编译错误。
能检测的问题
短期已覆盖:
- 方法缺失
- 方法参数个数变化
- 方法参数类型变化
- 方法返回值类型变化
- 字段缺失
- 字段类型变化
- primitive 与 boxed 类型变化
- 调用来源组件识别
- 目标组件识别
- 相似签名提示
- BOM 发布风险提示
- 发布通知中提示可能需要同步编译的组件
中期建议补充:
- 更完整的继承链解析,降低 Android framework 或三方继承导致的误判。
- Kotlin metadata 辅助解释,把 JVM 签名差异映射回 Kotlin 源码概念。
- 构造方法、默认参数
$default、data classcopy/componentN的专门归因提示。 - 接口默认方法、桥接方法、泛型擦除相关的兼容性提示。
- 风险分级,例如 high/medium/low。
- 白名单机制,允许确认过的三方或 framework 误判继续发布。
- 输出机器可读 JSON,便于 CI、发布通知、发布平台复用。
不能完全检测的问题
以下问题不属于当前检测脚本的主要能力范围:
- 业务语义变化,例如返回值含义改变。
- 方法仍存在,但内部行为不兼容。
- 反射调用字符串变化。
- JNI/native 层符号变化。
- 资源 ID、Manifest、ProGuard 规则导致的问题。
- 仅源码层面的兼容性问题,但二进制签名未变化。
- 运行时由特定分支、数据、机型触发的逻辑崩溃。
结论
该检测方案的价值在于提前发现组件二进制组合中的 ABI 链接风险。
它不是替代编译器,而是补齐组件化发布中“调用方不重新编译”带来的盲区。尤其适合发现方法签名变化、字段签名变化、Kotlin Int 与 Int? 这类看起来很小但 JVM ABI 已经变化的问题。