前言
代码覆盖率 (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 采集,在保障高稳定性和不侵入源码的前提下,优雅地实现了生产环境代码覆盖率的高性能采集,曾经过高德地图多版本验证,是一套成熟、稳固且高效的计划。在此分享进去,心愿能为有同样诉求的同学提供一些借鉴和思路。