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,就能正常访问了。但总的来说很鸡肋。
- 优点:实现上简单,且比较容易理解。
- 缺点:存在性能问题;远程化、插件化下的多ClassLoader存在覆盖不到的问题;替换前就被加载的类及在其中被new出来的类和替换后加载的类不是同一个ClassLoader问题,apk运行时候就存在代码安全隐患,虽然加白能解决但太过于麻烦。
2.3 findLoadedClass
在2.2中我们验证了findLoadedClass其实是OK的,那么实际上我们也就能够通过findLoadedClass来获取到所有加载过的类信息。注意到:
该方法修饰符为protected,也即是正常情况下我们需要通过反射的方式来获取到PathClassLoader,并继续反射调用它的findLoadedClass方法,以获取其加载状态。
那么当我们的类很多的时候,多次调用反射去执行findLoadedClass方法必然会对性能带来负面的影响。同样的,也天然具有2.2节中无法检测到独立ClassLoader所加载的类情况,除非我们预先能知道整个apk运行期间有多少个自定义ClassLoader。存在覆盖率上的问题。即:
- 优点:简单,容易实现,且无代码安全隐患
- 缺点:可能会引入较大的性能问题(执行耗时),独立ClassLoader检测不到的覆盖率问题。
2.4 Hack访问ClassTable
正如原文所诉,高德采用的是【复制ClassTable指针,通过标准API间接访问类加载状态的方案】,但更详细的细节在文章中并没有披露。网络上有篇类似的处理:一种Android已加载类检测方法
阅读材料:
- bhook:https://github.com/bytedance/bhook/blob/main/doc/native_manual.zh-CN.md
- VirtualXposed:https://github.com/android-hacker/VirtualXposed/blob/122beb371519cb2d221ce06756361aaa30e2674f/VirtualApp/lib/src/main/jni/Foundation/fake_dlfcn.cpp#L4
- https://github.com/feicong/android-rom-book/tree/main/chapter-09
- 类加载虚拟机层
正如上面文章一种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检测不到的覆盖率问题。