Android Dex分包

96
the_q
2017.05.07 20:57* 字数 2750

最近项目apk方法数即将达到65536上限,虽然通过瘦身减少了一些方法数,但是随着更多sdk的接入,终究还是避免不了方法数突破限制,所以开始着手dex分包的工作。

之所以存在方法数不能超过65536的限制主要有两个原因
1.dex文件格式的限制:dex文件中的方法个数使用short类型来存储的,即2个字节,最大值为65536,即单个dex文件的方法数上限为65536
2.系统对dex文件进行优化操作时分配的缓冲区大小的限制:在android2.x的系统上缓冲区只有5MB,android4.x为8MB或者16MB,如果方法数量超过缓冲区的大小时,会造成dexopt崩溃

所以我们一般apk的方法数要控制在65536以内,如果超出,就要考虑dex分包。

简单案例

首先新建一个简单的工程


src.png

MainActivity中有一个button,点击后跳转到OtherActivity,MainActivity中调用了HelperOne的方法

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    Button button = (Button)findViewById(R.id.btn);
    button.setOnClickListener(new OnClickListener() {

        @Override
        public void onClick(View arg0) {

            Intent intent = new Intent(MainActivity.this, OtherActivity.class);
            startActivity(intent);
        }
    });

    HelperOne helperOne = new HelperOne();
    helperOne.test();
}

OtherActivity也非常简单,初始化了HelperTwo对象并调用其方法,而HelperTwo中又调用了HelperThree类的方法

下面先采用ant的方式来对该工程进行多dex打包

dex分包——ant方式

ant脚本执行顺序

init-> clean-> dirs-> resource-src-> aidl-> compile -> dex-> package-res -> package-> copy_dex -> add-subdex-to-apk -> jarsigner ->zipalign -> debug

整个流程简单概括如下:

  1. init、clean、dirs 清理并创建输出目录
  2. resource-src 根据资源文件、manifest文件生成R.java
  3. aidl 对aidl文件进行处理生成对应的class文件
  4. compile 编译java源文件(包括R.java)生成class文件
  5. dex 将编译后的class文件和引入的jar包打包成dex文件,通过配置参数此处可以生成多个dex文件
  6. package-res 使用aapt工具处理资源文件,生成.ap_文件,其中包括编译后的资源文件、资源索引表resources.arsc、manifest文件等
  7. package 使用apk-builder将上述步骤生成的dex文件(主dex文件)、.ap_文件等打包生成apk文件,需要注意的是这里只能将主dex打包到apk中,不支持同时将多个dex文件打包到apk,所以还需要以下的步骤
  8. copy_dex 将第5步生成的多个dex文件拷贝至指定目录
  9. add-subdex-to-apk 通过aapt工具将上述除主dex外的其他dex文件添加到apk文件中
  10. jarsigner 对apk文件进行签名
  11. zipalign 对apk包进行字节对齐等优化操作

与普通的apk文件流程基本上一致,需要注意的是5、8、9这几个步骤,下面来详细看一下这几个target

5 target dex

  <target
        name="dex"
        depends="compile" >

        <echo>Converting compiled files and external libraries into ${outdir}/${dex-file}...</echo>
        <apply
            executable="${dx}"
            failonerror="true"
            parallel="true" >
            <arg value="--dex" />
            <!-- 多dex命令-->
            <arg value="--multi-dex" />
            <!-- 每个包最大的方法数为40000 -->
            <arg value="--set-max-idx-number=40000" />
            <arg value="--main-dex-list" />
            <!-- 主dex包含的class文件列表 -->
            <arg value="${main-dex-rule}" />
            <arg value="--minimal-main-dex" />
            <arg value="--output=${outdir}" />
            <arg value="${outdir}/classes" />
            <fileset
                dir="${external-libs}"
                includes="*.jar" />
        </apply>
    </target>

其中 --multi-dex 表明打包成多个dex文件
--set-max-idx-number=40000 表示每个dex文件中最多含有的方法数,可配置
--main-dex-list 指定需要打进主dex文件中的类,参数为主dex中类的列表文件
--minimal-main-dex 只有在main-dex-list列表中的类才能打进主dex
关于main-dex-list中的类的规则后面会讲到。这里暂且先将multidexTest目录下的文件放入主dex中,main-dex-rule内容如下

com/example/multidextest/MainActivity.class
com/example/multidextest/HelperOne.class
com/example/multidextest/ApplicationLoader.class

8 copy_dex

 <!-- copy dex to dir -->
    <target
        name="copy_dex"
        depends="package" >
        <echo message="copy dex..." />
        <copy todir="${basedir}" >
            <fileset dir="bin" >
                <include name="classes*.dex" />
            </fileset>
        </copy>
    </target>

执行上述步骤5之后,会在bin目录下生成多个dex文件,copy_dex就是将该目录下的dex文件复制到basedir中,方便打入apk

9 add-subdex-to-apk

    <target
        name="add-subdex-to-apk"
        depends="copy_dex" >

        <echo message="Add Subdex to Apk ..." />
        <foreach
            param="dir.name"
            target="aapt-add-dex" >
            <path>
                <fileset
                    dir="bin"
                    includes="classes*.dex" />
            </path>
        </foreach>
    </target>

遍历bin目录下的dex文件,并将其名称作为参数传递给target aapt-add-dex,注意ant中使用for循环需要引入ant-contrib扩展包

    <!-- 使用aapt命令添加dex文件 -->
    <target name="aapt-add-dex" >
        <echo message="${dir.name}" />
        <!-- 使用正则表达式获取classes的文件名 -->
        <propertyregex
            casesensitive="false"
            input="${dir.name}"
            property="dexfile"
            regexp="classes(.*).dex"
            select="\0" />
        <!-- 这里不需要添加classes.dex文件 -->
        <if>
            <equals
                arg1="${dexfile}"
                arg2="classes.dex" />
            <then>
                <echo>${dexfile} is not handle</echo>
            </then>
            <else>
                <echo>${dexfile} is handle</echo>
                <exec
                    executable="${aapt}"
                    failonerror="true" >
                    <arg value="add" />
                    <arg value="${out-debug-package-ospath}" />
                    <arg value="${dexfile}" />
                </exec>
            </else>
        </if>
        <delete file="${basedir}\${dexfile}" />
    </target>

使用正则表达式获取dex文件名称,如果是主dex classes.dex 则不处理,因为主dex在target package中已经由apk-builder打到apk中,
如果是其他从dex文件,则调用aapt工具将其添加到apk文件中

生成apk后解压缩,可以看到apk中已经包含了两个dex: classes.dex和classes2.dex


apk_package.png

我们来运行一下apk,可以发现报错, 找不到OtherActivity


exceptioin.png

这是因为在dalvik虚拟机加载apk时只会主动加载主dex,并不会对其他从dex进行处理, 我们在打包时 也没有将OtherActivity等其他类也打到主dex中,并且也没有去主动加载从dex,所以导致程序运行时找不到从dex中的类文件。而在art虚拟机上则没有这个问题,因为其对dex文件的处理又不一样,下节再详细讨论。

针对dalvik虚拟机,我们需要手动加载从dex文件,一般为了不影响程序使用,都是在application中去加载从dex。

dex文件的加载

以下的copyDex()方法和loadDex()方法需要在Application类onCreate中依次执行,以在apk启动时加载好所有需要的类。

首先将apk中的subdex复制到私有目录

    private void copyDex() throws Exception{
        Log.d(TAG, "copyDex");
        //获取已安装的apk文件
        File originalApk = new File(getApplicationInfo().sourceDir);
        //创建临时apk文件,存放于/data/data/<application package>/files目录下
        File newApk = new File(getFilesDir().getAbsoluteFile() + File.separator + "test.apk");
        if (!newApk.exists()) {
            newApk.createNewFile();
        }
        //拷贝apk
        copyFile(new FileInputStream(originalApk), new FileOutputStream(newApk));

        ZipFile apk = new ZipFile(newApk);

        Enumeration<? extends ZipEntry> enumeration = apk.entries();

        ZipEntry zipEntry = null;

        while (enumeration.hasMoreElements()) {
            zipEntry = (ZipEntry) enumeration.nextElement();
            //遍历得到除主dex文件外的其他从dex文件
            if (!zipEntry.isDirectory() && zipEntry.getName().endsWith("dex")&& !"classes.dex".equals(zipEntry.getName())) {
                Log.d(TAG, "zip entry name " + zipEntry.getName() + " file size "+ zipEntry.getSize());
                InputStream is = apk.getInputStream(zipEntry);
                FileOutputStream fos = openFileOutput(zipEntry.getName(), MODE_PRIVATE);
                //从临时apk文件中拷贝出从dex文件
                copyFile(is, fos);
            }

        }

        apk.close();
    }
     //拷贝文件
    private void copyFile(InputStream is, FileOutputStream fos) {
        try {
            int hasRead = 0;
            byte[] buf = new byte[1024];
            while((hasRead = is.read(buf)) > 0) {
                fos.write(buf, 0, hasRead);
            }
            fos.flush();
        } catch (Exception e) {
            Log.d(TAG, "copyFile error " + e);
        } finally {
            try {
                if (fos != null) {
                    fos.close();
                }
                if(is != null) {
                    is.close();
                }
            } catch (Exception e2) {
                Log.d(TAG, "copyFile close error " + e2);
            }
        }
    }

拷贝完了之后就可以加载对应的从dex文件了

//加载dex
private void loadDex(){
        Log.d(TAG, "loadDex");
        File[] files = getFilesDir().listFiles();
        if (null != files) {
        //遍历files目录下的dex文件
            for(File file: files){
                String fileName = file.getName();
                if (fileName.endsWith("dex") && !"classes.dex".equals(fileName)) {
                    injectDex(file.getAbsolutePath());
                }
            }
        }
    }

调用injectDex方法加载从dex文件

private String injectDex(String dexPath){
        boolean hasBaseDexClassLoaded = true;
        try {
            Class.forName("dalvik.system.BaseDexClassLoader");
        } catch (Exception e) {
            Log.d(TAG, "load BaseDexClassLoader fail " + e);
            hasBaseDexClassLoaded = false;
        }

        if (hasBaseDexClassLoaded) {
        //获取PathClassLoader
            PathClassLoader pathClassLoader = (PathClassLoader)getClassLoader();
            //通过DexClassLoader加载指定路径的dex文件
            DexClassLoader dexClassLoader = new DexClassLoader(dexPath, getDir("dex", 0).getAbsolutePath(), dexPath, getClassLoader());
            try {
            //通过pathClassLoader获取已经加载dex文件信息,即BaseDexClassLoader的DexPathList属性,而DexPathList中的dexElements属性用于保存加载的dex文件相关信息
            //获取DexClassLoader加载的从dex文件信息,并与已经加载的dex文件信息合并到一起
                Object dexElements = combineArray(getDexElements(getPathList(pathClassLoader)), getDexElements(getPathList(dexClassLoader)));
                //获取pathList属性
                Object pathList = getPathList(pathClassLoader);
                //然后通过反射方式设置为dexElements属性值
                setField(pathList,  pathList.getClass(), "dexElements", dexElements);
                return "SUCCESS";
            } catch (Exception e) {
                Log.d(TAG, " " + e);
            }
        }
        return "";
    }
//通过反射获取BaseDexClassLoader的pathList属性
public Object getPathList(Object baseDexClassLoader)
            throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
        return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
    }

    public Object getField(Object obj, Class<?> cl, String field)
            throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        return localField.get(obj);
    }

    public static void setField(Object obj, Class<?> cl, String field, Object value)
            throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {

        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        localField.set(obj, value);
    }

    //获取DexPathList的dexElements属性,dexElements用于存放已经加载的dex文件
    public Object getDexElements(Object paramObject)
            throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException {
        return getField(paramObject, paramObject.getClass(), "dexElements");
    }


    public static Object combineArray(Object arrayLhs, Object arrayRhs) {
        Class<?> localClass = arrayLhs.getClass().getComponentType();
        int i = Array.getLength(arrayLhs);
        int j = i + Array.getLength(arrayRhs);
        Object result = Array.newInstance(localClass, j);
        for (int k = 0; k < j; ++k) {
            if (k < i) {
                Array.set(result, k, Array.get(arrayLhs, k));
            } else {
                Array.set(result, k, Array.get(arrayRhs, k - i));
            }
        }
        return result;
    }

关于DexClassLoader和PathClassLoader的区别以及dex文件加载的过程会在后面的文章中详细讲到,这里不再赘述。

dalvik VS art 在multidex方面的区别

dalvik和art重要的区别就是dalvik执行的是dex字节码,而art虚拟机执行的是本地机器马。dalvik采用的是JIT(Just-in-Time)解释器在程序运行时动态的将dex字节码翻译成本地机器码,并且在程序每次重新运行的时候都需要重复这一步骤,所以dalvik的执行效率要低于art。

android 4.4以后开始引入art,art虚拟机执行的是本地机器码,而之前dalvik虚拟机上运行的app中包含的都是dex文件,所以就需要一个将dex文件翻译成本地机器码的过程。ART采用的是AOT(Ahead-of-Time),即在程序运行之前就将dex文件翻译成本地机器码,时机就在app安装的时候。

app在安装的过程中,PackageManagerService会请求守护进程installd来执行一次dexopt操作,即对dex字节码进行优化或者翻译,如果系统使用的是dalvik虚拟机,则会调用run_dexopt来将dex文件优化成odex文件,此处只会对classes.dex文件即主dex文件进行优化操作 ,如果系统使用的是art虚拟机,则会调用run_dex2oat来将dex文件翻译成本地机器码并保存在oat文件中,如果apk中含有多个dex文件,则会对多个dex文件都会进行解释处理,并保存到oat文件中。

所以在程序运行时,如果是dalvik虚拟机,则只会加载主dex的odex文件,而从dex文件需要通过multidex的方式手动进行dexopt和加载操作,如果是art虚拟机,则直接加载oat文件即可。

main-dex-list规则

上述例子中,我们是手动指定了主dex中的类文件,但是一般工程中有太多的文件,不可能靠手动来指定哪些类来打入主dex,所以google再android api21版本之上的sdk build-tools中加入了mainDexClasses 脚本来自动遍历指定路径中符合规则的文件名,并输出到指定文件中,这个输出文件就是上面提到的main-dex-rule。

在sdk build-tools下有如下三个文件,其中mainDexClasses为linux下的脚本,mainDexClasses.bat为windows环境下的bat脚本,mainDexClasses.rules为过滤规则,只有符合规则的类才能添加到main-dex-rule中。


maindexclasses.png

对应的命令格式为

mainDexClasses [--output <output file>] <application path>

output file 即输出的文件, application path为输入的文件组,可以包括compile之后的classes文件或者其他jar包

通过查看mainDexClasses.bat可以发现整个处理过程大概分为三步

  1. 环境变量检查、命令参数校验等 包括proguard环境变量

  2. 通过使用proguard的shrink功能,根据mainDexClasses.rules中定义的规则来对传入的文件进行裁剪,去掉无关的类,最后生成tmp.jar包
    mainDexClasses.rules文件其实就是proguard的规则文件,如图


    proguard.png

默认的mainDexClasses.rules只是保留了常用的入口类以及其直接引用类,如Instrumentation,Application,Activity,Service ,Receiver ,ContentProvider,直接引用类就是这些入口类中各个方法、变量引用、依赖的类,而这些被引用类中所引用到的其他类则被车位入口类的间接引用类,这些间接引用类是不会添加到main-dex-rule中的。

3.调用MainDexListBuilder类根据tem.jar包中的类生成主dex的文件列表,即main-dex-rule

所以需要修改target dex, 在multidex打包之前先自动生成main-dex-rule文件,然后再执行dx操作

     <target
        name="dex"
        depends="compile" >
        <echo>dex:Converting compiled files and external libraries into ${outdir}...</echo>
        //生成main-dex-rule文件
        <path id="inputdir">
            <pathelement location="${outdir-classes}"/>
        </path>
        <property name="files" refid="inputdir"/>
        <condition property="realfiles" value=""${files}"" else="${files}" ><os family="windows"/>
        </condition>
        <exec executable="${mainDexClasses}" failonerror="true" >
            <arg value="--disable-annotation-resolution-workaround"/>
            <arg line="--output ${main-dex-rule}"/>
            <arg value="${realfiles}"/>
           </exec>

        //dex操作
        <apply
            executable="${dx}"
            failonerror="true"
            parallel="true" >
            <arg value="--dex" />
            <!-- 多dex命令-->
            <arg value="--multi-dex" />
            <!-- 每个包最大的方法数为10000 -->
            <arg value="--set-max-idx-number=10000" />
            <arg value="--main-dex-list" />
            <!-- 主dex包含的class文件列表 -->
            <arg value="${main-dex-rule}" />
            <arg value="--minimal-main-dex" />
            <arg value="--output=${outdir}" />
            <arg value="${outdir}/classes" />
            <fileset
                dir="${external-libs}"
                includes="*.jar" />
        </apply>
    </target>

这里有个问题需要注意一下,在执行mainDexClasses时多添加了一个参数 --disable-annotation-resolution-workaround

如果不加该参数,会产生如下的异常,导致生成的main-dex-rule文件内容为空


dex_error.png

通过查看MainDexListBuilder的源码,可以发现main中对传入的参数进行了验证,如果没有--disable-annotation-resolution-workaround,则直接报错退出


main.png

关于--disable-annotation-resolution-workaround的官方解释如下


disable.png

加入该参数后构建正常通过,生成的main-dex-rule文件内容如下


rule.png


入口类以及直接引用类已包含在内。

构建出apk文件后解压缩,可以看到主dex中包含的类即为main-dex-rule中含有的类,


main_dex.png

从dex中包含其他间接引用类和第三方jar包中的类


second.png

针对main-dex-list生成的规则,各大互联网公司都有自己的解决方案。可以参考
《Android拆分与加载Dex的多种方案对比》一文

到这里,一个简单的dex分包示例就介绍完了,虽然是简单的例子,但是在一般较大的工程中进行分包操作也是同样的方式和注意事项。后续也会对比gradle的分包方式。

参考文章:
《Android APK DEX分包总结》
《美团Android DEX自动拆包及动态加载简介》
《MultiDex》

附demo下载
MultiDexTest

android随笔