Android类动态加载

July 15, 2015

本文译自戳我,似乎年代有点久远,但是应该作为基础知识学习一下。

Dalvik 虚拟机为开发者提供了动态加载类的功能。不但可以从默认的位置加载 dex 文件,应用还可以从外部位置,例如内部存储甚至网络加载它们。

当然这个技术并不是每个应用都需要的,事实上大多数应用都是不需要的。然后,有一些情况下,动态加载技术就显得格外有效了。下面列举一些情形:

  • 比较大的应用会有超过 64K(实际上是65536)的方法数,而一个 dex 文件支持的最大方法数就是 64k。为了应对这个限制,开发者可以将应用分割出第二个 dex 文件出来,并在运行时加载它们。
  • 应用架构被设计成需要能够支持在应用运行时动态加载代码执行逻辑的。

我们已经编写了一个案例应用为开发者们示范分割 dex 文件以及在运行时动态加载的操作了。(注意,该应用无法使用Eclipse ADT 去编译打包。而是应该使用已包含在内的一个 Ant 打包脚本,可以看 Readme.txt 获取更多信息。至于原因,下面会说明)

该应用有个基本的 Activity 调用了一个 lib 的方法去展示一个 Toast。这个 Activity 以及它的资源文件都在默认的 dex 中,(称为主 dex )。但是,上面提到的 lib 代码却存在于也打包进 APK 文件的第二个 dex 中。这需要一个修改过的打包过程,下面会详细介绍它。

在 lib 中的方法呗调用前,应用需要先主动去加载第二个 dex 文件。让我们来看一下有关的步骤。

代码组织

应用主要由 3 个类组成。

  • com.example.dex.MainActivity: 调用 lib 中方法的 UI 组件
  • com.example.dex.LibraryInrerface: lib 的接口代码
  • com.example.dex.LibraryProvider: lib 的实现代码

这个 lib 是被打包进第二个 dex 的,而其他剩余的类则被包含进主 dex 中。后面的 “打包过程”章节会说明如何完成这个任务。当然了,分包的策略是根据特定的场合由开发者自己决定的。

动态加载类以及方法调用

第二个 dex 中,包含了 LibrarProvider 类,存储在了应用的 asset 目录。首先,我们需要把这个 dex 文件转移至一个 class loader 可以访问的路径位置中。示例应用中使用了内部存储。(技术山来说,外部存储一样可以达到效果,但是必须考虑的是将应用的 dex 文件放在外部存储的潜在安全风险。)

下面的代码片段来自 MainActivity 用于完成 dex 文件的拷贝转移。

// Before the secondary dex file can be processed by the DexClassLoader,
// it has to be first copied from asset resource to a storage location.
File dexInternalStoragePath = new File(getDir("dex", Context.MODE_PRIVATE),
      SECONDARY_DEX_NAME);
...
BufferedInputStream bis = null;
OutputStream dexWriter = null;

static final int BUF_SIZE = 8 * 1024;
try {
    bis = new BufferedInputStream(getAssets().open(SECONDARY_DEX_NAME));
    dexWriter = new BufferedOutputStream(
        new FileOutputStream(dexInternalStoragePath));
    byte[] buf = new byte[BUF_SIZE];
    int len;
    while((len = bis.read(buf, 0, BUF_SIZE)) > 0) {
        dexWriter.write(buf, 0, len);
    }
    dexWriter.close();
    bis.close();
  
} catch (. . .) {...} 

接下来, 实例化一个 DexClassLoader 用于从去处的第二个 dex 中加载对用的 lib。在这种情形下加载的类,有几种不同的方式去调用它的方法。在示例应用中,对应的类实例被强转成接口,从而直接调用方法。

另外一种方法是通过反射调用,反射调用的好处是,不需要副 dex 去实现特定的接口。然而,要注意的是反射调用麻烦且低效。

// Internal storage where the DexClassLoader writes the optimized dex file to
final File optimizedDexOutputPath = getDir("outdex", Context.MODE_PRIVATE);

DexClassLoader cl = new DexClassLoader(dexInternalStoragePath.getAbsolutePath(),
                                     optimizedDexOutputPath.getAbsolutePath(),
                                     null,
                                     getClassLoader());
Class libProviderClazz = null;
try {
    // Load the library.
    libProviderClazz = cl.loadClass("com.example.dex.lib.LibraryProvider");
    // Cast the return object to the library interface so that the
    // caller can directly invoke methods in the interface.
    // Alternatively, the caller can invoke methods through reflection,
    // which is more verbose. 
    LibraryInterface lib = (LibraryInterface) libProviderClazz.newInstance();
    lib.showAwesomeToast(this, "hello");
} catch (Exception e) { ... }

打包过程

为了生成 2 个单独的 dex 文件,我们需要对标准的打包过程做一些改造。要做到这点,只需简单的修改工程文件中 Ant 所需的 build.xml 文件中 “-dex” 目标。

修改 “-dex” 目标有以下步骤:

  1. 创建 2 个目录用于存储会被转换成主 dex 和 副 dex 的 .class文件。
  2. 选择性的拷贝 PROJECT_ROOT/bin/classes 中的 .class 文件到 2 个目录中去。

     <!-- Primary dex to include everything but the concrete library implementation. -->
     <copy todir="${out.classes.absolute.dir}.1" >
     <fileset dir="${out.classes.absolute.dir}" >
         <exclude name="com/example/dex/lib/**" />
     </fileset>
     </copy>
     <!-- Secondary dex to include the concrete library implementation. -->
     <copy todir="${out.classes.absolute.dir}.2" >
     <fileset dir="${out.classes.absolute.dir}" >
         <include name="com/example/dex/lib/**" />
     </fileset>
     </copy>     
    
  3. 转换 2 个目录中的 .class 文件为 2 个分开的 dex 文件。
  4. 将副 dex 文件转换为一个 jar 包,因为这是 DexClassLoader 输入需要的格式。最后,将这个 jar 包存储在工程的 “asset” 目录。

     <!-- Package the output in the assets directory of the apk. -->
     <jar destfile="${asset.absolute.dir}/secondary_dex.jar"
           basedir="${out.absolute.dir}/secondary_dex_dir"
          includes="classes.dex" />
    

在工程根目录下执行 ant debug(或者release)命令触发打包。

大功告成!在合适的情况下,动态加载技术会十分有用。