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

相关推荐
奔跑吧 android2 小时前
【android bluetooth 协议分析 07】【SDP详解 2】【SDP 初始化】
android·bluetooth·aosp15·bt·gd·sdp_init
xchenhao7 小时前
基于 Flutter 的开源文本 TTS 朗读器(支持 Windows/macOS/Android)
android·windows·flutter·macos·openai·tts·朗读器
coder_pig8 小时前
跟🤡杰哥一起学Flutter (三十五、玩转Flutter滑动机制📱)
android·flutter·harmonyos
消失的旧时光-19439 小时前
OkHttp SSE 完整总结(最终版)
android·okhttp·okhttp sse
ansondroider10 小时前
OpenCV 4.10.0 移植 - Android
android·人工智能·opencv
hsx66613 小时前
Kotlin return@label到底怎么用
android
itgather14 小时前
安卓设备信息查看器 - 源码编译
android
whysqwhw14 小时前
OkHttp之buildSrc模块分析
android
hsx66614 小时前
从源码角度理解Android事件的传递流程
android