关于高德地图:高德Android高性能高稳定性代码覆盖率技术实践
前言 代码覆盖率(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来说,会是一个不小的内存开销。所以还能够思考第二种形式。 ...