高德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来说,会是一个不小的内存开销。所以还可以考虑第二种方式。
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采集,在保证高稳定性和不侵入源码的前提下,优雅地实现了生产环境代码覆盖率的高性能采集,已经过高德地图多版本验证,是一套成熟、稳定且高效的方案。在此分享出来,希望能为有同样诉求的同学提供一些借鉴和思路。
高德Android高性能高稳定性代码覆盖率技术实践的更多相关文章
- 微信团队分享:iOS版微信的高性能通用key-value组件技术实践
本文来自微信开发团队guoling的技术分享. 1.前言 本文要分享的是iOS版微信内部正在推广和使用的一个高性能通用key-value 组件的技术实践过程,该组件在微信内部被命名为MMKV(以下简称 ...
- 腾讯技术分享:GIF动图技术详解及手机QQ动态表情压缩技术实践
本文来自腾讯前端开发工程师“ wendygogogo”的技术分享,作者自评:“在Web前端摸爬滚打的码农一枚,对技术充满热情的菜鸟,致力为手Q的建设添砖加瓦.” 1.GIF格式的历史 GIF ( Gr ...
- IPv6技术详解:基本概念、应用现状、技术实践(下篇)
本文来自微信技术架构部的原创技术分享. 1.前言 在上篇<IPv6技术详解:基本概念.应用现状.技术实践(上篇)>,我们讲解了IPV6的基本概念. 本篇将继续从以下方面展开对IPV6的讲解 ...
- IPv6技术详解:基本概念、应用现状、技术实践(上篇)
本文来自微信技术架构部的原创技术分享. 1.前言 普及IPV6喊了多少年了,连苹果的APP上架App Store也早已强制IPV6的支持,然并卵,因为历史遗留问题,即使在IPV4地址如果饥荒的情况下, ...
- Android端代码染色原理及技术实践
导读 高德地图开放平台产品不断迭代,代码逻辑越来越复杂,现有的测试流程不能保证完全覆盖所有业务代码,测试不到的代码及分支,会存在一定的风险.为了保证测试全面覆盖,需要引入代码覆盖率做为测试指标,需要对 ...
- 高性能高可用的微服务框架TarsGo的腾讯实践
conference/2.3 高性能高可用的微服务框架TarsGo的腾讯实践 - 陈明杰.pdf at master · gopherchina/conferencehttps://github.co ...
- 从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践
本文由马蜂窝技术团队电商交易基础平台研发工程师"Anti Walker"原创分享. 一.引言 即时通讯(IM)功能对于电商平台来说非常重要,特别是旅游电商. 从商品复杂性来看,一个 ...
- 让互联网更快:新一代QUIC协议在腾讯的技术实践分享
本文来自腾讯资深研发工程师罗成在InfoQ的技术分享. 1.前言 如果:你的 App,在不需要任何修改的情况下就能提升 15% 以上的访问速度,特别是弱网络的时候能够提升 20% 以上的访问速度. 如 ...
- Redis数据库云端最佳技术实践
欢迎大家前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~ 本文由腾讯云数据库 TencentDB发表于云+社区专栏 邹鹏,腾讯高级工程师,腾讯云数据库Redis负责人,多年数据库.网络安全研发经验. ...
- 子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践
本文原文内容来自InfoQ的技术分享,本次有修订.勘误和加工,感谢原作者的分享. 1.前言 自从2018年8月20日子弹短信在锤子发布会露面之后(详见<老罗最新发布了“子弹短信”这款IM,主打熟 ...
随机推荐
- docker安装带postgis插件的postgresql 数据库
最初直接拉取的postgresql 数据,在导入 .bakup 文件时始终会报错,最后才想到该数据库默认不带postgis空间组件 一.拉取镜像 这里我们拉取postgres 和 gis 组合的镜像 ...
- C# EFCore 根据Oracle/SqlServer数据库表生成实体类和DbContext
官方文档: https://docs.microsoft.com/zh-cn/ef/core/managing-schemas/scaffolding?tabs=dotnet-core-cli 本文以 ...
- Java(类与对象、封装)
面向过程.面向对象 面向对象编程(Object-Oriented Programming, OOP) 本质:以类的方式组织代码,以对象的组织(封装)数据. 抽象 三大特性 封装 继承 多态 从认识论的 ...
- 2023-06-17:说一说redis中渐进式rehash?
2023-06-17:说一说redis中渐进式rehash? 答案2023-06-17: 在Redis中,如果哈希表的数组一直保持不变,就会增加哈希冲突的可能性,从而降低检索效率.为了解决这个问题,R ...
- 【tvm解析】 Operator Strategy 机制
本文地址:https://www.cnblogs.com/wanger-sjtu/p/15082871.html Relay Operator Strategy是建立Relay IR与TOPI算子库的 ...
- GPT3的应用场景:从文本生成到智能推荐
目录 1. 引言 2. 技术原理及概念 2.1 基本概念解释 2.2 技术原理介绍 2.3 相关技术比较 3. 实现步骤与流程 3.1 准备工作:环境配置与依赖安装 3.2 核心模块实现 3.3 集成 ...
- sql server注入rce实践
背景:在漏洞挖掘中,合理的利用sql注入,可以把注入转换成rce,使一个高危漏洞变成严重漏洞.在红蓝对抗中,利用注入rce,实现内网横向移动.笔者基于漏洞挖掘和红蓝对抗上遇到的sql server注入 ...
- PostgreSQL 12 文档: 部分 VI. 参考
部分 VI. 参考 这份参考中的条目意欲提供关于相应主题的权威.完整和正式的总结.关于使用PostgreSQL的更多信息(以叙述.教程或例子的形式)可以在本书的其他部分找到.见每个参考页面上列出的交叉 ...
- Unity的IGenerateNativePluginsForAssemblies:深入解析与实用案例
Unity IGenerateNativePluginsForAssemblies Unity是一款非常流行的游戏引擎,它支持多种平台,包括Windows.Mac.Linux.Android.iOS等 ...
- .Net Core 如何数据导出 Excel?(EPPlus->OfficeOpenXml 实现固定列和动态列导出)
〇.前言 对于将数据以 Excel 表格文件输出,还是比较常用的,也存在诸多情况,比如列固定或不固定.数据类型为 List<T>或 Json 对象等. 本文通过包 OfficeOpenXm ...