前言
这套Android代码依赖分析服务整个方案的出发点和思路都非常『直白』:支付宝Android客户端是由众多模块(称为Bundle)组成的,各个业务模块开发都是在自己负责的Bundle上进行,对其他模块的代码通过Bundle依赖的形式调用。从这个角度看,其他模块的代码都是『黑盒』,底层Bundle的修改对上层调用Bundle的影响是不透明的。所以,底层Bundle的修改可能会对上层调用的业务模块产生负面影响,但相关研发同学却无法轻易察觉到这种影响,最终产生Bug甚至线上问题。于是,通过分析调用依赖产出修改的影响范围,从而让相关人员、相关工具服务感知到,这种『朴素』的方案自然而然成了一种解决问题的思路。技术选型
支付宝APP在内部研发平台构建打包,一个完整的APP由若干个特定版本的Bundle组合而成,这个特定版本的Bundle组合在研发平台上称为一个『基线』。初期需求:分析出在一个基线里各个Bundle的代码之间的调用依赖关系,然后根据两个基线diff出变化的代码,分析出变更代码的影响代码范围。拆分下这个需求,可以分为三个步骤:- Diff基线A和基线B之间各个Bundle的变更代码
其中最关键的是第一步,分析基线A的调用依赖关系。分析代码的调用链路,一般有两种实现方案,一种是基于源代码的分析,另一种是基于编译产物的分析,两种方案各有优缺点如下:- 优点:分析出的数据结构贴近源代码,包含行号、目录等源代码信息,避免编译带来的变化影响(比如内联),方便和变更代码匹配。
- 缺点:由于需要拉取并读取Git,分析速度上有影响;需要分别支持Java、Kotlin等语言;无法理解编译脚本处理的源代码范围,默认只能处理Bundle Git库内全部源代码,对于一些提供给支付宝外的基础库模块代码也会分析。
- 优点:编译能抹平Java、Kotlin等语言的差异,还能屏蔽不必要的代码影响(比如编译产物不包含Bundle内提供给非支付宝的代码),同时对于外部依赖、继承等场景分析更简单,拉取和读取产物速度上有优势。
- 缺点:编译会引入变化,导致和源代码无法完全一一对应(比如内联导致函数合并),缺少行号、目录等源代码信息,和后续的变更代码匹配上需要单独处理。
综合比较下来,结合应用场景主要是分析变更代码的Bundle之间依赖影响,最终确定选择基于编译产物的依赖分析方案。另外,为了方便Bundle之间依赖影响场景的使用,方案以基线内各个Bundle的编译产物(jar、aar等)作为分析输入,读取其中Java/Kotlin编译生成的class字节码,进一步分析类、方法之间的调用关系。方案
下面是方案的一个简单流程说明,其中部分流程通过多线程并发处理加速分析。整个过程中的核心是构造类/方法间的依赖关系,主要包括继承关系构建、引用(属性/方法/泛型/注解等)关系构建、反射特殊分析、方法调用构建等,针对支付宝APP的一些特定写法,比如配置、服务继承等,在关系构建过程中加入特殊处理,人为添加对应的关系链。关系构建的过程也非常容易理解,以TreeMap的形式依次遍历各个Class字节码,将特定关系标记到对应类/方法上,整个过程有若干次遍历,因为有的关系构建需要依赖前面构建的关系,比如类A调用了接口B的方法,在关系整理过程中,第一次遍历会先找出接口B的子类实现C1 C2...,后续遍历分析方法调用关系时,根据一些规则整理出A和B以及C1有实际调用关系。在关系构建过程中和构建完成后,还有个特殊的步骤:整理内存。由于支付宝APP的Bundle总数有大几百个,类总数有10W+,方法、属性等数量巨大,为了减少不必要的磁盘IO拖慢速度,整个关系分析过程是一次读取,然后在内存中完成的,内存峰值压力较大,所以加入了整理内存的过程,在构建过程中每次完成一个类的分析,就释放一部分的数据内存占用,并对占用内存较多的String/Char数据做了共享内存池设计。经过一系列内存裁剪,内存峰值从最初的1.5G+降低到了800M~900M。这里可能会有疑问,为什么不把内存的中间数据存下来,从而彻底解决内存占用的问题?当时的主要考虑是磁盘IO速度跟不上,为了后续能够和变更代码更快的匹配和关系追溯,全部放在内存比IO要快很多,写法上也简单很多,内存数据结构定义不需要考虑IO到磁盘上做转换。以上完成了整个APP内依赖关系的分析,针对使用场景,还有两个步骤:- 获取两个基线之间的变更代码,通过Git diff分析出变更的代码,
- 查找变更代码的影响,需要先将变更代码片段转换成类/方法等数据结构,然后通过依赖关系图中查找到调用链的上游,作为结果返回
方案应用
目前Android的依赖分析方案应用在若干个场景,主要作用帮助节省人力、确定某些代码范围。灰度变更精准测试
支付宝APP在每个版本进入到灰度阶段,会每天进行下一个灰度包的回归测试,这个过程中每个业务线都会回归各自的测试用例。依赖分析方案的用处是帮助找出来T-1时间段内所有的变更代码及其影响到的模块代码,这是一个典型的使用场景,内部研发平台每天构建出灰度包后,会将T-1时间段内集成的Bundle和基线信息发送过来,依赖分析服务通过变更依赖分析产出变更的代码、受影响的代码和模块,反馈给内部研发平台,内部研发平台按照一定的规则生成回归测试计划,以完成回归测试。这套方案最初是以类作为变更粒度来分析影响范围的方案1.0,后来又升级为以方法作为变更粒度结合类变更来分析影响的方案2.0,整体效果相比之前的全量回归和以Bundle作为变更粒度的方案有了较大幅度提升。以支付宝APP某个版本采样为例,回归测试用例数减半,参与测试的业务线数也减半。无用代码优化
通过依赖分析方案产出的数据,可以找出哪些类是没有被引用的无用类,这就是无用代码的使用场景。通过分析出的无用类,和扫描卡点场景结合,在研发过程中形成卡点,提醒相关研发同学及时去掉这些代码,减小包大小体积。- 非特殊外部入口类、非XML引用类,通过分析配置文件和XML等获取
- 无其他类引用,或仅仅被无用类引用的类,通过多次遍历无用类集合并『剪枝』无用类的引用链条获得
- 非JNI引用类,暂时没有C/C++依赖分析能力,通过白名单配置过滤
- 非动态调用类,目前依赖分析方案支持直接通过反射传入类名的方式调用分析,但无法分析包装反射工具类方式的调用
通过依赖分析方案产出的数据,再结合上以上几种情况的过滤,产出无用类清单,再结合线上类覆盖率数据,由扫描平台卡点检查,目前已经在内部研发流程中接入。耗时Runnable变更检查
当前支付宝APP能够监测程序运行过程中各个Runnable的运行时间,在灰度阶段,针对APP启动过程中一些耗时的Runnable数据采集并提示,然后由专门负责同学跟进分析,防止APP运行性能劣化。在这个过程中,需要重点关注的是新增或修改代码导致的Runnable长耗时,如何判断一个Runnable是新增或修改的代码是比较浪费人工的工作,特别是Android里Runnable的写法大部分是new Runnable{ ... }这种匿名内部类的情况,线上监测的是Runnable是ClassXX$12,名字和代码对应比较麻烦。依赖分析方案会对编译产物进行分析,对于匿名内部类的情况不需要特殊处理,只需要知道运行类名,就能对应到实际的代码数据结构,然后通过对代码数据的关键语句抽取特征值对比,即可得出类代码是否变化的结论。最终形成的方案,线上监控服务的平台将采样到的长耗时Runnable传给依赖分析服务,由依赖分析服务找到对应的代码类,并在基准版本和最新版本之间对比代码结构数据,给出Runnable是否变化的结论,再由监控服务将新增/修改的长耗时Runnable信息同步给相关同学处理,从而大幅降低了人工对比分析的成本。变更/下线代码统计
这个场景大部分是一些一次性的统计场景,想要拿到特定接口的调用方,后续用于一些代码下线或变更风险跟进。由于直接通过代码搜索容易遗漏,且人工梳理搜索结果比较费时,通过依赖分析服务进行统计也是一种省时的方案。总结
总的来说,目前的依赖分析方案从纯静态分析的角度已经达到了可用状态,并且帮助不同的场景降低了人工投入、提升了分析准确度。当然仍然存在一些不足的地方,需要能力优化或者能力补全。- 目前的依赖分析方案是平台服务性质的,对于一些定制化的分析统计需求或本地分析需求,灵活性不够。
- 只有代码控制流的分析,缺少对代码数据流的分析,这方面有一些诉求,比如想知道某个方法内精准的执行路径影响。
- 不支持so分析,JNI和java之间的调用关系欠缺。
为此,升级版的、更灵活的分析方案也在开发完善中,后续会对接到相应的分析场景中。
如对本文有任何建议或问题,请关注我们的微信公众号,我们将私信回复 ❤️