Android动态加载入坑指南

曾几何时,国内各大公司掀起了一股研究Android动态加载的技术,两年多过去了,动态加载技术俨然成了Android开发中必须掌握的技术。那么动态加载技术是什么呢,这里谈谈我的个人看法,如有雷同,纯属偶然。

什么是动态加载技术

对于动态加载的概念,没有一个权威的定义,参考网上的解释,我们举一个例子,动态加载代码就是通过在运行时加载外部代码(磁盘,网络等)改变程序行为的技术(感觉有点像装饰者模式)。主要目的是为了达到让用户不用重新安装APK就能升级应用的功能。

为了加深大家对这种概念的理解,我们结合pc端来说说手机端的动态加载。

传统的pc端动态加载

熟悉Java的同学应该比较清楚,Java的可执行文件是Jar,运行在虚拟机上JVM上,虚拟机通过ClassLoader加载Jar文件并执行里面的代码。所以Java程序也可以通过动态调用Jar文件达到动态加载的目的。

动态加载技术在PC软件领域广泛使用,比如qq上线的时候忘了某个功能的修复,这个时候就可以用动态加载来修复我们的bug。

Android应用的动态加载技术

Android应用类似于Java程序,只不过虚拟机换成了Dalvik/ART,而Jar换成了Dex。我们知道,在Android的apk文件中往往有一个或者多个Dex文件,系统的类加载器(PathDexClassLoader)加载的就是dex文件,虽然一个apk一旦构建出来,我们是无法更换里面的Dex文件的,但是我们可以在类加载动态加载外部的dex文件来达到动态加载的目的。

JVM 类加载机制

JVM 的类加载机制是双亲委派模型,这里贴上JVM加载的图解。

对于上面这张图,我们有以下几点需要说明。

  • BootStrapClassLoader是顶级的类加载器,它是唯一一个不继承自ClassLoader中的类加载器,它高度集成于 JVM是ExtensionClassLoader的父加载器,它的类加载路径是JDK\jre\lib 和 用户指定的虚拟机参数-Xbootclasspath的值。
  • ExtensionClassLoader 是 BootStrapClassLoader 的子加载器,同时是 SystemClassLoader(有的地方称 AppClassLoader)的父加载器,它的类加载路径是 JDK\jre\lib\ext 和系统属性 java.ext.dirs 的值。
  • SystemClassLoader 是 ExtensionClassLoader 的子加载器,同时是我们的应用程序的类加载器,我们在应用程序中编写的类一般情况下(如果没有到动态加载技术的话)都是通过这个类加载加载的。它的类加载路径是环境变量 CLASSPATH 的值或者用户通过命令行可选项 -cp (-classpath) 指定的值。
  • 类加载器由于父子关系形成树形结构,开发人员可以开发自己的类加载器从而实现动态加载功能,但必须给这个类加载器指定树上的一个节点作为它的父加载器。
  • 因为类加载器是通过包名和类名(或者说类的全限定名),所以由于委派式加载机制的存在,全限定名相同的类不会在有 祖先—子孙 关系的类加载器上分别加载一次,不管这两个类的实现是否一样。
  • 不同的类加载器加载的类一定是不同的类,即使它们的全限定名一样。如果全限定名一样,那么根据上一条,这两个类加载器一定没有 祖先-子孙 的关系。这样来看,可以通过自定义类加载器使得相同全限定名但实现不同的类存在于同一 JVM 中,也就是说,类加载器相当于给类在包名之上又加了个命名空间。
  • 如果两个相同全限定名的类由两个非 祖先-子孙 关系的类加载器加载,这两个类之间通过instanceof 和 equals() 等进行比较时总是返回false

安卓应用和普通的 java 应用不同,它们运行于 Dalvik 虚拟机。JVM 是基于栈的虚拟机,而 Dalvik 是基于寄存器的虚拟机。Android采用 dex 作为储存类字节码信息的文件。当 java 程序编译成 class 后,编译器会使用 dx 工具将所有的class 文件整合到一个 dex 文件,目的是使其中各个类能够共享数据,在一定程度上降低了冗余,同时也是文件结构更加紧凑。

为了说明Android的类加载机制,我们需要对Android的ClassLoader做一个了解。

安卓中两个重要的类加载器:DexClassLoader 和 PathClassLoader。

那么对于Android来说,我们来看看Android的加载模型。

DexClassLoader & PathClassLoader说明

我们首先看一些这两个类。

package dalvik.system;
import java.io.File;

public class DexClassLoader extends BaseDexClassLoader {
   
    public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
}
package dalvik.system;
public class PathClassLoader extends BaseDexClassLoader {
   
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }
    public PathClassLoader(String dexPath, String libraryPath, ClassLoader parent) {
        super(dexPath, null, libraryPath, parent);
    }
}

可以看到,这两个类加载器都是继承自 BaseDexClassLoader,只是分别实现了自己的构造方法。

  • BaseDexClassLoader
public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, librarySearchPath, optimizedDirectory);
}

我们发现BaseDexClassLoader作为一个基类,其构造极其简单,它做了两件事:连接了父加载器;构造了一个 DexPathList 实例保存在 pathList 中。

参数意思如下:

  • 第一个参数指的是我们要加载的 dex 文件的路径,它有可能是多个 dex 路径,取决于我们要加载的 dex 文件的个数,多个路径之间用 : 隔开。
  • 第二个参数指的是优化后的 dex 存放目录。实际上,dex 其实还并不能被虚拟机直接加载,它需要系统的优化工具优化后才能真正被利用。优化之后的 dex 文件我们把它叫做 odex (optimized dex,说明这是被优化后的 dex)文件。其实从 class 到 dex 也算是经历了一次优化,这种优化的是机器无关的优化,也就是说不管将来运行在什么机器上,这种优化都是遵循固定模式的,因此这种优化发生在 apk 编译。而从 dex 文件到 odex 文件,是机器相关的优化,它使得 odex 适配于特定的硬件环境,不同机器这一步的优化可能有所不同,所以这一步需要在应用安装等运行时期由机器来完成。需要注意的是,在较早版本的系统中,这个目录可以指定为外部存储中的目录,较新版本的系统为了安全只允许其为应用程序私有存储空间(/data/data/apk-package-name/)下的目录,一般我们可以通过 Context#getDir(String dirName) 得到这个目录。
  • 第三个参数的意义是库文件的的搜索路径,一般来说是 .so 库文件的路径,也可以指明多个路径。
  • 第四个参数就是要传入的父加载器,一般情况我们可以通过 Context#getClassLoader() 得到应用程序的类加载器然后把它传进去。

好了,到这里就很清楚了,Dalvik 虚拟机要加载的 dex 文件的路径(DexPathList),那么Dalvik是如何找到Dex的呢?有人会说反射,对,大方向对了。那么我们看看系统究竟是怎么做的。

  DexPathList

public DexPathList(ClassLoader definingContext, String dexPath,
            String librarySearchPath, File optimizedDirectory) {

        if (definingContext == null) {
            throw new NullPointerException("definingContext == null");
        }

        if (dexPath == null) {
            throw new NullPointerException("dexPath == null");
       }

       if (optimizedDirectory != null) {
           if (!optimizedDirectory.exists())  {
               throw new IllegalArgumentException(
                       "optimizedDirectory doesn't exist: "
                       + optimizedDirectory);
           }

           if (!(optimizedDirectory.canRead()
                           && optimizedDirectory.canWrite())) {
               throw new IllegalArgumentException(
                       "optimizedDirectory not readable/writable: "
                       + optimizedDirectory);
           }
       }

       this.definingContext = definingContext;

       ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
       // save dexPath for BaseDexClassLoader
       this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                              suppressedExceptions, definingContext);

       // Native libraries may exist in both the system and
       // application library paths, and we use this search order:
       //
       //   1. This class loader's library path for application libraries (librarySearchPath):
       //   1.1. Native library directories
       //   1.2. Path to libraries in apk-files
       //   2. The VM's library path from the system property for system libraries
       //      also known as java.library.path
       //
       // This order was reversed prior to Gingerbread; see http://b/2933456.
       this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
       this.systemNativeLibraryDirectories =
               splitPaths(System.getProperty("java.library.path"), true);
       List<File> allNativeLibraryDirectories = new ArrayList<>(nativeLibraryDirectories);
       allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);

       this.nativeLibraryPathElements = makePathElements(allNativeLibraryDirectories,
                                                         suppressedExceptions,
                                                         definingContext);

       if (suppressedExceptions.size() > 0) {
           this.dexElementsSuppressedExceptions =
               suppressedExceptions.toArray(new IOException[suppressedExceptions.size()]);
       } else {
           dexElementsSuppressedExceptions = null;
       }
}

这里我们主要看如下几行代码:

his.definingContext = definingContext;

       ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
       // save dexPath for BaseDexClassLoader
       this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                              suppressedExceptions, definingContext);

       // Native libraries may exist in both the system and
       // application library paths, and we use this search order:
       //
       //   1. This class loader's library path for application libraries (librarySearchPath):
       //   1.1. Native library directories
       //   1.2. Path to libraries in apk-files
       //   2. The VM's library path from the system property for system libraries
       //      also known as java.library.path
       //
       // This order was reversed prior to Gingerbread; see http://b/2933456.
       this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
       this.systemNativeLibraryDirectories =
               splitPaths(System.getProperty("java.library.path"), true);
       List<File> allNativeLibraryDirectories = new ArrayList<>(nativeLibraryDirectories);
       allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);

       this.nativeLibraryPathElements = makePathElements(allNativeLibraryDirectories,
                                                         suppressedExceptions,
                                                         definingContext);

这段代码主要是给 dexElements和nativeLibraryPathElements赋值。我们知道Android在通过默认的虚拟机dex后,会继续优化为odex 文件。

dexElements 是通过 makeDexElements() 方法得到的。makeDexElements的方法里面我们主要关注前面两个参数,我们来看一下splitDexPath(dexPath)。

private static List<File> splitPaths(String searchPath, boolean directoriesOnly) {
       List<File> result = new ArrayList<>();

       if (searchPath != null) {
           for (String path : searchPath.split(File.pathSeparator)) {
               if (directoriesOnly) {
                   try {
                       StructStat sb = Libcore.os.stat(path);
                       if (!S_ISDIR(sb.st_mode)) {
                           continue;
                       }
                   } catch (ErrnoException ignored) {
                       continue;
                   }
               }
               result.add(new File(path));
           }
       }

       return result;
}

这个方法很简单就是用,分隔的路径分割后保存为 File 类型的列表返回。现在看看 makeDexElements() 这个方法:

private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
                                            List<IOException> suppressedExceptions,
                                            ClassLoader loader) {
     return makeElements(files, optimizedDirectory, suppressedExceptions, false, loader);
}
private static Element[] makeElements(List<File> files, File optimizedDirectory,
                                          List<IOException> suppressedExceptions,
                                          boolean ignoreDexFiles,
                                         ClassLoader loader) {
       Element[] elements = new Element[files.size()];
       int elementsPos = 0;
       /*
        * Open all files and load the (direct or contained) dex files
        * up front.
        */
       for (File file : files) {
           File zip = null;
           File dir = new File("");
           DexFile dex = null;
           String path = file.getPath();
           String name = file.getName();

           if (path.contains(zipSeparator)) {
               String split[] = path.split(zipSeparator, 2);
               zip = new File(split[0]);
               dir = new File(split[1]);
           } else if (file.isDirectory()) {
               // We support directories for looking up resources and native libraries.
               // Looking up resources in directories is useful for running libcore tests.
               elements[elementsPos++] = new Element(file, true, null, null);
           } else if (file.isFile()) {
               if (!ignoreDexFiles && name.endsWith(DEX_SUFFIX)) {
                   // Raw dex file (not inside a zip/jar).
                   try {
                       dex = loadDexFile(file, optimizedDirectory, loader, elements);
                   } catch (IOException suppressed) {
                       System.logE("Unable to load dex file: " + file, suppressed);
                       suppressedExceptions.add(suppressed);
                   }
               } else {
                   zip = file;

                   if (!ignoreDexFiles) {
                       try {
                           dex = loadDexFile(file, optimizedDirectory, loader, elements);
                       } catch (IOException suppressed) {
                           /*
                            * 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).
                            * Let dex == null and hang on to the exception to add to the tea-leaves for
                            * when findClass returns null.
                            */
                           suppressedExceptions.add(suppressed);
                       }
                   }
               }
           } else {
               System.logW("ClassLoader referenced unknown path: " + file);
           }

           if ((zip != null) || (dex != null)) {
               elements[elementsPos++] = new Element(dir, false, zip, dex);
           }
        }
       if (elementsPos != elements.length) {
           elements = Arrays.copyOf(elements, elementsPos);
       }
       return elements;
}

通过代码我们可以大致了解到,这个方法就是将之前的File对象通过重新组合成一个新的Elements对象,然后我们Loader读取的就是Element对象。看一下 loadDexFile() 怎样加载 DexFile 的

private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader,
                                      Element[] elements) throws IOException {
       if (optimizedDirectory == null) {
           return new DexFile(file, loader, elements);
       } else {
           String optimizedPath = optimizedPathFor(file, optimizedDirectory);
           return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);
       }
}

先说明下无论是 DexFile(File file, Classloader loader, Elements[] elements) 还是 DexFile.loadDex() 最终都会调用 DexFile(String sourceName, String outputName, int flags, ClassLoader loader, DexPathList.Element[] elements) 这个构造方法。所以这个方法的逻辑就是:如果 optimizedDirectory 为 null,那么就直接利用 file 的路径构造一个 DexFile;否则就根据要加载的 dex(或者包含了 dex 的 zip) 的文件名和优化后的 dex 存放的目录组合成优化后的 dex(也就是 odex)文件的输出路径,然后利用原始路径和优化后的输出路径构造出一个DexFile.

分析完这两字段,现在我们回过头来看看 DexPathList 这个对象,这个对象持有 dexElements 和 nativeLibraryPathElements 这两个属性,也就是说它保存了 dex 和 本地方法库。

为了加深大家对DexPathList的理解,我们来看看官方的说明。

A pair of lists of entries, associated with a {@code ClassLoader}. One of the lists is a dex/resource path — typically referred to as a “class path” — list, and the other names directories containing native code libraries. Class path entries may be any of: a {@code .jar} or {@code .zip} file containing an optional top-level {@code classes.dex} file as well as arbitrary resources, or a plain {@code .dex} file (with no possibility of associated resources).</br>This class also contains methods to use these lists to look up classes and resources.

大概的意思就是 DexPathList 的作用和 JVM 中的 classpath 的作用类似,JVM 根据 classpath 来查找类,而 Dalvik 利用 DexPathList 来查找并加载类。DexPathList 包含的路径可以是 .dex 文件的路径,也可以是包含了 dex 的 .jar 和 .zip 文件的路径。

BaseClassLoader 加载器的类加载过程

我们知道,一个类加载器的入口方法是 loadClass()。这是Java语音所共有的。类加载器通过findClass()找到所需要加载的类。

protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
        Class<?> clazz = findLoadedClass(className);

        if (clazz == null) {
            ClassNotFoundException suppressed = null;
            try {
                clazz = parent.loadClass(className, false);
            } catch (ClassNotFoundException e) {
                suppressed = e;
            }

            if (clazz == null) {
                try {
                    clazz = findClass(className);
                } catch (ClassNotFoundException e) {
                    e.addSuppressed(suppressed);
                    throw e;
                }
            }
        }

        return clazz;
    }

BaseDexClassLoader 也继承自 ClassLoader,因此我们就从 findClass() 方法来分析下 BaseClassLoader 加载类的过程。

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
       List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
       Class c = pathList.findClass(name, suppressedExceptions);
       if (c == null) {
           ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
           for (Throwable t : suppressedExceptions) {
               cnfe.addSuppressed(t);
           }
           throw cnfe;
       }
       return c;
}

这个方法极其简单,主要风格findclass找到类 Class c = pathList.findClass(name, suppressedException)这里BaseClassLoader 把查找类的任务委托给了 pathList。那么我们来看一下Android的DexPathList的findClass又做了什么事情。

public Class findClass(String name, List<Throwable> suppressed) {
       for (Element element : dexElements) {
           DexFile dex = element.dexFile;

           if (dex != null) {
               Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
               if (clazz != null) {
                   return clazz;
               }
           }
       }
       if (dexElementsSuppressedExceptions != null) {
           suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
       }
       return null;
}

它遍历了 dexElements 中的所有 DexFile,通过 DexFile 的loadClassBinaryName() 方法加载目标类。dexElements 又把查找类的任务委托给了DexFile

private DexFile(String sourceName, String outputName, int flags, ClassLoader loader,
           DexPathList.Element[] elements) throws IOException {
       if (outputName != null) {
           try {
               String parent = new File(outputName).getParent();
               if (Libcore.os.getuid() != Libcore.os.stat(parent).st_uid) {
                   throw new IllegalArgumentException("Optimized data directory " + parent
                           + " is not owned by the current user. Shared storage cannot protect"
                           + " your application from code injection attacks.");
               }
           } catch (ErrnoException ignored) {
               // assume we'll fail with a more contextual error later
           }
       }

       mCookie = openDexFile(sourceName, outputName, flags, loader, elements);
       mFileName = sourceName;
       //System.out.println("DEX FILE cookie is " + mCookie + " sourceName=" + sourceName + " outputName=" + outputName);
}

到这里我们就已经很明白了,openDexFile调用openDexFileNative()方法,(

mCookie = openDexFile(sourceName, outputName, flags, loader, elements);

它做的事就是把对应的 dex 文件加载到内存中,然后返回给 java 层一个类似句柄一样的东西 Object:mCookie。

在构造方法中 DexFile 就完成了 dex 文件的加载过程。现在我们回到 DexFile 对象的loadClassBinaryName()

public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
       return defineClass(name, loader, mCookie, this, suppressed);
}

private static Class defineClass(String name, ClassLoader loader, Object cookie,
                                    DexFile dexFile, List<Throwable> suppressed) {
       Class result = null;
       try {
           result = defineClassNative(name, loader, cookie, dexFile);
       } catch (NoClassDefFoundError e) {
           if (suppressed != null) {
               suppressed.add(e);
           }
       } catch (ClassNotFoundException e) {
           if (suppressed != null) {
               suppressed.add(e);
           }
       }
       return result;
}

看到这里我们明白了,class 对象在 java 层加载过程的尽头就是这个 defineClass() 方法,这个方法调用本地法 defineClassNative() 从 dex 中查找目标类,如果找到了,就把这个代表这个类的 Class 对象返回。到此,Android的加载过程我们终于看完了。

到这里我们回头看看Android的两个类加载器:DexClassLoader() 和 PathClassLoader()。

DexClassLoader 用来加载 .dex 文件以及包含 dex 文件的 .jar、.zip 和未安装的 .apk 文件,因此需要指定优化后的 dex 文件的输出路径;

PathClassLoader 一般用来加载已经安装到设备上的.apk,因为应用在安装的时候已经对 apk 文件中的 dex 进行了优化,并且会输出到 /data/dalvik-cache 目录下(android M 在这目录下找不到,应该是改成了 /data/app/com.example.app-x/oat 目录下),所以它不需要指定优化后 dex 的输出路径。

常用的插件化和动态加载都是基于DexClassLoader来实现的,如果有需要的请点链接 点击打开链接

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏技术小黑屋

你的Java代码对JIT编译友好么?

本文为 InfoQ 中文站特供稿件,首发地址为:你的Java代码对JIT编译友好么?。如需转载,请与 InfoQ 中文站联系。

32820
来自专栏王亚昌的专栏

程序的存储空间布局

栈 由编译器自动分配释放管理。局部变量及每次函数调用时返回地址、以及调用者的环境信息(例如某些机器寄存器)都存放在栈中。新被调用的函数在栈上为其自动和临时变量分...

14620
来自专栏idealclover的填坑日常

C++ cin清理缓冲区

C++在用cin做输入的时候,尤其是需要输入int型值的时候,往往需要做验证与处理。而对于异常值,则需要先清理缓冲区,再准备下一次输入。

29920
来自专栏程序员互动联盟

【编程基础第十三讲】代码如何写才最漂亮第二篇

存在问题: 好多小伙伴对编码的格式作用模糊,以为只要完成功能就行,其实这种观点是错误的,一定要重视代码规范,不然你哭的地都找不到。 如何实施: 良好的代码开发习...

26380
来自专栏C语言及其他语言

C语言中EOF是什么意思?

相信很多朋友在学习C语言过程中,都看到过EOF的字样,但翻过整本C语言的书,也没有看到有这个函数或者关键字的,岂不是感觉很奇怪?难道学的不细有疏漏吗?当然不是!...

48850
来自专栏程序人生 阅读快乐

C语言编程魔法书:基于C11标准

主要讲解C11标准的语法内容,并且从整个编译、连接到加载过程都会涉及。同时在后会分别介绍GCC编译器与Clang编译器的C语言语法扩展。通过阅读本书,读者能够完...

19720
来自专栏Kevin-ZhangCG

Java开发岗面试知识点解析

400110
来自专栏java一日一条

有效处理Java异常的三个原则,你知道吗?

在有效使用异常的情况下,异常类型回答了“什么”被抛出,异常堆栈跟踪回答了“在哪“抛出,异常信息回答了“为什么“会抛出,如果你的异常没有回答以上全部问题,那么可能...

19010
来自专栏MelonTeam专栏

Kotlin 初体验: 用 Kotlin 写命令行工具

导语 :可喜可贺, kotlin 在今年的 google I/O 大会上, 成为 google android 平台的新一门官方语言, 偶尔有了个写工具的机会试...

49470
来自专栏Java3y

多线程基础必要知识点!看了学习多线程事半功倍

22880

扫码关注云+社区

领取腾讯云代金券