(一)组件治理之多仓组件化编译的一些问题

首先介绍下我们的项目结构,我们是组件化开发,不同的业务组件存放在各自的仓库之中,组件通过提供 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 之后,这项能力已经没有了,所以,我们急需在编译期间增加这些检查,在后面的文章会继续介绍

相关推荐
xvch21 分钟前
Kotlin 2.1.0 入门教程(七)
android·kotlin
望风的懒蜗牛36 分钟前
编译Android平台使用的FFmpeg库
android
浩宇软件开发1 小时前
Android开发,待办事项提醒App的设计与实现(个人中心页)
android·android studio·android开发
ac-er88882 小时前
Yii框架中的多语言支持:如何实现国际化
android·开发语言·php
苏金标2 小时前
The maximum compatible Gradle JVM version is 17.
android
zhangphil3 小时前
Android BitmapShader简洁实现马赛克,Kotlin(一)
android·kotlin
iofomo7 小时前
Android平台从上到下,无需ROOT/解锁/刷机,应用级拦截框架的最后一环,SVC系统调用拦截。
android
我叫特踏实8 小时前
SensorManager开发参考
android·sensormanager
五味香10 小时前
Java学习,查找List最大最小值
android·java·开发语言·python·学习·golang·kotlin
graceyun15 小时前
C语言进阶习题【1】指针和数组(4)——指针笔试题3
android·java·c语言