首先介绍下咱们的项目结构,咱们是组件化开发,不同的事务组件存放在各自的仓库之中,组件经过提供 api 的办法供其他事务调用,大致作用图如下:
完成层模块与模块之间不直接依靠,只经过依靠 api 层服务发现的机制来触发完成层的调用,例如上图的主页依靠分享 api 模块完成调用,从事务架构上来看,组件化不只协助咱们完成了组件隔离与解耦,还协助了咱们在各个不同事务线的高度复用。可即使再完美的架构,在事务开发中仍是会遇到一些坑。
咱们的版别开发是走班车准则,每个版别都会有许多的需求上车,每个需求的合入都是打好 release 组件合入壳工程,假如对外暴露的 API 模块或是底层模块稍有不注意外部的调用状况,就会呈现许多隐蔽的编译问题被带到运行时,这是非常风险的操作。
一切的问题,都能够理解为版别不一致导致的兼容性问题。当然也有终极解决办法,单仓编译就没有这些事了。
1、常量引证被直接替换
组件在编译时,假如涉及到常量或是枚举的引证,将会被直接替换成对应的值,并不会保留引证关系。假如对外的模块在新的需求开发时修正了该值,而且未奉告调用模块的话,则会呈现在运行时调用方与提供方不匹配的状况,进而产生一些运行时的逻辑问题,而且,该问题在编码期间还不易发现,因为在壳组件下检查各组件的调用状况时,这个引证关系是在的,调用方能关联到常量引证,这首要是 sourceCode 起的作用,但从字节码来看,所对应的值现已不一样,经过 debug 调试才发现该问题。
2、运行时的 AbstractMethodError 反常
该反常表现为调用了对方一个未完成的笼统办法。 例如,A 模块的 1.0 版别引证了 B 模块 1.0 版别,并调用 change 办法,该模块调用状况如下:
// 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 办法签名给改了:
// 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)
找不到结构办法反常,我来举例下这个问题。
// 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 参数:
// 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
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
new A(1, (String)null, 2, (DefaultConstructorMarker)null);
- A 模块 2.0 版别的 Decompile
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 之后,这项才能现已没有了,所以,咱们急需在编译期间添加这些检查,在后面的文章会持续介绍