前言

代码覆盖率(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;}

这种形式简略间接,不过会一次性将DexFile中的所有类名加载到内存中,而依据咱们的测试,每一万个类大概占0.8mb内存,对于动辄数万个类的大型App来说,会是一个不小的内存开销。所以还能够思考第二种形式。

2.云化下载

从构建平台获取类名数据,上传到云化平台,App在须要的时候下载应用。

至于选用哪种形式,间接依据类数量来选取就好。类数量特地多时,如大型App场景,倡议应用云化形式;一般App或插件,间接从安装包类获取即可。

子过程采集

主过程未加载的类,咱们会交给子过程再次查问。这就须要子过程提供反对跨过程调用的查问接口,咱们抉择了简略牢靠,且容易复用的AIDL计划来实现。

具体做法是:

通过AIDL定义查问接口,并定义对应的Action,在Service的onBind办法中依据Action返回查问接口的Binder实现类用于近程调用。

同时思考到跨过程的老本较高,如果对每个类都调用一次查问接口,无疑是难以承受的。于是咱们想到了文件+批量查问的形式:利用文件作为数据载体,将已加载的类和未加载的类都写入到文件中,在接口间传递文件门路。文件操作还能够采纳BufferedReader和BufferedWriter以晋升性能。

调用过程如图:

这样做的益处也不言而喻:

  • 采集一个过程仅需一次跨过程调用,老本极低
  • 防止数据序列化的内存开销
  • 绕开大数据无奈间接跨过程传递的问题
  • 采集流程更简略,可按需采集须要的过程
  • 不便数据过滤,防止反复查问已加载类,晋升采集性能

插件采集

对于宿主类,查问PathClassLoader对应的ClassTable即可。

而插件个别通过BaseDexClassLoader或其派生类进行加载,须要查问相应ClassLoader的ClassTable。

对于在子过程中应用的插件,只是多了跨过程接口调用,将已加载类和残余类返回给主过程进行解决的操作。

采集步骤如下:

  • 查问子过程类时,会同时查问该过程中运行的插件类,将数据写入按插件名划分的文件。
  • 对主过程插件的采集是整个流程的最初一个环节,此时会检测每个插件对应的数据文件(子过程生成),并进行合并解决,最初将数据文件删除。
  • 最初再解决残余的插件数据文件,这部分文件属于只在子过程运行的插件。

到此,就失去了所有插件的类加载数据。

解Mapping

查看代码覆盖率数据时,咱们冀望看到原始的类名,所以解Mapping是必经之路。

解Mapping操作能够在端上进行,也能够在服务侧进行,出于安全性思考,咱们抉择了服务侧。

Mapping文件由打包过程生成,每个安装包对应一份。咱们的做法是在构建平台打正式包的时候通过脚本生成混同类与明文类的映射文件,服务端在须要的时候通过App版本信息获取对应的映射文件,反解出原始类名,并与模块进行关联。

最终展现到平台的就是解完Mapping,并与模块、插件实现关联的代码覆盖率数据。

数据存储及增量计算

采集的数据须要存储起来,为了不便计算增量数据,咱们抉择了数据库作为存储计划,因为它天生具备去重及排序功能,而且性能也不错。具体的做法是:

  • 创立一张数据表,只需蕴含一个名为class的列就行,该列申明为主键,不承受空值和反复。
  • 每次采集前,获取其中的行数,采集过程中,将已加载的类名数据更新到表中,让数据库主动实现去重。采集实现后,再次获取数据行数,与采集前的行数相减得出的offset就是增量局部,咱们只须要将这部分数据上传到服务。

性能和稳定性

通过咱们的重复测试和调优,对5w+类的采集均匀耗时约0.5s/次,采集期间内存增长在500kb左右,CPU无显著上涨。

同时也通过高德地图线上多个版本验证,未发现相干解体及ANR。

其余

绕开黑灰名单

Android P当前,官网将ClassTable成员变量退出了黑灰名单,在应用反射拜访之前,需绕开SDK限度。咱们采纳的是元反射+设置豁免的形式,具体的实现能够参考GitHub上的开源我的项目FreeReflection,想要理解更多可自行Google查问。

采集机会和频率

尽管采集过程短暂无感,但为了最小的影响App的运行,咱们将采集工作放在子线程中,并抉择在App退后台一段时间后开始执行。

同时因为咱们只须要晓得代码应用的比例和大抵状况,每次冷启后只采集一次即可。

多位用户屡次冷启后的数据,曾经足以反映实在的代码应用状况了。如果须要每个类的应用频次数据,在服务端聚合统计也能失去。

写在最初

代码覆盖率作为一种度量形式,不仅能为咱们下线旧代码提供根据,同时还能反映某个性能的应用热度,能够为资源分配、调度决策等提供根据,是软件开发中一项不可或缺的重要工具。

咱们这套全新的计划,简洁而不简略,奇妙地实现了无Hack采集,在保障高稳定性和不侵入源码的前提下,优雅地实现了生产环境代码覆盖率的高性能采集,曾经过高德地图多版本验证,是一套成熟、稳固且高效的计划。在此分享进去,心愿能为有同样诉求的同学提供一些借鉴和思路。