前言
代码覆盖率(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收集,在确保高稳定性和不侵入源码的前提下,优雅地完成了出产环境代码覆盖率的高功能收集,现已过高德地图多版别验证,是一套成熟、稳定且高效的计划。在此共享出来,期望能为有相同诉求的同学提供一些借鉴和思路。