Android 代码热度统计(概述)

1. 前言

代码热度统计,在测试中一般也叫做代码覆盖率。一般得到代码覆盖率后就能了解整体样本在线上的代码使用情况,为无用代码下线提供依据。

做了一下调研,在Android中一般比较常用的是:JaCoCO覆盖率统计工具,它采用构建时插桩,APP运行采集覆盖数据,并可本地可视化展示的一套完整链路。使用可参考:Android 代码覆盖率统计

但大量插桩必然会带来性能、包大小上的劣势,相关更详细的使用和分析可以参考高德的这篇文章:Android 端代码染色原理及技术实践

在高德的另一篇文章:高德Android高性能高稳定性代码覆盖率技术实践 中也提到了其实代码热度统计有多种方式,如下图:
在这里插入图片描述
但,正如文章中所诉,Jacoco、Hook PathClassLoader方案虽然兼容性极强,但均会影响性能和包大小,故不适合上线到生产环境中。而通过ClassLoader的findLoadedClass方案:

在Android中对于App自定义的类,即PathClassLoader加载的类,如果直接调用findLoadedClass进行查询,即使这个类没有加载,也会执行加载操作。

很明显,不合适。故上述适合生产环境的方案只有一种,即:Hack访问ClassTable方案。

2. 方案介绍

Jacoco更加适用于测试同学功能验证,对比查看验证功能逻辑对应的代码覆盖情况,以确保不漏测。

相关教程网络上很多,比如:搜索到一篇相关的文章:滴滴开源 Super-jacoco:java 代码覆盖率收集平台文档 可以了解下,它增强了本地测试验证中的增量代码覆盖程度统计。

2.1 插桩的另一种方案

前文介绍了,Jacoco的插桩方式采集粒度很细,带来的apk包大小增量和性能的增量是较大的。而注意到,高德介绍的后三种的采集粒度都是class,那么对应的其实我们可以只在每个class的init方法中插桩,这样无论是apk包大小增量还是性能的负面影响都会低很多。我们自己实现也挺简单,可以参考字节的byteX:coverage-plugin。

这种方案同样不能覆盖到插件化、远程化这些动态加载的Class,且每个类的init或者cinit方案去插桩埋点,本身会有包大小、运行性能的损耗,比较鸡肋。

2.2 Hook PathClassLoader方案

在插件化、远程化过程中,我们一般需要自定义一个PathClassLoader来替换APP一启动创建的ClassLoader,这样我们就能拦截在application的attachBaseContext之后的findClass或者loadClass行为,故而就能知道当前启动访问了哪些类。

实现方案比较简单,可以参考Qigsaw的SplitDelegateClassloader塞入的过程。或者可以参考这篇文章:Android旁门左道之动态替换应用程序。关键逻辑即为:通过context获取到LoadedApk mPackageInfo,在LoadedApk里面定义的ClassLoader mClassLoader即为待替换的目标。如果替换失败了,可再替换ContextImpl中的ClassLoader mClassLoader;作为兜底。

至于为什么需要这么替换,需要了解APP的启动,可以阅读ActivityThread开始追代码和debug调试看看,后面再详细展开。

至于实现:

// 自定义类加载器
public class MFClassLoader extends PathClassLoader {public final static String TAG = ConstantValues.TAG;private static BaseDexClassLoader originClassLoader;public MFClassLoader(ClassLoader parent) {super("", parent);originClassLoader = (BaseDexClassLoader) parent;}@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException {Log.e(TAG, "====> findClass: " + name);// U can upload info to server. then analysis all datas.try {return originClassLoader.loadClass(name);} catch (ClassNotFoundException error) {error.printStackTrace();throw error;}}@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {return findClass(name);}
}
// 替换classLoader
public class MFApplication extends Application {public final static String TAG = ConstantValues.TAG;@Overrideprotected void attachBaseContext(Context base) {super.attachBaseContext(base);attachBaseContextCallBack(base);}private void attachBaseContextCallBack(Context base) {boolean b = replaceClassLoader(base, new MFClassLoader(MFApplication.class.getClassLoader()));Log.e(TAG, "====> attachBaseContext --> [replace classloader " + b + "]");}private boolean replaceClassLoader(Context baseContext, ClassLoader reflectClassLoader) {try {Object packageInfo = HiddenApiReflection.findField(baseContext, "mPackageInfo").get(baseContext);if (packageInfo != null) {HiddenApiReflection.findField(packageInfo, "mClassLoader").set(packageInfo, reflectClassLoader);}Log.e(TAG, "===> replaceClassLoader by packageInfo.");return true;} catch (Throwable e) {e.printStackTrace();}try {HiddenApiReflection.findField(baseContext, "mClassLoader").set(baseContext, reflectClassLoader);Log.e(TAG, "===> replaceClassLoader by Context.");return true;} catch (Throwable e) {e.printStackTrace();}return false;}
}

注意到上述代码中:

public MFClassLoader(ClassLoader parent) {// public PathClassLoader(String dexPath, ClassLoader parent) super("", parent);originClassLoader = (BaseDexClassLoader) parent;
}

对应的dexPath传入的是一个空值,也即是实际上类查找的时候所使用的ClassLoader还是originClassLoader去加载Class,而每个类对应的Class对象的classLoader属性中记录了当前加载的类加载器对象,也就是实际上还是会记录的是originClassLoader。那么后续我们在任意一个类中,通过this.getClass().getClassLoader获取到的ClassLoader对象还是原来的originClassLoader,自然在该对象中new xxx()对象,还是使用的originClassLoader,也就是后续的类查找,其实我们自定义的MFClassLoader其实感知不到。那么如何解决?

这里其实很简单,那就是让当前我们定义的MFClassLoader去查找真正的类。也即是需要在初始化的时候传入dexPath和librarySearchPath,这两个内容可以很轻松获取到,比如:

// dexPath 无远程化、插件化情况,一般就只有base.apk
// 如:/data/app/com.mengfou.honeynote-X_rWVreU1BlVpRYlCS-5Jw==/base.apk
context.getPackageCodePath()
// librarySearchPath 同理,一般也为base apk的lib目录
// 如:/data/app/com.mengfou.honeynote-X_rWVreU1BlVpRYlCS-5Jw==/lib/arm64
private String getPathFromReflect(ClassLoader originalClassLoader) {try {Field pathListField = HiddenApiReflection.findField(originalClassLoader, "pathList");pathListField.setAccessible(true);Object pathList = pathListField.get(originalClassLoader);Field nativeLibraryDirectoriesField = HiddenApiReflection.findField(pathList, "nativeLibraryDirectories");nativeLibraryDirectoriesField.setAccessible(true);List<File> nativeLibraryDirectories = (List<File>) nativeLibraryDirectoriesField.get(pathList);if(nativeLibraryDirectories != null) {Log.e(TAG, "===> MFClassLoader nativeLibraryDirectories: " + nativeLibraryDirectories.get(0) );return nativeLibraryDirectories.get(0).getAbsolutePath();}} catch (NoSuchFieldException | IllegalAccessException e) {e.printStackTrace();}return "";
}

那么对应的自定义类加载器就改写为:

public class MFClassLoader extends PathClassLoader {public final static String TAG = ConstantValues.TAG;private static BaseDexClassLoader originClassLoader;// 第一种实现 public MFClassLoader(ClassLoader originalClassLoader) {super("", originalClassLoader);originClassLoader = (BaseDexClassLoader) originalClassLoader;}// 第二种实现public MFClassLoader(String dexPath, String libraryPath, ClassLoader originalClassLoader) {super(dexPath, libraryPath,  originalClassLoader.getParent());originClassLoader = (BaseDexClassLoader) originalClassLoader;}@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException {Class<?> aClass;try {aClass = super.findClass(name);} catch (ClassNotFoundException e) {aClass = originClassLoader.loadClass(name);}Log.e(TAG, beautifulPrint(name, aClass.getClassLoader().getClass().getCanonicalName()));return aClass;}private String beautifulPrint(String name, String canonicalName) {int length = name.length();StringBuilder stringBuilder = new StringBuilder("===> findClass: ");stringBuilder.append(name);while(length < 80) {stringBuilder.append(" ");length++;}stringBuilder.append(canonicalName);return stringBuilder.toString();}@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {return findClass(name);}
}

这样修改后,几乎所有的类我们都能感知到,比如:
在这里插入图片描述
测试某个类中查找未加载的类:
在这里插入图片描述
我们的自定义ClassLoader也拦截到了。

值得注意的是,前面参考的博客中指出“为了提升启动性能,对于App自定义的类,即PathClassLoader加载的类,如果直接调用findLoadedClass进行查询,即使这个类没有加载,也会执行加载操作。

这里对其进行了验证,上述结论大概是错误的。且Android官方文档也说明了该API:
在这里插入图片描述

写了个案例验证:
在这里插入图片描述
观察源码:

class PathClassLoader extends BaseDexClassLoader
class BaseDexClassLoader extends ClassLoader

而 findLoadedClass(name);方法的调用只出现在ClassLoader类中。可通过cs.android.com来查阅。而在ClassLoader类的loadClass方法中我们可以看见这样的一个调用:
在这里插入图片描述
进入该方法:
在这里插入图片描述

走到了VMClassLoader的一个native方法。也即是art/runtime/native/java_lang_VMClassLoader.cc。至少源码反应在Java代码层未做主动load。而至于native方法中是否有在findLoadedClass方法,去加载,待考究,后面再看。

回到主题【Hook PathClassLoader方案】,在一定程度上确实可行,但一般大型apk中都有动态dex/apk,会自定义ClassLoader,这部分会检测不到。另外,因为我们是在Application的attach方法中进行的替换ClassLoader,那么其实在替换之前就加载的类查找也是使用原有的ClassLoader,也即是还会丢失部分数据。比如:
在这里插入图片描述
这里我们构建对象的ClassLoader就是原来的PathClassLoader。因为MFApplication是该classLoader加载的。

而且这样会存在代码安全隐患,因为也就是在APP启动后至少是在Application和其余代码中间就存在两个ClassLoader,因为两个ClassLoader在第二种实现中是独立的,也就是分别在两个ClassLoader中获取到的对象,其实数据毫无关系,比如我们在Application中存储了一下this,然后期望在后面某个由自定义ClassLoader加载的实例化类去访问存储的Application,但其实正常情况情况下访问不到,比如:
在这里插入图片描述
调用后会报错,NPE。而如果用第一种实现就无该问题,因为本质上都使用的originalClassLoader,但我们自定义的ClassLoader这个时候就无用了,因为几乎不能拦截和记录到findClass的过程。

那么如果需要用第二种实现,我们就需要对工程进行改造,确保在自定义Application中没有访问非替换ClassLoader的类,显然有点强人所难,因为实际开发中,我们确实会使用自定义Application的各个回调接口来定义加载某些类,比如初始化框架、启动器等。

略微一想其实也能解决,就是处理比较麻烦。比如这里保存的Application的类,若后面有自定义ClassLoader加载的类中访问Application中new出来的对象的类,我们可以加个白名单:
在这里插入图片描述
如上图所示,让自定义Application和ContextManager用originalClassLoader,就能正常访问了。但总的来说很鸡肋。

  1. 优点:实现上简单,且比较容易理解。
  2. 缺点:存在性能问题;远程化、插件化下的多ClassLoader存在覆盖不到的问题;替换前就被加载的类及在其中被new出来的类和替换后加载的类不是同一个ClassLoader问题,apk运行时候就存在代码安全隐患,虽然加白能解决但太过于麻烦。

2.3 findLoadedClass

在2.2中我们验证了findLoadedClass其实是OK的,那么实际上我们也就能够通过findLoadedClass来获取到所有加载过的类信息。注意到:
在这里插入图片描述
该方法修饰符为protected,也即是正常情况下我们需要通过反射的方式来获取到PathClassLoader,并继续反射调用它的findLoadedClass方法,以获取其加载状态。

那么当我们的类很多的时候,多次调用反射去执行findLoadedClass方法必然会对性能带来负面的影响。同样的,也天然具有2.2节中无法检测到独立ClassLoader所加载的类情况,除非我们预先能知道整个apk运行期间有多少个自定义ClassLoader。存在覆盖率上的问题。即:

  1. 优点:简单,容易实现,且无代码安全隐患
  2. 缺点:可能会引入较大的性能问题(执行耗时),独立ClassLoader检测不到的覆盖率问题。

2.4 Hack访问ClassTable

正如原文所诉,高德采用的是【复制ClassTable指针,通过标准API间接访问类加载状态的方案】,但更详细的细节在文章中并没有披露。网络上有篇类似的处理:一种Android已加载类检测方法

阅读材料:

  1. bhook:https://github.com/bytedance/bhook/blob/main/doc/native_manual.zh-CN.md
  2. VirtualXposed:https://github.com/android-hacker/VirtualXposed/blob/122beb371519cb2d221ce06756361aaa30e2674f/VirtualApp/lib/src/main/jni/Foundation/fake_dlfcn.cpp#L4
  3. https://github.com/feicong/android-rom-book/tree/main/chapter-09
  4. 类加载虚拟机层

正如上面文章一种Android已加载类检测方法所诉:
在这里插入图片描述
Hack访问ClassTable方式本质上还是传入每个类去查找这个类是否被loaded,同样的classTable在每个ClassLoader中都不一样,所以也需要找到所有的classloader,但有个好处就是没有替换全局PathClassLoader那样,需要考虑和处理由于存在两个PathClassLoader所引入的代码安全性隐患。但实际上,如果某个动态加载的apk/dex,使用的是独立自定义的ClassLoader来加载,那么其实还是会丢失数据。

这么来说,其实这里【Hack访问ClassTable】方案和【Hook PathClassLoader】方案的优势就是:① 无需处理由于存在两个PathClassLoader所引入的代码安全性隐患;② 在native层调用lookup方法来查找,可能性能会略优于2.3节的方案,但还是需要遍历所有的Class name去做匹配(但无需频繁反射调用findLoadedClass这种Java层代码)。
缺点:独立ClassLoader检测不到的覆盖率问题。

2.5 参考博客https://juejin.cn/post/7282606413842612283

3. 相关链接

  • Android 常见热修复方案及原理
  • 另一种绕过 Android P以上非公开API限制的办法
  • Android高性能高稳定性代码覆盖率技术实践

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

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

相关文章

RAG优化

RAG搭建本地AI知识库&#xff0c;在使用过程中遇到的三大痛点&#xff0c;以及相应的进阶方案。1. RAG知识库的三大痛点-- 内容理解不足&#xff1a;AI难以全面理解导入资料的内容&#xff0c;比如在向量编码时候&#xff0c;生硬的截断等导致分析结果不理想。eg: 知识库分割器…

Ubuntu 24.04 启用 root 图形登录

关键词&#xff1a;Ubuntu 24.04、root 登录、GDM、SSH、nano、配置文件一、前言 Ubuntu 默认禁用 root 账户 的图形与 SSH 登录&#xff0c;这是为了安全。但在某些场景&#xff08;如测试、救援、自动化脚本&#xff09;你可能需要 直接用 root 登录 GNOME 桌面。本文以 Ubun…

Jekyll + Chirpy + GitHub Pages 搭建博客

Chirpy 是适用于技术写作的简约、响应迅速且功能丰富的 Jekyll 主题&#xff0c;文档地址&#xff1a;https://chirpy.cotes.page/ &#xff0c;Github 地址&#xff1a;jekyll-theme-chirpy 。 1.开始 打开 chirpy-starter 仓库&#xff0c;点击按钮 Use this template -->…

学习 Flutter (一)

学习 Flutter (一) 1. 引言 什么是 Flutter&#xff1f; Flutter 是 Google 开发的一套开源 UI 框架&#xff0c;主要用于构建高性能、高保真、跨平台的应用程序。使用一套 Dart 编写的代码&#xff0c;开发者可以同时构建适用于&#xff1a; Android iOS Web Windows、mac…

Spring Boot 实现图片防盗链:Referer 校验与 Token 签名校验完整指南

Spring Boot 实现图片防盗链教程&#xff08;Referer 校验 Token 签名校验&#xff09;本文将详细讲解两种防盗链实现方案&#xff0c;并提供完整代码示例。方案一&#xff1a;Referer 校验通过检查 HTTP 请求头中的 Referer 字段判断来源是否合法。实现步骤创建 Referer 拦截…

从 JSON 到 Python 对象:一次通透的序列化与反序列化之旅

目录 一、为什么要谈 JSON 二、最快速上手&#xff1a;两把钥匙 dumps 与 loads 三、深入 dumps&#xff1a;参数是魔法棒 四、深入 loads&#xff1a;把风险挡在门外 五、文件级序列化&#xff1a;dump 与 load 六、处理中文与编码陷阱 七、异常场景与调试技巧 八、实…

Leetcode 3315. 构造最小位运算数组 II

1.题目基本信息 1.1.题目描述 给你一个长度为 n 的质数数组 nums 。你的任务是返回一个长度为 n 的数组 ans &#xff0c;对于每个下标 i &#xff0c;以下 条件 均成立&#xff1a; ans[i] OR (ans[i] 1) nums[i] 除此以外&#xff0c;你需要 最小化 结果数组里每一个 a…

黑搜小知识 | DNS域名解析过程是什么样的?

什么是DNS&#xff1f;DNS( Domain Name System)是“域名系统”的英文缩写&#xff0c;是一种组织成域层次结构的计算机和网络服务命名系统&#xff0c;它用于TCP/IP网络&#xff0c;它所提供的服务是用来将主机名和域名转换为IP地址的工作。举例来说&#xff0c;如果你要访问域…

MyBatis 使用教程及插件开发

作者&#xff1a;小凯 沉淀、分享、成长&#xff0c;让自己和他人都能有所收获&#xff01; 本文的宗旨在于通过简单干净实践的方式教会读者&#xff0c;使用 SpringBoot 配置 MyBatis 并完成对插入、批量插入、修改、查询以及注解事务和编程事务的使用&#xff0c;通过扩展插件…

Maui劝退:用windows直接真机调试iOS,无须和Mac配对

初级代码游戏的专栏介绍与文章目录-CSDN博客 我的github&#xff1a;codetoys&#xff0c;所有代码都将会位于ctfc库中。已经放入库中我会指出在库中的位置。 这些代码大部分以Linux为目标但部分代码是纯C的&#xff0c;可以在任何平台上使用。 源码指引&#xff1a;github源…

【极客日常】后端任务动态注入执行策略的一种技术实现

近期做项目时遇到一个场景&#xff0c;是需要在后端任务执行时动态注入策略。具体而言&#xff0c;笔者负责的后端服务&#xff0c;可以理解是会在线上服务发布时&#xff0c;对服务风险做实时扫描&#xff0c;那么这个扫描就需要根据当前线上服务发布上下文&#xff0c;匹配对…

8. JVM类装载的执行过程

1. JVM介绍和运行流程-CSDN博客 2. 什么是程序计数器-CSDN博客 3. java 堆和 JVM 内存结构-CSDN博客 4. 虚拟机栈-CSDN博客 5. JVM 的方法区-CSDN博客 6. JVM直接内存-CSDN博客 7. JVM类加载器与双亲委派模型-CSDN博客 8. JVM类装载的执行过程-CSDN博客 9. JVM垃圾回收…

Linux操作系统之信号:信号的产生

前言&#xff1a;上篇文章我们大致讲解了信号的有关概念&#xff0c;为大家引入了信号的知识点。但光知道那些是远远不够的。本篇文章&#xff0c;我将会为大家自己的讲解一下信号的产生的五种方式&#xff0c;希望对大家有所帮助。一、键盘&#xff08;硬件&#xff09;产生信…

pdf拆分

文章目录 背景目标实现下载 背景 好不容易下载的1000页行业报告&#xff0c;领导非要按章节拆分成20份&#xff01;学术论文合集需要按作者拆分投稿&#xff0c;手动分页到怀疑人生…客户发来加密合同&#xff0c;要求每5页生成独立文档&#xff0c;格式还不能乱&#xff01; …

vue3使用mermaid生成图表,并可编辑

效果图实际代码<template><div class"mermaid-container" style"z-index: 99999" ref"wrapperRef"><!-- 控制栏 --><div class"control-bar"><div class"control-bar-flex control-bar-tab-wrap"…

tcp/quic 的滑动窗口

一、滑动窗口 rwnd&#xff1a; 接收端窗口&#xff0c;接收方在每次发送ACK确认报文时&#xff0c;会包含一个 rwnd (Receive Window Size) 字段&#xff0c;指明自己当前剩余的接收缓冲区大小&#xff08;即可用窗口&#xff09;&#xff0c;这里是否是socket的接收缓冲区&am…

JVM监控及诊断工具-命令行篇

18.1. 概述 性能诊断是软件工程师在日常工作中需要经常面对和解决的问题&#xff0c;在用户体验至上的今天&#xff0c;解决好应用的性能问题能带来非常大的收益。 Java 作为最流行的编程语言之一&#xff0c;其应用性能诊断一直受到业界广泛关注。可能造成 Java 应用出现性能…

Jenkins 版本升级与插件问题深度复盘:从 2.443 到 2.504.3 及功能恢复全解析

前言&#xff1a;问题溯源与升级必要性 在 Jenkins 持续集成体系中&#xff0c;插件生态是其强大功能的核心驱动力。然而&#xff0c;某次例行维护中&#xff0c;团队对 Jenkins 2.443 环境的插件进行批量升级后&#xff0c;意外触发连锁反应 &#xff1a; SSH Server 插件功能…

Ribbon实战

一、前置知识 1.1 负载均衡定义 负载均衡指的是将网络请求通过不同的算法分配到不同的服务器上的技术&#xff0c;从而提升系统的性能。 1.2 负载均衡工具 负载均衡工具可以分分为客户端负载均衡工具和服务端负载均衡工具&#xff0c;它们的区别如下。 表1-1 负载均衡工具…

cs285学习笔记(一):课程总览

根据 Fall 2023 学期的官方课程日程&#xff0c;这里是 CS 285 全课程的 Lecture 大纲及内容摘要&#xff0c;详细对应周次和主题&#xff0c;方便你快速定位每节课要点、相关作业与视频资源 &#x1f3af; 官方课程地址 YouTobe 视频地址 blibli视频(带中文字幕) &#x…