android热修复相关之Multidex解析

从本篇文章开始,对classloader方案热修复的相关知识进行学习。这个方案的源头是基于google为了解决方法数超限问题而引入的MultiDex技术。关于方法数超限问题,估计大家都有所了解,这里就不多介绍了。

MultiDex的实现分为两方面,一方面在编译apk过程中,插件能将class文件打成多个dex文件,另一方面需要在程序运行时,将classes2.dex, classes3.dex加载进来。我们主要关注dex加载过程,至于dex拆分过程,这里简单的说一下。
编译apk过程中,android在5.0及其以上的SDK中dx工具支持multidex参数。

  [--multi-dex [--main-dex-list=<file> [--minimal-main-dex]]

参数说明:

  • --multi-dex:多 dex 打包的开关
  • --main-dex-list=<file>:参数是一个类列表的文件,在该文件中的类会被打包在第一个 dex 中
  • --minimal-main-dex:只有在--main-dex-list 文件中指定的类被打包在第一个 dex,其余的都在第二个 dex 文件中,主要是为了减小主dex的大小。

和Multidex相关的gradle任务如下:

    :transformClassesWithJarMergingForDebug UP-TO-DATE
    :collectDebugMultiDexComponents UP-TO-DATE
    :transformClassesWithMultidexlistForDebug UP-TO-DATE
    :transformClassesWithDexForDebug UP-TO-DATE
  • transformClassesWithJarMergingForDebug
    这个transform的作用是将所用到的 jar 转换至一个单一的 Jar 中,输出产物在 build/intermediates/transforms/jarMerging 目录下的 combined.jar文件。
  • collectDebugMultiDexComponents
    该task扫描AndroidManifest.xml中的application、activity、receiver、provider、service等相关类,并将这些类的信息写入到manifest_keep.txt文件中,该文件位于目录build/intermediates/multi-dex/debug
  • transformClassesWithMultidexlistForDebug
    这个transform根据之前的 mainfest_keep 及一些 proguard 文件来生成 mainDex 中指定的类集合文件,对应生成的输出结果为 maindexlist.txt,同时生成componentClasses.jar文件,两个文件均位于build/intermediates/multi-dex/debug目录下。
  • transformClassesWithDexForDebug
    调用dx命令,进行dex生成,这里在处理主dex是通过遍历maindexlist.txt对应的class文件,读取class文件格式中常量池的内容,从而获取到依赖类。

通过以上的transform和相关task,我们打出的apk会包含不只一个xx.dex。下面来讲一下多dex运行的问题。这里需要大家对类加载器有所了解,可以参考我之前写过的一篇文章Android插件化框架系列之类加载器或者查看相关文章进行学习。

对于apk中多dex成功加载的问题,按虚拟机类型进行分类分析:

Dalvik虚拟机

针对dalvik虚拟机,我们都知道google是借助Multidex库解决的。唯一的可能就是dalvik不能从一个apk中加载多个dex,我们去源码里验证一下,以4.2的源码为例,分析一下classloader加载dex的流程(PS:这里主要为了分析classloader,所以没有从apk安装开始,可以认为是在odex不存在的情况分析PathClassLoader加载dex的过程)我们看一下BaseDexClassLoader源码。
/libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;
    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);
        this.originalPath = dexPath;
        this.pathList =
            new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = pathList.findClass(name);
        if (clazz == null) {
            throw new ClassNotFoundException(name);
        }
        return clazz;
    }

可见我们传入apk路径后,在加载器构建时会构建一个DexPathList,此外什么都没做,跟进去看一下。
/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

public DexPathList(ClassLoader definingContext, String dexPath,
           String libraryPath, 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;
       this.dexElements =
           makeDexElements(splitDexPath(dexPath), optimizedDirectory);
       this.nativeLibraryDirectories = splitLibraryPath(libraryPath);
   }

此时的dexPath是我们传入的apk路径,整个构造函数完成的任务就是填充dexElements,这个dexElements是一个Element[]类型的变量,Element又是什么?在dalvik下,我们可以这么理解,每个dex加载成功后,会对应成一个DexFile对象,这里暂时可以认为Element就是DexFile的一个封装(不考虑资源的情况下),也就是等同于一个dex。篇幅原因,直接进行解释啦,splitDexPath负责解析传入路径,支持路径中有多个dex或者压缩包,例如xxx.zip;xxx.zip;xxx.zip等,是利用File.pathSeparator进行分割的,每个路径会封装成一个File,最终形成一个list,当然了,绝大多数情况下,我们传给类加载器的路径都是单一的。

   private static Element[] makeDexElements(ArrayList<File> files,
            File optimizedDirectory) {
        ArrayList<Element> elements = new ArrayList<Element>();
        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) {
                    System.logE("Unable to open zip file: " + file, ex);
                }
               try {
                    dex = loadDexFile(file, optimizedDirectory);
                } catch (IOException ignored) {                 
                }
            } 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()]);
    }

这没什么好分析的,我们传入的是apk,可以看到,先是封装成一个ZipFile,然后调用了loadDexFile方法。最终会调用到DexFile的openDexFile方法,该方法是一个native方法。源码在 /dalvik/vm/native/dalvik_system_DexFile.cpp

static void Dalvik_dalvik_system_DexFile_openDexFile(const u4* args,
    JValue* pResult)
{
    StringObject* sourceNameObj = (StringObject*) args[0];
    StringObject* outputNameObj = (StringObject*) args[1];
    DexOrJar* pDexOrJar = NULL;
    JarFile* pJarFile;
    RawDexFile* pRawDexFile;
    char* sourceName;
    char* outputName;
    ...
    ...
    if (hasDexExtension(sourceName)
            && dvmRawDexFileOpen(sourceName, outputName, &pRawDexFile, false) == 0) {
        LOGV("Opening DEX file '%s' (DEX)", sourceName);
        pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
        pDexOrJar->isDex = true;
        pDexOrJar->pRawDexFile = pRawDexFile;
        pDexOrJar->pDexMemory = NULL;
    } else if (dvmJarFileOpen(sourceName, outputName, &pJarFile, false) == 0) {
        LOGV("Opening DEX file '%s' (Jar)", sourceName);
        pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
        pDexOrJar->isDex = false;
        pDexOrJar->pJarFile = pJarFile;
        pDexOrJar->pDexMemory = NULL;
    } else {
        LOGV("Unable to open DEX file '%s'", sourceName);
        dvmThrowIOException("unable to open DEX file");
    }
    if (pDexOrJar != NULL) {
        pDexOrJar->fileName = sourceName;
        addToDexFileTable(pDexOrJar);
    } else {
        free(sourceName);
    }
    RETURN_PTR(pDexOrJar);
}

hasDexExtension判断是否是.dex,我们这里是.apk,显然会走到dvmJarFileOpen方法中。dvmJarFileOpen方法在/dalvik/vm/JarFile.cpp中

int dvmJarFileOpen(const char* fileName, const char* odexOutputName,
    JarFile** ppJarFile, bool isBootstrap)
{
    ...
    ... 
    fd = openAlternateSuffix(fileName, "odex", O_RDONLY, &cachedName);
    if (fd >= 0) {
        LOGV("Using alternate file (odex) for %s ...", fileName);
        if (!dvmCheckOptHeaderAndDependencies(fd, false, 0, 0, true, true)) {
            LOGE("%s odex has stale dependencies", fileName);
            free(cachedName);
            cachedName = NULL;
            close(fd);
            fd = -1;
            goto tryArchive;
        } else {
            LOGV("%s odex has good dependencies", fileName);
            //TODO: make sure that the .odex actually corresponds
            //      to the classes.dex inside the archive (if present).
            //      For typical use there will be no classes.dex.
        }
    } else {
        ZipEntry entry;

tryArchive:
        entry = dexZipFindEntry(&archive, kDexInJarName);
        if (entry != NULL) {
            bool newFile = false;
            if (odexOutputName == NULL) {
                cachedName = dexOptGenerateCacheFileName(fileName,
                                kDexInJarName);
                if (cachedName == NULL)
                    goto bail;
            } else {
                cachedName = strdup(odexOutputName);
            }
            LOGV("dvmJarFileOpen: Checking cache for %s (%s)",
                fileName, cachedName);
            fd = dvmOpenCachedDexFile(fileName, cachedName,
                    dexGetZipEntryModTime(&archive, entry),
                    dexGetZipEntryCrc32(&archive, entry),
                    isBootstrap, &newFile, /*createIfMissing=*/true);
            if (fd < 0) {
                LOGI("Unable to open or create cache for %s (%s)",
                    fileName, cachedName);
                goto bail;
            }
            locked = true;
        ....
        ....
    return result;
}

首先openAlternateSuffix检查是否已经存在了对应的odex,如果存在,在dvmCheckOptHeaderAndDependencies中进行opt格式校验,如果不存在odex或者存在无效odex时,会利用dexzipFindEntry函数去查找匹配对应的dex,而kDexInJarName的值为常量,这就解释了我们的问题,dalvik虚拟机中只会对名为“classes.dex”的dex文件进行加载,其余的均不会加载。当找到dex后,会调用dvmOpenCachedDexFile函数,在函数内部会有启动执行dexopt相关的代码,进而执行dexopt过程,这个暂时不做分析。

static const char* kDexInJarName = "classes.dex";

到这里我们就从源码角度解释了为什么dalvik虚拟机只能加载apk包的一个dex,而且必须为classes.dex。整个流程也是为了记录下android类加载器加载流程,下面分析到art虚拟机的时候相似流程会跳过。因为apk中的classes2.dex,...等dex均无法加载,应用启动时肯定会报找不到类的异常。Multidex的作用就是想办法把classes2.dex,classes3.dex尽可能早的加载进来。

Multidex的相关源码分析的文章很多,大家可以自行查看,这里只看一下核心代码。基本上可以归结成两步:

    1. 将apk中的classes2.dex,classes3.dex...拷贝到目录/data/data/pkgName/code_cache/secondary-dexes/下,命名为/data/data/pkgName/code_cache/secondary-dexes/apkName.apk.classesN.zip,具体拷贝的过程在MultiDexExtractor的extract方法中,可以看出,相当于重命名为classes.dex压缩到了一个zip中。和前面源码分析的相符,即压缩包中的classes.dex。
        ZipEntry classesDex = new ZipEntry("classes.dex");
    1. 利用反射,将所有的zip放到DexPathList的Elements数组中,并进行调用。
private static final class V14 {
    private V14() {
    }
    private static void install(ClassLoader loader, List<File> additionalClassPathEntries, File optimizedDirectory) throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
      Field pathListField = MultiDex.findField(loader, "pathList");
      Object dexPathList = pathListField.get(loader);
      MultiDex.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries), optimizedDirectory));
    }
    private static Object[] makeDexElements(Object dexPathList, ArrayList<File> files, File optimizedDirectory) throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
      Method makeDexElements = MultiDex.findMethod(dexPathList, "makeDexElements", new Class[]{ArrayList.class, File.class});
      return (Object[])((Object[])makeDexElements.invoke(dexPathList, new Object[]{files, optimizedDirectory}));
    }
  }

这段是核心代码,也是所有classloader热修复方案的根本来源。我们来简要分析一下这段代码。additionalClassPathEntries是我们拷贝过来的所有的zip包列表,通过反射调用makeDexElements函数,得到新的Elements数组,然后调用用expandFieldArray函数,将两个Elements数组进行合并。这样应用的pathClassLoader中的elements数组就包含多个dex文件了当我们查找类的时候就是遍历elements中的dex,从每个dex中依次查找。

 private static void expandFieldArray(Object instance, String fieldName, Object[] extraElements) throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
    Field jlrField = findField(instance, fieldName);
    Object[] original = (Object[])((Object[])jlrField.get(instance));
    Object[] combined = (Object[])((Object[])Array.newInstance(original.getClass().getComponentType(), original.length + extraElements.length));
    System.arraycopy(original, 0, combined, 0, original.length);
    System.arraycopy(extraElements, 0, combined, original.length, extraElements.length);
    jlrField.set(instance, combined);
  }

顺便说一下MultiDex中可能遇到的问题,因为我们项目是用的插件化框架,方法数虽然超出了65536,但是主项目方法数超出的并不多,线上并没有监测到ANR问题,但是从原理上来讲,dalvik下,apk安装时只对主dex进行了dexopt,而从dex都是在第一次启动时,进行dexopt操作的,具体时机是在makeDexElements函数被反射调用时进行的,至于dexopt为什么耗时,后面的文章会进行分析。所以如果Classes2.dex很大,或者从dex很多,加载过程将相当耗时,确实很有可能出现ANR,基本上大家的解决方案都是采用异步加载,做个等待页面来解决。

ART虚拟机

ok,分析完了dalvik下多dex加载,我们都知道Multidex库对于API20以上是不需要的,art虚拟机进行了相关的内建支持。来看一下art的类加载器在这一块的处理,以android6.0源码为例,java层代码几乎一样,直接看native层代码:
/art/runtime/native/dalvik_system_DexFile.cc

static jobject DexFile_openDexFileNative(
    JNIEnv* env, jclass, jstring javaSourceName, jstring javaOutputName, jint) {
  ScopedUtfChars sourceName(env, javaSourceName);
  ClassLinker* linker = Runtime::Current()->GetClassLinker();
  std::vector<std::unique_ptr<const DexFile>> dex_files;
  std::vector<std::string> error_msgs;
  dex_files = linker->OpenDexFilesFromOat(sourceName.c_str(), outputName.c_str(), &error_msgs);
  if (!dex_files.empty()) {
    jlongArray array = ConvertNativeToJavaArray(env, dex_files);
    if (array == nullptr) {
      ScopedObjectAccess soa(env);
      for (auto& dex_file : dex_files) {
        if (Runtime::Current()->GetClassLinker()->IsDexFileRegistered(*dex_file)) {
          dex_file.release();
        }
      }
    }
    return array;
  } else {
    ScopedObjectAccess soa(env);
    CHECK(!error_msgs.empty());
    // The most important message is at the end. So set up nesting by going forward, which will
    // wrap the existing exception as a cause for the following one.
    auto it = error_msgs.begin();
    auto itEnd = error_msgs.end();
    for ( ; it != itEnd; ++it) {
      ThrowWrappedIOException("%s", it->c_str());
    }
    return nullptr;
  }
}

我们关注这三句就全明白了,dex_files是一个Vector对象,然后通过OpenDexFilesFromOat去加载apk中的所有dex,保存在dex_files中,然后通过ConvertNativeToJavaArray函数转化成jlongArray返回java端,保存在了DexFile的mCookie变量中。

  std::vector<std::unique_ptr<const DexFile>> dex_files;
  dex_files = linker->OpenDexFilesFromOat(sourceName.c_str(), outputName.c_str(), &error_msgs);
  jlongArray array = ConvertNativeToJavaArray(env, dex_files);

接下来的逻辑我们就不一一去追踪了,OpenDexFilesFromOat函数首先去判断有没有生成oat文件,如果没有,会先执行dexoat过程,生成oat文件,然后从oat文件中查找dex,最终会走到/art/runtime/dex_file.cc的OpenFromZip函数中

bool DexFile::OpenFromZip(const ZipArchive& zip_archive, const std::string& location,
                        std::string* error_msg,
                        std::vector<std::unique_ptr<const DexFile>>* dex_files) {
  for (size_t i = 1; ; ++i) {
    std::string name = GetMultiDexClassesDexName(i);
    std::string fake_location = GetMultiDexLocation(i, location.c_str());
    std::unique_ptr<const DexFile> next_dex_file(Open(zip_archive, name.c_str(), fake_location,
                                                      error_msg, &error_code));
    if (next_dex_file.get() == nullptr) {
      if (error_code != ZipOpenErrorCode::kEntryNotFound) {
        LOG(WARNING) << error_msg;
      }
      break;
    } else {
      dex_files->push_back(std::move(next_dex_file));
    }
    if (i == std::numeric_limits<size_t>::max()) {
      LOG(ERROR) << "Overflow in number of dex files!";
      break;
    }
  }
  return true;
}
}
std::string DexFile::GetMultiDexClassesDexName(size_t index) {
  if (index == 0) {
    return "classes.dex";
  } else {
    return StringPrintf("classes%zu.dex", index + 1);
  }
}

ok,看到GetMultiDexClassesDexName函数就不需要解释什么了。

整体有点乱,简单总结一下,在art虚拟机中,Multidex是内建支持的,在apk安装时就完成了所有dex的dexoat过程。而dalvik下,apk安装时dalvik虚拟机只能对classes.dex进行处理,借助于MultiDex库反射elements数组进行dex添加完成的。

这篇文章写的目的一是为了引出classloader热修复方案,二是了解一下dalvik和art在类加载器方面的区别。

参考:
1.http://blog.csdn.net/jiangwei0910410003/article/details/50799573
2.http://blog.csdn.net/richie0006/article/details/51103976

目前本人在公司负责热修复相关的工作,主要是基于robust的热修复相关工作。感兴趣的同学欢迎进群交流。


image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容