近年来,随着手机业务的快速发展,为知足手机端用户诉求和业务功能的迅速增加,移动端的技术架构也从单一的大工程应用,逐步向模块化、组件化方向发展。以高德地图为例,Android 端的代码已突破百万行级别,超过100个模块参与最终构建。java
试想一下,若是没有一套标准的依赖检测和监控工具,用不了多久,模块的依赖关系就可能会乱成一锅粥。bash
从模块 Owner 的角度看,为何依赖分析这么重要?网络
做为模块 Owner,我首先想知道“谁依赖了我?依赖了哪些接口”。惟有如此才能评估本模块改动的影响范围,以及暴露的接口的合理性。架构
我还想知道“我依赖了谁?调用了哪些外部接口”,对所须要的外部能力作到心中有数。ide
从全局视角看,一个健康的依赖结构,要防止“下层模块”直接依赖“上层模块”,更要杜绝循环依赖。经过分析全局的依赖关系,能够快速定位不合理的依赖,提早暴露业务问题。模块化
所以,依赖分析是研发过程当中很是重要的一环。函数
提到 Android 依赖分析,首先浮如今脑海中的多是如下这些方案:工具
分析 Gradle 依赖树。组件化
扫描代码中的 import
声明。性能
使用 Android Studio 自带的分析功能。
咱们逐个来分析这几个方案:
1. Gradle 依赖树
使用 ./gradlew :<module>:dependencies --configuration releaseCompileClasspath -q
命令,很容易就能够获得模块的依赖树,如图:
不难发现,这种方式有两个问题:
声明即依赖,即便代码中没有使用的库,也会输出到结果中。
只能分析到模块级别,没法精确到方法级别。
2. 扫描 import
声明
扫描 Java 文件中的 import 语句,能够获得文件(类)之间的调用关系。
由于模块与文件(类)的对应关系很是容易获得(扫描目录)。因此,获得了文件(类)之间的依赖关系,便是获得了模块之间文件(类)级别的依赖关系。
这个方案相比 Gradle 依赖扫描提高告终果维度,能够分析到文件(类)级别。可是它也存在一些缺点:
没法处理 import * 的状况。
扫描“有 import 但未使用对应类”的场景效率过低(须要作源码字符串查找)。
3. 使用 IDE 自带的分析功能
触发 Android Studio 菜单 「Analyze」 -> 「Analyze Dependencies」,能够获得模块间方法级别的依赖关系数据。如图:
Android Studio 能准确分析到模块之间“方法级别”的引用关系,支持在 IDE 中跳转查看,也能扫描到对 Android SDK 的引用。
这个方案比前面两个都优秀,主要是准确。可是它也有几个问题:
耗时较长:全面分析 AMap 全源码,大约须要 10 分钟。
分析结果没法为第三方复用,没法生成可视化的依赖关系图。
分析正向依赖和逆向依赖,须要扫描两次。
总结一下上述三种方案:
参考 Android 构建流程图,全部的 Java 源代码和 aapt 生成的 R.java 文件,都会被编译成 .class 文件,再被编译为 dex 文件,最终经过 apkbuilder 生成到 apk 文件中。图中的 .class 文件便是咱们所说的 Java 字节码,它是对 Java 源码的二进制转义。
在 Android 端,常见的字节码应用场景包括:
字节码插桩:用于实现对 UI 、内存、网络等模块的性能监控。
修改 jar 包:针对无源码的库,经过编辑字节码来实现一些简单的逻辑修改。
回到本文的主题,为何要分析字节码,而不是 Java 代码或者 dex 文件?
不使用 Java 代码是由于有些库以 jar 或者 aar 的方式提供,咱们获取不到源码。不使用 dex 文件是由于它没有好用的语法分析工具。因此解析字节码几乎是咱们惟一的选择。
要获得模块之间的依赖关系,其实就是要获得“模块间类与类”之间的依赖关系。而要肯定类之间的关系,分析类字节码的语句便可。
1. 在什么时机来分析?
了解 Android 构建流程的同窗,应该对 transform 这个任务不陌生。它是 Android Gradle 插件提供的一个字节码 Hook 入口。
在 transform 这个任务中,全部的字节码文件(包括三方库) 以 Input 的格式输入。
以JarInput 为例,分析其 file 字段,可获得模块的名称。解析 file 文件,便可获得此模块全部的字节码文件。
有了模块名称和对应路径下的 class 文件,就创建了模块与类的对应关系,这是咱们拿到的第一个关键数据。
2. 使用什么工具分析?
解析 Java 字节码的工具,最经常使用的包括 Javassit,ASM,CGLib。ASM 是一个轻量级的类库,性能较好,但须要直接操做 JVM 指令。CGLib 是对 ASM 的封装,提供了更高级的接口。
相比而言,Javassist 要简单的多,它基于 Java 的 API ,无需操做 JVM 指令,但其性能要差一些(由于 Javassit 增长了一层抽象)。在工程原型阶段,为了快速验证结果,咱们优先选择了 Javassit 。
3. 具体方案是怎样的?
先看一个简单的示例,如何分析下面这段代码的调用关系:
1: package com.account;
2: import com.account.B;
3: public class A {
4: void methodA() {
5: B b = new B(); // 初始化了 Class B 的实例 b
6: b.methodB(); // 调用了 b 的 methodB 方法
7: }
8: }
复制代码
第1步:初始化环境,加载字节码 A.class,注册语句分析器。
// 初始化 ClassPool,将字节码文件目录注册到 Pool 中。
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath('<class文件所在目录>')
// 加载类A
CtClass cls = pool.get("com.account.A");
// 注册表达式分析器到类A
MyExprEditor editor = new MyExprEditor(ctCls)
ctCls.instrument(editor)
复制代码
第2步:自定义表达式解析器,分析类A(以解析语句调用为例)。
class MyExprEditor extends ExprEditor {
@Override
void edit(MethodCall m) {
// 语句所在类的名称
def clsAName = ctCls.name
// 语句在哪一个方法被调用
def where = m.where().methodInfo.getName()
// 语句在哪一行被调用
def line = m.lineNumber
// 被调用类的名称
def clsBName = m.className
// 被调用的方法
def methodBName = m.methodName
}
// 省略其它解析函数 ...
}
复制代码
ExprEditor 的 edit(MethodCall m) 回调能拦截 Class A 中全部的方法调用(MethodCall)。
除了本例中对 MethodCall 的解析,它还支持解析 new,new Array,ConstructorCall,FieldAccess,InstanceOf,强制类型转换,try-catch 语句。
解析完 Class A,咱们获得了 A 对 B 的依赖信息 :
Class1 | Class2 | Expr | method1 | method2 | lineNo |
---|---|---|---|---|---|
com.account.A | com.account.B | NewExpr | methodA | 5 | |
com.account.A | com.account.B | methodCall | methodA | methodB | 6 |
简单解释以下:
类 com.account.A 的第5行(methodA方法内),调用了 com.account.B 的构造函数;
类 com.account.A 的第6行(methodA方法内),调用了 com.account.B 的 methodB 函数;
这即是“类和类之间方法级”的依赖数据。结合第1步获得的“模块和类”的对应关系,最终咱们便得到了“模块间方法级的依赖数据”。
基于这些基础数据,咱们还能够自定义依赖检测规则、生成全局的模块依赖关系图等,本文就不展开了。
本文主要介绍了模块依赖分析在研发过程当中的重要性,分析了 Android 常见的依赖分析方案,从 Gradle 依赖树分析, Import 扫描,使用 IDE 分析,到最后的字节码解析,方案逐步递进。越是接近源头的解法,才是越根本的解法。