首先介绍下我们的项目结构,我们是组件化开发,不同的业务组件存放在各自的仓库之中,组件通过提供 api 的方式供其他业务调用,大致效果图如下:
实现层模块与模块之间不直接依赖,只通过依赖 api 层服务发现的机制来触发实现层的调用,例如上图的首页依赖分享 api 模块实现调用,从业务架构上来看,组件化不仅帮助我们实现了组件隔离与解耦,还帮助了我们在各个不同业务线的高度复用。可即便再完美的架构,在业务开发中还是会遇到一些坑。
我们的版本开发是走班车制度,每个版本都会有很多的需求上车,每个需求的合入都是打好 release 组件合入壳工程,如果对外暴露的 API 模块或是底层模块稍有不注意外部的调用情况,就会出现很多隐蔽的编译问题被带到运行时,这是非常危险的操作。
所有的问题,都可以理解为版本不一致导致的兼容性问题。当然也有终极解决办法,单仓编译就没有这些事了。
1、常量引用被直接替换
组件在编译时,如果涉及到常量或是枚举的引用,将会被直接替换成对应的值,并不会保留引用关系。如果对外的模块在新的需求开发时修改了该值,并且未告知调用模块的话,则会出现在运行时调用方与提供方不匹配的情况,进而发生一些运行时的逻辑问题,并且,该问题在编码期间还不易发现,因为在壳组件下查看各组件的调用情况时,这个引用关系是在的,调用方能关联到常量引用,这主要是 sourceCode 起的作用,但从字节码来看,所对应的值已经不一样,通过 debug 调试才发现该问题。
2、运行时的 AbstractMethodError 异常
该异常表现为调用了对方一个未实现的抽象方法。 例如,A 模块的 1.0 版本引用了 B 模块 1.0 版本,并调用 change 方法,该模块调用情况如下:
java
// 1.0 版本的 A 模块,只依赖 1.0 的 B 接口模块进行编译
class A {
public void test(){
// 通过服务化的方式拿到 B 实例
B b = ServiceProvider.get("B");
b.change();
}
}
// 1.0 版本的 B 接口模块
interface B{
void change()
}
// 1.0 版本的 B 实现模块
class BImp implements B{
public void change(){}
}
B 模块新需求开发 2.0 版本,把 change 方法签名给改了:
java
// 2.0 版本的 B 接口模块
interface B{
void change(int type)
}
// 2.0 版本的 B 实现模块
class BImp implements B{
public void change(int type){}
}
本地需求开发时,最终的依赖是 1.0 版本的 A 模块、1.0 版本的 B 接口模块和 2.0 版本的 B 实现模块,这就会导致 A 模块调用 B 模块的 change 方法接口中是有该方法的,但实现层已经没有这个方法了,因为原来的方法签名发生了改变,虚拟机会觉得 B 实现层未实现接口方法,抛出 AbstractMethodError 异常。
该类异常主要集中在需求分支开发阶段,由于需要联调其他业务模块,对方会给一个联调版本,如果该版本低于壳工程里的依赖版本,就会导致在编译项目时取的是壳工程依赖版本,也就发生了 B 模块一个是 1.0 一个是 2.0 的情况,也有的是 pom 透传了一个版本号很高但代码依然是 1.0 版本的情况。
好在这类问题主要集中在需求开发阶段,但依然是要运行时才发现该问题,解决办法可以检索出所有继承抽象类与接口的类,有无实现抽象方法,没有实现的话,则在编译期间报错,提前发现问题。
2、运行时的 NoClassDefFoundError、NoSuchMethodError、NoSuchFieldError 异常
这类异常贡献了主要力量,主要集中在高版本不兼容低版本上,例如只升级了 okhttp 为 4.x 版本,导致 okhttp-urlconnect 3.x 版本找不到 okhttp 的 delimiterOffset 方法。对于内部的基础库来说,更要注意这类问题,如果高版本没有做向下兼容处理,导致一些类、方法、字段等删除了,涉及到这些调用的业务都要重打组件,对于这个版本没有需求的同学来说,这就是在增加他人工作量,如果别人不配合的话,就要回退自己的组件重新兼容。
但也不能一直兼容下去吧,对于大版本的升级,会对一些长期的 Deprecated 做删除处理,AGP 与 Android SDK 经常这么干,所以,提前检查涉及到的业务组件是非常有必要的,至少能在编译期间就检查出问题。这个检查思路也很简单,记录所有依赖的类、字段与方法,然后再检查每个类里面的方法调用,是否能在记录中找到,找不到的话,说明是遇到了 NoXXError,可以提前编译失败。
好在 NoXXError 异常可以在壳工程下查看,一般是类、方法或是字段爆红。
3、kotlin 的默认参数
kotlin 的语法糖在背后做了很多事情,因为新版本对 data class 新增了个默认参数,导致使用到这个 data class 的组件报了 java.lang.NoSuchMethodError:No direct method <init>(xxx)
找不到构造方法异常,我来举例下这个问题。
kotlin
// A 模块 1.0 版本的 data class
data class A(val id:Int,val name: String = "zhangsan")
// B 模块 1.0 版本调用 data class
val a = A(1)
由于版本升级,A 模块需要提供更多的参数,新增了 age 参数:
kotlin
// A 模块 2.0 版本新增了 age 参数
data class A(val id:Int,val name: String = "zhangsan", val age: Int = 8)
如果 A 与 B 类在一个模块里面同时参与编译是不会有任何问题的,因为 kotlinc 会同时修改调用处与被调用处。
我们 Decompile 看下具体问题:
- A 模块 1.0 版本的 Decompile
java
public A(@NotNull int id, @NotNull String name) {//...}
// $FF: synthetic method
public A(int var1, String var2, int var3, DefaultConstructorMarker var4) {//...}
- B 模块 1.0 版本的 Decompile
java
new A(1, (String)null, 2, (DefaultConstructorMarker)null);
- A 模块 2.0 版本的 Decompile
java
public A(@NotNull int id, @NotNull String name, int age) {//...}
// $FF: synthetic method
public A(int var1, String var2, int var3, int var4, DefaultConstructorMarker var5) {//...}
通过 Decompile 发现, B_1.0 模块的 new 初始化在 A_2.0 没有这个构造,这是 kotlin 的一个特性,对于设置了默认参数的方法,kotlinc 会再生成一个方法,然后新增两个参数,一个是位计算,用来赋值默认值,另一个未看到使用处。
这个地方的主要问题是,kotlinc 不仅会对默认参数的方法生成 synthetic method,还会对调用处进行更改,如果调用处缺省默认参数,调用处就会被 kotlinc 强行增加标志位,然后改成调用 synthetic method,如果原来的方法增加新的默认参数的话,就会生成新的 synthetic method,就会与调用处不一致。
这里面还有一个小插曲,就是发生该问题的时候,通过壳工程来查看各个组件的的调用情况,由于最终调用的是 synthetic method,sourceCode 是看不出效果的,并且 A 模块的调用还能正常跳转到 B 模块的方法,简直摸不着头脑,编码状态下又看不出问题,应用编译的时候也不报错,最终被流转到了运行时。
4、Android 适配升级导致的方法找不到
我们在做 Android 适配时,可能只关注 以某个目标版本平台的适配
与 运行在该版本的适配
,往往会忽略掉一些曾经的 Deprecated 方法在该目标版本中可能被移除了,这里以 Android13 适配为例,在 Android 13 中,WebSettings 的 setAppCacheEnabled、setAppCachePath 方法已经被替换成 setCacheMode 方法,如果在壳工程上直接升级 compileSDK 为 33,并且,适配文档中没有考虑到,将会在运行时发生 NoSuchMethodError 异常。
如果想查看每次版本升级导致的 api 移除,可以查看链接
13 移除 setAppCacheEnabled、setAppCachePath 可以查看该文档
总结
在我们还是使用 ProGuard 编译项目的时候,还能检索出找不到的类、方法和字段异常,在迁移到 R8 之后,这项能力已经没有了,所以,我们急需在编译期间增加这些检查,在后面的文章会继续介绍