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进行查询,即使这个类没有加载,也会执行加载操作。”
这里对其进行了验证,上述结论大概是错误的。写了个案例:
观察源码:
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 Hack访问ClassTable
正如原文所诉,高德采用的是【复制ClassTable指针,通过标准API间接访问类加载状态的方案】,但更详细的细节在文章中并没有披露。网络上有篇类似的处理:一种Android已加载类检测方法
因为没怎么写过native层代码,这里还需要了解下xhook,后面看看怎么实现。待补充…
3. 相关链接
- Android 常见热修复方案及原理
- 另一种绕过 Android P以上非公开API限制的办法
- Android高性能高稳定性代码覆盖率技术实践