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调试看看,后面再详细展开。

至于实现:

java 复制代码
// 自定义类加载器
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;
    }

    @Override
    protected 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;
        }
    }

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return findClass(name);
    }
}
java 复制代码
// 替换classLoader
public class MFApplication extends Application {
    public final static String TAG = ConstantValues.TAG;

    @Override
    protected 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;
    }
}

注意到上述代码中:

java 复制代码
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,这两个内容可以很轻松获取到,比如:

java 复制代码
// 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 "";
}

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

java 复制代码
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;
    }

    @Override
    protected 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();
    }

    @Override
    public 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. 相关链接

相关推荐
三少爷的鞋12 小时前
AI 时代,我们都将成为通才型开发者:只懂 Android,已经不够了
android
Dovis(誓平步青云)16 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
晚风叙码18 小时前
MySQL 数据类型详解:从数值到字符串,一篇讲透
android·mysql·adb
传奇开心果编程18 小时前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程21 小时前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer
事圆则缓1 天前
Android AOSP 定制常见概念:源码目录、系统镜像与刷机流程
android
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第2课 Compose 基础语法
android·windows·学习·ui·ios·kotlin·composer