代码覆盖率(Code coverage)是软件测试中的一种度量方式,用于反映代码被测试的比例和程度。
在软件迭代过程中,除了应该关注测试过程中的代码覆盖率,用户使用过程中的代码覆盖率也是一个非常有价值的指标,同样不可忽视。因为伴随着业务扩展和功能更新,产生了大量过时和废弃的代码,这些代码或者很少甚至完全不再使用,或者“年久失修”,缺少维护,不仅对应用包体积有影响,还可能带来稳定性风险。此时,能够采集生产环境的代码覆盖率,了解线上代码的使用情况,为下线无用代码提供依据,就十分重要了。
目标
我们的目标很明确:根据云端配置,采集线上每个类的触达和使用频次,上传到云端,在平台进行处理,并提供查询和报表展示能力。

如上图所示,我们期望代码覆盖率数据能在平台上进行查询和直观的展示,在需要时可以直接查看,为下线旧代码、资源调度和分配等提供决策依据,最终为用户提供更小的App安装包,更好的功能使用体验。
通过云控中心,我们可以控制是否启用覆盖率采集,也可以根据覆盖率(类使用频次)动态调整App中金刚位、线程等资源的调度分配策略。其中覆盖率采集方案是最为重要的一环,业界也有很多成熟的方案,但都有各自适合的场景,而我们的诉求是在尽量不影响用户使用和App运行的前提下,采集类粒度的代码使用覆盖率。使用的采集方案应该少Hack,实现简单,兼顾稳定性和性能,同时也不会侵入打包流程,带来包体积影响等,在经过深入探索后,我们自研出了一套完美满足这些要求的全新方案。
方案对比
下表为常见方案与自研方案的各项指标对比,绿色表示更优。

从表格中可以看出:
Jacoco方案
类似的还有Emma、Cobertura等,他们都通过插桩实现,可以支持所有版本所有粒度的采集,但是插桩带来了一定的包体积和性能影响,不适合线上大范围使用。
Hook PathClassLoader方案
实现简单,无源码侵入,且支持所有Android版本,但Hook PathClassLoader不仅带来了性能影响,甚至可能波及App稳定性。
Hack访问ClassTable方案
能够按需采集,对App性能几乎没有影响,但Hack可能带来兼容性问题,且实现较复杂。
自研方案
-
性能优异,支持按需采集,无损App性能
-
实现简单,未使用任何“黑科技”,稳定性和兼容性极好
-
支持跨进程和插件采集
对比得知自研方案能更好的满足我们采集线上代码覆盖率的诉求,因为它不仅有着很好的稳定性,而且有着优异的性能,几乎不会对用户产生任何影响。那么它是如何做到高性能和高稳定性的呢?请看下文介绍。
方案介绍
原理
要采集类粒度的代码覆盖率,其实就是要知道在App运行过程中,加载和使用了哪些类。在Java应用中,这可以通过调用ClassLoader的findLoadedClass方法直接查询得到,而在Android App中却没那么简单。原因是Android系统做了这样一个优化:
为了提升启动性能,对于App自定义的类,即PathClassLoader加载的类,如果直接调用findLoadedClass进行查询,即使这个类没有加载,也会执行加载操作。
这不是我们期望的。
虽然我们没办法直接调用FindLoadedClass方法查询类的加载状态,但是经过深入研究和分析,我们发现ClassLoader最终是通过查询它的ClassTable字段得到类加载状态的,如果我们也能访问ClassTable,问题不就迎刃而解了吗?沿着这个思路,我们创新性地提出了复制ClassTable指针,通过标准API间接访问类加载状态的方案。
该方案巧妙地实现了对ClassTable的无Hack访问;同时完美绕开了我们不需要的类加载优化,寥寥数行代码就实现了类加载情况的获取,巧妙且简洁,同时它还具备以下优势:
- 采集速度是普通方案的5倍以上,性能优异
- 使用标准API访问ClassTable,兼容性与稳定性极佳
- 仅使用一次反射,无任何“黑科技”,简单稳定
- 不影响类加载及App运行
- 完美支持多进程和插件的采集
不过有一点需要注意:
ClassTable字段是从Android N开始引入的,所以该方法只适用于Android N及以上。出于必要性和ROI考虑,我们也未对Android N以下版本进行适配。
采集流程
基于上述的方案,我们设计了完整的代码覆盖率采集功能,关键流程如下:

可以看到整个端侧的采集流程是串行的,非常便于流程控制和数据整合。下面说明一下设计思路:
-
采集时将App分为两部分,一部分是主进程和子进程使用的宿主类数据,另一部分是插件类数据。
-
基于查询方式采集,主进程、子进程、插件分别提供查询类加载状态的接口。
-
流程基于串行方式,由主进程控制,依次调用相应的接口采集主进程、子进程和插件的数据。
-
每个版本只采集和上报未加载过的类数据,首次采集时,以类全集为输入;后续的每次采集,以上一版本未加载的类为输入,采集次数越多,需要查询的类越少。
-
主进程和子进程依次查询,查询都以上一次查询后剩余的未加载类为输入,因此越靠后的子进程所需查询的数量越少,同一个插件在不同进程的实例的查询也与此类似。
如下图所示:

-
采集结束时,会生成一份宿主类数据和N份插件类数据(假如有N个插件)。这些数据会分别与之前的采集结果做Diff,将增量数据上传服务。
-
服务平台进行存储、解Mapping、模块关联等处理,最后以报表形式聚合展示。
值得注意的是:
-
主进程与子进程使用的类都属于宿主,采集结果应该合并为一份数据;同理,一个插件无论在多少个进程加载,最后也只应生成一份该插件的数据。
-
采集时我们将数据分为两部分,这样可以提高采集效率,也方便后续解混淆;在平台展示时,合并展示更有意义。
版本管理
Android App的代码大都会经过混淆处理,混淆后的类名会因版本而异,这就需要根据App版本来管理覆盖率数据。
按版本管理数据后,每个版本会清除上一版本的数据,避免数据错乱;一个特定的类,在当前版本已经使用过之后,会记录下来,后续此版本的采集不再重复查询它的使用情况。
每个版本首次采集时,需要以App的类名全集作为输入,每一次采集会产生一个未使用类的集合,作为下一次采集的输入。这样,一个版本中每次采集需要关注的类数量会逐步减少,可避免无意义的查询,提升采集性能。
类名数据获取
类名数据可以通过两种方式获取:
1.从安装包获取
安装包内的类名数据可以从PathClassLoader中获取,插件则可以从对应的BaseDexClassLoader中获取,使用如下方法即可:
public static List<String> getClassesFromClassLoader(BaseDexClassLoader classLoader) throws ClassNotFoundException, IllegalAccessException { //类名数据位于BaseDexClassLoader.pathList.dexElements.dexFile中,可以通过反射获取 //先获取pathList字段 Field pathListF = ReflectUtils.getField("pathList", BaseDexClassLoader.class); pathListF.setAccessible(true); Object pathList = pathListF.get(classLoader); //获取pathList中的dexElements字段 Field dexElementsF = ReflectUtils.getField("dexElements", Class.forName("dalvik.system.DexPathList")); dexElementsF.setAccessible(true); Object[] array = (Object[]) dexElementsF.get(pathList); //获取dexElements中的dexFile字段 Field dexFileF = ReflectUtils.getField("dexFile", Class.forName("dalvik.system.DexPathList$Element")); dexFileF.setAccessible(true); ArrayList<String> classes = new ArrayList<>(256); for (int i = 0; i < array.length; i++) { //获取dexFile DexFile dexFile = (DexFile) dexFileF.get(array[i]); //遍历DexFile获取类名数据 Enumeration<String> enumeration = dexFile.entries(); while (enumeration.hasMoreElements()) { classes.add(enumeration.nextElement()); } } return classes; }
