Android快速加载Dex

DexFile类的LoadDex方法可以动态加载Dex文件,但是,这个类有一个缺陷,就是第一次启动并加载一个dex文件时,(尤其在ART模式)需要花费很长的时间。因为它会执行一次dexopt(art以下)或dex2oat(art及以上)操作。
正常情况下不会感觉到它的不足,但是当你在App中使用插件化框架的时候,这个缺点会放大。
本篇总结了几个解决此问题的方法

目前的一些解决方案

TurboDex

早在2016年的GMTC会议时,Lody就已经在做TurboDex了,当时没有细聊,只知道Lody做了并开源了一个这样的东西,可以瞬间加载Dex文件。 今天有空研究了一下,现总结说明一下其原理

介绍

众所周知,Android中在Runtime加载一个 未优化的Dex文件 (尤其在 ART 模式)需要花费 很长的时间. 当你在App中使用 插件化框架 的时候, 首次加载插件就需要耗费很长的时间.

TurboDex 就是为了解决这一问题而生, 就像是给AndroidVM开启了上帝模式, 在引入TurboDex后, 无论你加载了多大的Dex文件,都可以在毫秒级别内完成.

原理

  • 请参考以下的源码

    #define HOOK(func) Cydia::elfHookFunction("libc.so", #func, (void*) my_##func, (void**) &org_##func)
    
    static bool hooked = false;
    static bool enable = false;
    
    int (*org_execv)(const char *name, char **argv);
    
    int my_execv(const char *name, char **argv) {
            #ifdef DEBUG
            LOGD("#execv %s.", name);
            #endif
    
            if(enable && strcmp(name, DEX2OAT_BIN) == 0) {
                exit(0);
            }
    
              return org_execv(name, argv);
    }
    
    void enableFastLoadDex() {
        if(!hooked) {
            HOOK(execv);
            hooked = true;
        }
        enable = true;
    }
    
  • 它的实现原理就是Hook了libc.so中的execv函数,在其执行时,检查是否是要调用dex2oat,如果是的话,直接退出
  • 实现跟下边的看雪论坛中的一篇文章写的是一样的

看雪论坛中的一篇文章

Tinker

技术文章中的线索

在Tinker分享的技术文章中有这样一段话

九、厂商OTA后应用启动耗时问题

在系统OTA后,旧的补丁的oat文件都已经过期。系统会在首次加载时,会重新执行dex2oat。这导致可能会在前台等待很长的时间,甚至出现ANR。这也是Vivo在某次会议上点名批评Tinker的最大原因。

事实上,我们并非没有努力过。更早的时候,我们花费了1个多月的时间实现了分平台合成方案。即在Dalvik合成全量的Dex,在Art合成一个小的Dex。同一个输入,生成不同的并且合法的输出。这个的确不容易,我们也是踩过无数的坑,无数次尝试才实现。但是由于Art的内联问题,这个方案需要废弃。

还有什么样的方案?这个时候,厂商给我们抛出橄榄枝,可以给微信做单独的优化:

在系统升级时,帮助微信把Tinker目录的odex文件重新做dex2oat;
首次调用补丁dex2oat时,采用类似Oppo/Vivo的异步策略。
但是个人坚决反对这些特殊的优化,如果没有做定制的厂商怎么办?外部使用Tinker的应用怎么办?这不是一个非常良好的选择。如何解决:

回退版本;检测到厂商OTA之后,我们立刻删除补丁,然后再在后台异步重新做dex2oat。这个方法非常简单,看起来也的确可以解决OTA的问题,但大范围的回退版本是否会造成更大的问题,尽管只是短暂的回退。假设我某次补丁修改了某些数据库的结构?所以这个方案是不能采用的。

弹等待框;看起来厂商OTA的间隔不会非常频繁,如果使用等待框的方式用户也可以接受。这个我们采用的方案是当检测到系统OTA后,使用单独的进程去展示等待框。看起来好像没问题,但是这个有个非常大的问题,当主进程dex2oat超过60S的时候,一样会由于bg anr被系统杀死。这个方案在Commit中提交,很快被删除了。但是这套代码其实可以应用在Dalvik的多dex加载,大家可以参考一下。

解释执行;受Oppo/Vivo异步执行dex2oat启发,我们是否可以在OTA的首次先使用解释模式执行odex文件,在后台再做异步的dex2oat?事实上,这也是我们最终采用的方案。但是这里要注意的细节其实非常多,如果判断解释执行成功,解释执行的命令参数如何拼写,instruction set如何获取?大家可以参考这个Commit.

源码分析

  • 源码文件 tinker-android/tinker-android-loader/src/main/java/com/tencent/tinker/loader/TinkerParallelDexOptimizer.java
  • 在这里有一个解释执行的判断
    if (useInterpretMode) {
        interpretDex2Oat(dexFile.getAbsolutePath(), optimizedPath);
    } else {
        DexFile.loadDex(dexFile.getAbsolutePath(), optimizedPath, 0);
    }
    
  • interpretDex2Oat函数的实现如下

    private void interpretDex2Oat(String dexFilePath, String oatFilePath) throws IOException {
    
        final File oatFile = new File(oatFilePath);
        if (!oatFile.exists()) {
            oatFile.getParentFile().mkdirs();
        }
    
        final List<String> commandAndParams = new ArrayList<>();
        commandAndParams.add("dex2oat");
        commandAndParams.add("--dex-file=" + dexFilePath);
        commandAndParams.add("--oat-file=" + oatFilePath);
        commandAndParams.add("--instruction-set=" + targetISA);
        commandAndParams.add("--compiler-filter=interpret-only");
    
        final ProcessBuilder pb = new ProcessBuilder(commandAndParams);
        pb.redirectErrorStream(true);
        final Process dex2oatProcess = pb.start();
        StreamConsumer.consumeInputStream(dex2oatProcess.getInputStream());
        StreamConsumer.consumeInputStream(dex2oatProcess.getErrorStream());
        try {
            final int ret = dex2oatProcess.waitFor();
            if (ret != 0) {
                throw new IOException("dex2oat works unsuccessfully, exit code: " + ret);
            }
        } catch (InterruptedException e) {
            throw new IOException("dex2oat is interrupted, msg: " + e.getMessage(), e);
        }
    }
    
  • 源码分析
    • tinker的源码中是通过调用系统中的命令dex2oat并通过传递特定的参数来达到解释执行的目的
    • 关键参数: –compiler-filter=interpret-only
    • –compiler-filter官方解释
      --compiler-filter=(verify-none|interpret-only|space|balanced|speed|everything|time):
          select compiler filter.
          Example: --compiler-filter=everything
          Default: speed
      
    • 参数总结

总结

方案 原理 适用场景
TurboDex 调用DexFile.LoadDex然后Hook libc.so 简单暴力,适用于不改动原代码的情况下
Tinker 直接调用dex2oat命令 适用于自己全面掌控代码的情况下

另一个问题

DexFile类中的方法loadDex已经在api level 26中被废弃了,如下

loadDex(String sourcePathName, String outputPathName, int flags)

This method was deprecated in API level O. Applications should use one of the standard classloaders such as PathClassLoader instead. This API will be removed in a future Android release.

看来使用dex2oat这个方案还靠谱些;而且有必要再研究一下其它的方案

参考

文章目录
  1. 1. 目前的一些解决方案
    1. 1.1. TurboDex
      1. 1.1.1. 介绍
      2. 1.1.2. 原理
    2. 1.2. 看雪论坛中的一篇文章
    3. 1.3. Tinker
      1. 1.3.1. 技术文章中的线索
    4. 1.4. 源码分析
  2. 2. 总结
  3. 3. 另一个问题
  4. 4. 参考