背景
在一开始写Android的时候经常碰到一些ClassNotFoundException,大部分情况下是少导入了什么包导致的。我碰到一个困扰了一年之久的ClassNotFoundException,终于在这两天我解决了这个问题,下面让我给大家表演一下真正的技术。
我四年前写了个路由组件,一年前打算优化下注册逻辑,之前的注册逻辑是用ClassLoader去寻找特定包名下的所有class,然后去反射的方式实现的。我打算写了一个Plugin插件,通过transfrom的方式把所有的apt生成的class向一个注册类内插入,然后在初始化的时候调用这个注册类完成注册流程。
但是在插件写好之后,我只要一运行项目就会抛出一个ClassNotFoundException,报错内容如下。
Process: com.kronos.router, PID: 4643
java.lang.RuntimeException: Unable to instantiate activity ComponentInfo{com.kronos.router/com.kronos.sample.MainActivity}: java.lang.ClassNotFoundException: Didn't find class "com.kronos.sample.MainActivity" on path: DexPathList[[zip file "/data/app/com.kronos.router-2/base.apk"],nativeLibraryDirectories=[/data/app/com.kronos.router-2/lib/x86, /system/lib, /vendor/lib]]
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2567)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2726)
at android.app.ActivityThread.-wrap12(ActivityThread.java)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1477)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:154)
at android.app.ActivityThread.main(ActivityThread.java:6119)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776)
Caused by: java.lang.ClassNotFoundException: Didn't find class "com.kronos.sample.MainActivity" on path: DexPathList[[zip file "/data/app/com.kronos.router-2/base.apk"],nativeLibraryDirectories=[/data/app/com.kronos.router-2/lib/x86, /system/lib, /vendor/lib]]
at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:56)
at java.lang.ClassLoader.loadClass(ClassLoader.java:380)
at java.lang.ClassLoader.loadClass(ClassLoader.java:312)
at android.app.Instrumentation.newActivity(Instrumentation.java:1078)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2557)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2726)
at android.app.ActivityThread.-wrap12(ActivityThread.java)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1477)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:154)
at android.app.ActivityThread.main(ActivityThread.java:6119)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776)
Suppressed: java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/appcompat/app/AppCompatActivity;
at java.lang.VMClassLoader.findLoadedClass(Native Method)
at java.lang.ClassLoader.findLoadedClass(ClassLoader.java:742)
at java.lang.ClassLoader.loadClass(ClassLoader.java:362)
而我用jadx反编译了apk,在反编译后的项目内,是能找到所有的class的,然后因为工作原因我也就搁置了一段时间,然后断断续续,周末还是会去看看这个问题。

问题突破口
这两天正好在看《深入理解JVM虚拟机》的虚拟机类加载机制这章,其中的类加载的验证机制其实启发了我,先走下流程看下类的验证的释义。
类的验证
验证阶段是链接阶段的第一步,目的就是确保class文件的字节流中包含的信息符合虚拟机的要求,不能危害虚拟机自身安全。验证阶段主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。
-
文件格式验证
验证class文件格式规范
-
元数据验证
就是对字节码描述的信息进行语义分析,保证描述的信息符合java语言规范。验证点可能包括(这个类是否有父类(除Object)、这个类是否继承了不允许被继承的类(final修饰的)、如果这个类的父类是抽象类,是否实现了父类或接口中要求实现的方法)。
-
字节码验证
进行数据流和控制流分析,这个阶段对类的方法体进行校验,保证被校验的方法在运行时不会做出危害虚拟机的行为。
-
符号引用验证
符号引用中通过字符串描述的权限定名是否能找到对应的类、符号引用类中的类,字段和方法的访问性(private protected public default)是否能被当前类访问。
那么有没有可能在验证这个地方抛出的异常类似,然后导致这个类加载失败,导致了我上面的crash呢。
饭还是要一口一口吃,我们先从抛出这个异常的地方开始跟进吧。
Android ClassLoader
这几天查了下资料,同时翻看了下ClassLoader的源代码,安卓的类加载机制基本上来说和Java的是一样的。而ClassNotFoundException这个异常是在ClassLoader在loadClass方法触发的时候抛出的异常。
sample项目比较简单,所以默认使用的是PathDexClassLoader,而findClass方法还是调用的BaseDexClassLoader。
BaseDexClassLoader
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = pathList.findClass(name);
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}
上面是BaseDexClassLoader的findClass方法,简单的说当在ptahList内能找到你的类的情况下,返回class类,如果class没有找到就会抛出ClassNotFoundException。
那么问题来了,我反编译包中这些class都是存在的,那么问题在哪呢??????从findClass分析的话,那么罪魁祸首只有可能是DexPathList。
DexPathList
private final DexPathList pathList;
/**
* Constructs an instance.
*
* @param dexPath the list of jar/apk files containing classes and
* resources, delimited by {@code File.pathSeparator}, which
* defaults to {@code ":"} on Android
* @param optimizedDirectory directory where optimized dex files
* should be written; may be {@code null}
* @param libraryPath the list of directories containing native
* libraries, delimited by {@code File.pathSeparator}; may be
* {@code null}
* @param parent the parent class loader
*/
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(parent);
this.originalPath = dexPath;
this.pathList =
new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
首先在BaseDexClassLoader构造方法中初始化了DexPathList对象,然后在findClass使用的就是这个DexPathList对象。
/**
* Makes an array of dex/resource path elements, one per element of
* the given array.
*/
private static Element[] makeDexElements(ArrayList<File> files,
File optimizedDirectory) {
ArrayList<Element> elements = new ArrayList<Element>();
/*
* Open all files and load the (direct or contained) dex files
* up front.
*/
for (File file : files) {
ZipFile zip = null;
DexFile dex = null;
String name = file.getName();
if (name.endsWith(DEX_SUFFIX)) {
// Raw dex file (not inside a zip/jar).
try {
dex = loadDexFile(file, optimizedDirectory);
} catch (IOException ex) {
System.logE("Unable to load dex file: " + file, ex);
}
} else if (name.endsWith(APK_SUFFIX) || name.endsWith(JAR_SUFFIX)
|| name.endsWith(ZIP_SUFFIX)) {
try {
zip = new ZipFile(file);
} catch (IOException ex) {
/*
* Note: ZipException (a subclass of IOException)
* might get thrown by the ZipFile constructor
* (e.g. if the file isn't actually a zip/jar
* file).
*/
System.logE("Unable to open zip file: " + file, ex);
}
try {
dex = loadDexFile(file, optimizedDirectory);
} catch (IOException ignored) {
/*
* IOException might get thrown "legitimately" by
* the DexFile constructor if the zip file turns
* out to be resource-only (that is, no
* classes.dex file in it). Safe to just ignore
* the exception here, and let dex == null.
*/
}
} else {
System.logW("Unknown file type for: " + file);
}
if ((zip != null) || (dex != null)) {
elements.add(new Element(file, zip, dex));
}
}
return elements.toArray(new Element[elements.size()]);
}
DexPathList 封装了dex路径,这是一个final类,而且访问权限是包权限,也就是说外界不可继承,也不可访问这个类。在DexPathList构造的时候会根据路径。去生成了一个dex数组,相信看过热修复机制的朋友看到这些应该已经比较熟悉了。
DexPathList
/**
* Finds the named class in one of the dex files pointed at by
* this instance. This will find the one in the earliest listed
* path element. If the class is found but has not yet been
* defined, then this method will define it in the defining
* context that this instance was constructed with.
*
* @return the named class or {@code null} if the class is not
* found in any of the dex files
*/
public Class findClass(String name) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext);
if (clazz != null) {
return clazz;
}
}
}
return null;
}
当我们调用DexPathList去findClass的时候,就是去遍历所有的dexElements实例(顺手讲下热修复原理,不就是把dex加载到Elements的最前面,当最前面的dex有值的情况下就不会调用后面的dex去生成实例),然后从dex实例中去获取到我们的类,如果没找到那么就会返回一个null。
有没有可能是别的原因导致的呢,dex数组一开始在加载的时候就出现问题了呢????
DexFile和类加载验证
其实我在解决异常的时候,在ClassNotFoundException上面发现了另外一个Log日志的。W/m.kronos.route: Failure to verify dex file '/data/app/com.kronos.router-vMP1wsKFwirBk84bH8e11Q==/base.apk': Invalid type descriptor: 'V;'
其实我看到这个日志的时候大概已经知道问题所在了。其实这个报错就是我插入的字节码不合法,然后这个dex加载失败了。
但是本着需要探索下宇宙的边界在哪里的精神,我决定还是深挖一下。以下大部分基于我的猜测,并没有实际证据支撑,如果有误导或者问题请各位大佬指正啊。
/**
* Opens a DEX file from a given filename. This will usually be a ZIP/JAR
* file with a "classes.dex" inside.
*
* The VM will generate the name of the corresponding file in
* /data/dalvik-cache and open it, possibly creating or updating
* it first if system permissions allow. Don't pass in the name of
* a file in /data/dalvik-cache, as the named file is expected to be
* in its original (pre-dexopt) state.
*
* @param fileName
* the filename of the DEX file
*
* @throws IOException
* if an I/O error occurs, such as the file not being found or
* access rights missing for opening it
*/
public DexFile(String fileName) throws IOException {
mCookie = openDexFile(fileName, null, 0);
mFileName = fileName;
guard.open("close");
//System.out.println("DEX FILE cookie is " + mCookie);
}
/*
* Open a DEX file. The value returned is a magic VM cookie. On
* failure, an IOException is thrown.
*/
native private static int openDexFile(String sourceName, String outputName,
int flags) throws IOException;
我们先看下类加载机制的流程图

- 加载
这个阶段我个人看法,就是在ClassLoader的构造函数执行的过程。从安卓出发应该就是BaseClassLoader初始化过程中把所有.dex文件读入到ClassLoader内存中。
- 验证
这个阶段我个人看法,就是DexFile类的openDexFile方法被执行完之后,这个native代码应该会去验证.dex文件内容是否合法。如果非法则不会加载这个dex文件。
- 后续流程
后续流程我认为则是和class的构造什么的相关的,并不在文章的讨论范围之内。
结论
首先要多尊重下字节码,因为在插桩过程中并没有代码的有效性检查的情况下,我们没法保证我们插入的字节码是一个没有错误的代码,特别是在安卓中,因为多个.class文件会被打成一个.dex,如果其中有一个.class文件的格式有问题的情况下,就会导致这个dex挂载失败,然后吧就会抛出一些奇奇怪怪的类找不到的问题。就像这个异常,其实和我插入的类并没有任何关系一样。
其次在源码的追溯过程中,更深入的感受了下java的类加载机制,虽然我也不能确定我的理解是不是有偏差,毕竟和这方面相关的资料实在有限,我甚至都没找到是如何验证代码格式的这段逻辑。
最后吧,问题才会让人成长,让人记忆更深刻。吃一堑长一智,之前在准备面试的过程中我还是看过一部分类加载机制的,但是和天书一样,今天看明天忘,偶尔吃个亏还是不错的。