高德Android高性能高稳定性代码覆盖率技术实践

news2024/11/27 19:55:29

前言

代码覆盖率(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 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/976328.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Spring 6.0和SpringBoot 3.0新特性

目录 主要更新内容是以下几个&#xff1a; AOT编译 Spring Native GraalVM SpringBoot3生成二进制可执行文件底层流程 主要更新内容是以下几个&#xff1a; A Java 17 baselineSupport for Jakarta EE 10 with an EE 9 baselineSupport for generating native images with…

录音转写和AI质检的区别和使用场景

区别&#xff1a; 录音转写是将语音或录音转化为文本形式的过程。它通常涉及使用自然语言处理技术和语音识别算法来将音频文件中的语音转换为可读的文本格式。 AI质检是一种利用人工智能技术对客户服务、销售和其他电话中心交互进行自动化评估的过程。通过分析和评估客户和代…

【C++】STL案例2-员工分组

#include <iostream> using namespace std;#include<vector> #include<map>#define CHEHUA 0 //策划部门 #define MEISHU 1 //美术部门 #define YANFA 2 //研发部门class Worker { public:string m_Name;int m_Salary; };void createWorker(vector<…

flask使用Flask-Mail实现邮件发送

Flask-Mail可以实现邮件的发送&#xff0c;并且可以和 Flask 集成&#xff0c;让我们更方便地实现此功能。 1、安装 使用pip安装&#xff1a; $ pip install Flask-Mail或下载源码安装&#xff1a; $ git clone https://github.com/mattupstate/flask-mail.git $ cd flask-…

Java线程与锁常考知识点

Java线程与锁常考知识点 基础篇1. 创建线程的几种方式2. 线程池创建的方式3. 线程池提交任务的流程4. 线程池有哪些状态 进阶篇1. 说说对线程安全的理解2. 对守护线程的理解3. ThreadLocal的底层原理4. 并发、并⾏、串⾏之间的区别5. Java死锁如何避免&#xff1f;6. 线程池的底…

滑动窗口实例8(最小覆盖子串)

题目&#xff1a; 给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串&#xff0c;则返回空字符串 "" 。 注意&#xff1a; 对于 t 中重复字符&#xff0c;我们寻找的子字符串中该字符数量必须不少于 t…

C/C++中FILE指针的使用总结

在C中&#xff0c;FILE指针是一个用于文件操作的指针类型。它是C语言标准库中的一部分&#xff0c;也被C所继承。通过使用FILE指针&#xff0c;我们可以打开、读取、写入和关闭文件。 在C中&#xff0c;FILE指针通常与标准库中的文件操作函数一起使用&#xff0c;例如fopen(),…

通俗易懂玩QT:QStackedWidget 学习

QStackedWidget 学习 一、介绍 业务需求&#xff0c;用到了 QStackWidget 这个类&#xff0c;然后程序存在很严重的内存泄露问题&#xff0c;所以特意研究了一下 QStackWidget 类&#xff0c;QStackWidget 类的功能是窗体切换&#xff0c;它比 QTabWidget 使用起来更为灵活&a…

【高等数学基础知识篇】——不定积分

文章目录 一、不定积分的概念与基本性质1.1 原函数与不定积分的基本概念1.2 不定积分的基本性质 二、不定积分基本公式与积分法2.1 不定积分基本公式2.2 不定积分的积分法2.2.1 换元积分法2.2.2 分部积分法 三、两类重要函数的不定积分——有理函数与三角有理函数3.1 有理函数的…

架构设计基础设施保障IaaS计算

目录 1 IaaS概述2 服务部署演进历程3 云虚拟机4 云虚拟机如何选型5 云虚拟机的创建操作6 服务部署访问 1 IaaS概述 云计算并不是一种单一类型的产品&#xff0c;而是为满足企业各种IT需求而提供的多种服务。 通过云计算提供的一类这样的服务是基础设施即服务&#xff08;IaaS&a…

蓝桥杯备赛(Day5)——二叉树

二叉树存储 普通做法&#xff0c;二叉树一个节点包括结点的数值以及指向左右子节点的指针 在class Node中 def __init__(self,s,lNone,rNone):self.valNoneself.llself.rr 在竞赛中&#xff0c;我们往往使用静态数组实现二叉树&#xff0c;定义一个大小为N的静态结构体数组…

使用iCloud和Shortcuts实现跨设备同步与自动化数据采集

在如今的数字时代&#xff0c;跨设备同步和自动化数据采集对于提高工作效率和便利性至关重要。苹果的iCloud和Shortcuts App为我们提供了强大的工具&#xff0c;可以实现跨设备同步和自动化数据采集的功能。本文将详细介绍如何利用iCloud和Shortcuts App实现这些功能&#xff0…

英诺森供应链一体化平台解析

近日&#xff0c;2023年中国物流与采购联合会科学技术奖正式公布&#xff0c;该奖项经国家科技部批准&#xff0c;在国家科学技术奖励工作办公室登记备案&#xff0c;是我国物流行业最具影响力的奖项之一。 英诺森联合客户申报的科技项目“英诺森供应链智能数据平台”&#xf…

Golang编写客户端SDK,并开源发布包到GitHub,供其他项目import使用

目录 编写客户端SDK&#xff0c;并开源发布包到GitHub1. 创建 GitHub 仓库2. 构建项目&#xff0c;编写代码Go 代码示例&#xff1a;项目目录结构展示&#xff1a; 3. 提交代码到 GitHub仓库4. 发布版本5. 现在其他人可以引用使用你的模块包了 编写客户端SDK&#xff0c;并开源…

记一次以太网连接失败修复

症状: 很久没用这个电脑了&#xff0c;开机以后&#xff0c;发现连不上校园网。 遂检查网线&#xff0c;发现网线连在自己笔记本是可以用的&#xff0c;说明网线没问题。 但是网线连在主机是红灯常亮黄灯闪烁&#xff0c;怀疑是网卡有问题&#xff08;后证明不是&#xff0c…

成都车展:比亚迪“豹力美学”杀入硬派SUV市场

在熙熙攘攘人头攒动的2023成都国际车展上&#xff0c;如果要评选一家人气最旺的车企展台&#xff0c;那必然非比亚迪莫属。 在比亚迪现场展示的多款车型中&#xff0c;作为比亚迪旗下方程豹品牌的首款车型&#xff0c;以“超级混动硬派SUV”著称的方程豹5&#xff0c;自然吸引了…

nuxt中extendRoutes添加多个扩展路由

文档中https://www.nuxtjs.cn/api/configuration-router有写使用extendRoutes添加多个路由 添加多个路由&#xff0c;数组拼接只能使用push&#xff0c;其实均不管用

【王道】操作系统笔记 第一章 操作系统概述

1.1.1 操作系统的概念和功能 我们熟悉的操作系统有哪些&#xff1f; 从计算机系统的层次结构上看操作系统&#xff1a; 以一台电脑的诞生为例&#xff1a; 第一步&#xff0c;厂家组装一台裸机 第二步&#xff0c;出售前安装操作系统 第三步&#xff0c;用户安装应用程序 第四…

一日一技:Python如何同时调用多个GPT的API?

相信很多同学或多或少都在Python中使用过GPT API&#xff0c;通过Python安装openai库&#xff0c;来调用GPT模型。 OpenAI官方文档中给出了一个示例&#xff0c;如下图所示&#xff1a; OpenAI API 测试 如果你只有一个API账号&#xff0c;那么你可能不觉得这样写有什么问题。…

其他计算机系统基础知识

其他计算机系统基础知识 概述计算机语言多媒体系统工程系统工程方法切克兰德方法并行工程方法综合集成法WSR方法 系统工程的生命周期基于模型的系统工程 概述 不考 学系统工程就行 整体来说考的概率不大&#xff0c;以了解为主 计算机语言 多媒体 15年之前考过 系统工程 系统工…