在上篇文章 《组件治理之多仓组件化编译的一些问题》中介绍,一些原本可在编译期间报错的问题被带到了运行时,我们需要开发一款检查插件,把 NoClassDefFoundError、NoSuchMethodError、NoSuchFieldError 与 AbstractMethodError 等异常提前在编译期间卡住。
1、收集所有参与编译的 Class 文件
参与项目编译的模块有:
- Android SDK 源码
- Java 源码
- 依赖组件
1、Android SDK 源码我们可以通过读 AppExtension 的 compileSdkVersion 拿到参与编译的版本,然后读取 local.properties 里的 sdk.dir 路径,由此即可拼接出 android.jar 的路径,以此拿到 Android SDK 源码,读取到的路径如下:
SDK_DIR/platforms/android-compileSdkVersion/android.jar
2、Java 源码不是很好拿到,从 jdk9 开始,已经没有 rt.jar 了,具体可以查看 oracle 关于 Removed rt.jar and tools.jar 的部分,所以,这里只好退而求其次,使用 jdk8 的 rt.jar 参与编译。
3、运行时的依赖可以通过 RuntimeClasspath Configuration 来拿到所有参与编译的依赖 jar 文件
在拿到上面所有的 jar 文件后,我们就可以通过 ASM 来读取 jar 里面的 Class 文件,并收集出 Class 文件的字段、方法等信息,然后存到一个以 ClassName 为 key 的 map 集合中,方便后面在分析 Class 文件时可以直接判断引用的类是否存在,并且还可以拿到 Class 相关的信息。
2、检查 class 文件引用外部类的情况
一个类引用到其他类的几种情况:
- 注解:类、字段、方法、参数使用注解去描述的情况
- 字段:使用类去申明的字段,基础类型忽略
- 方法:方法 Code 里涉及到的外部类字段、方法的调用
- 接口
- 父类
我们在遍历所有参与编译依赖的 Class 文件时(Android、java 源码不参与遍历),即可通过这些情况去分析引用情况。 这里有一个细节点,在方法 Code 中的字段与方法调用,在 owner 找不到的情况还要继续从他的父类与接口继续查找,因为调用的字段与方法有可能在父类。
一些特殊情况的处理: 有的模块可能就是会报 unsolved,例如 androidx.compose.ui:ui 依赖的 RenderNodeApi23 与 RenderNodeApi29 类中的 RenderNode,他们的包名在不同的 SDK 版本不一样,但他们在运行阶段会通过 SDK 版本来选择加载哪个类,所以,类似这类的 unsolved 是可以放过的,但前提是做好 review
3、检查 xml 中 class 文件的引用情况
在 layout 的布局 xml 中,对于自定义 view 的定义,也需要进行类扫描
4、插件介绍
1、插件能力
- 分析模块之间的真实引用关系,并生成 plantUML 与 mermaid 文件
- 组件依赖重复类检查
- 未解决的引用检查
2、执行插件
./gradlew moduleRef
执行完成后会在 app/build 目录生成 moduleRef.json 文件,效果如下:
json
{
"androidx.compose.ui:ui:1.3.0": {
"dependencies": [
"org.jetbrains.kotlin:kotlin-stdlib:1.7.20",
"androidx.compose.ui:ui-unit:1.3.0",
"androidx.compose.runtime:runtime:1.3.0",
"androidx.compose.ui:ui-graphics:1.3.0",
"//............."
],
"unsolved": {
"clazz": [
"android.view.RenderNode",
"android.view.DisplayListCanvas"
],
"fields": [
"androidx.compose.ui.platform.RenderNodeApi23_android.view.RenderNode"
],
"methods": []
}
}
}
dependencies
为androidx.compose.ui:ui:1.3.0
所使用到的依赖unsolved
为androidx.compose.ui:ui:1.3.0
依赖使用到的 类、字段和方法在整个依赖关系中都找不到
3、生成的组件引用关系图的一部分:
5、一些小插曲:
AbstractMethodError 异常主要是检测没有实现父类的抽象方法,起初以为这个检查挺简单的,但在一路思考之后发现,并没有那么简单,画个树状图大家就能看明白了:
实现类的父类可能是抽象类,并且抽象类的父类可能也是抽象类,并且还带有接口,所以,就需要从前往后查找父类是否为抽象类,查到之后必须从后往前遍历,因为抽象类有可能把父类或是接口的抽象方法给实现,这样的话,子类就无需实现了,这种情况是不会发生 AbstractMethodError 异常的,这里还需要需要注意一下接口的 default 方法,接口里面实现父类接口时,如果用 defeault 实现抽象方法的话,这种情况子类也是无需实现的,并且,default 方法的 accessFlag 也没有 ACC_ABSTRACT 标识:
在我吭哧吭哧开发之后又发现一些小问题,接口的多继承下,是允许方法重复的,例如:
java
public interface IAnimal {
void run();
}
public interface Dog extends IAnimal{
void run();
}
所以,在收集方法 accessFlag 为 ACC_ABSTRACT 时,需要做一下去重。 我以为终于解决所有问题了,但在检查结果时发现,还是有一些情况没有检测到,这个问题就真的离了大谱了,Java 编译出来的 class 是没问题的,问题出现在了 Kotlin 上面。在 Kotlin 中,接口继承接口时,也是可以实现父类的抽象方法,效果看起来跟 Java 的 default 类似,示例如下:
Dog 接口实现了父类 IAnimal 接口的抽象 run 方法,代码上来看并没有问题,但检测结果却报了 AbstractMethodError 异常,说 run 方法没有实现,如果按 java 的 default 方法来看的话,Dog 这个类的 run 方法应该是一个非抽象方法,现在只能 Decompile 看下具体原因了:
Kotlin 接口实现方法居然是通过桥接类做到的,Dog 类的 run 方法仍然是抽象方法,在 Kotlin 的这种情况下,我没办法通过类遍历来检查抽象方法有无实现。按道理,应该可以继续遍历接口的 innerClass 内部类,检查是否有 DefaultImpls 类,然后检查 DefaultImpls 的方法是否与接口方法签名一致,是的话,也算是实现了接口方法,目前这个部分的代码还在 feature 分支实现中。