Android源码系列(26) -- MultiDex

November 20, 2019

一、介绍

1.1 功能

经过长期需求迭代、引入大量第三方代码库之后,构建的安装包包含海量方法。即便经过代码混淆,依然会在不久的将来遇到 Android64K方法数 问题。

官方对此这样解释:

Android 应用 (APK) 文件包含 Dalvik Executable (DEX) 文件形式的可执行字节码文件,这些文件包含用来运行您的应用的已编译代码。Dalvik Executable 规范将可在单个 DEX 文件内引用的方法总数限制为 65,536,其中包括 Android 框架方法、库方法以及您自己的代码中的方法。在计算机科学领域内,术语千(简称 K)表示 1024(或 2^10)。由于 65,536 等于 64 X 1024,因此这一限制称为“64K 引用限制”。

既然单个Dex文件不能容纳应用所有方法引用,应运而生解决方案:把多余的方法引用放到第二个、第三个等后续Dex文件中。方法引用越多,最终分包数量越多。

1.2 构建

添加以下配置后,代码构建时自动分包:

1
2
3
4
5
android {
    defaultConfig {
        multiDexEnabled true
    }
}

MultiDex 用于应用启动时加载被分割的子dex,让后续类加载能从子dex找到目标类。

1
implementation "androidx.multidex:multidex:2.0.0"

1.3 疑难

当然还可能会遇到:

  • 启动类没有分到主包引起 ClassNotFoundException
  • 读取dex时间长导致 ANR 提示;

这些问题网上很多文章都有提及,自行查找就有解决方案。更详细的说明可以参考官方文档:为方法数超过 64K 的应用启用多 dex 文件

为提高文章阅读性和不影响理解的前提,下文移除部分日志并微调代码格式,插图可以浏览器右键打开查看。

二、集成

最简单方式是继承 MultiDexApplication 类。

如果不方便继承父类,可以选择在自定义的 Application.attachBaseContext(Context base) 里主动调用 MultiDex.install(this);

1
2
3
4
5
6
7
8
9
public class MultiDexApplication extends Application {
    public MultiDexApplication() {
    }

    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        MultiDex.install(this);
    }
}

三、提取

3.1 IS_VM_MULTIDEX_CAPABLE

Android4.4(API19) 获取 System.getProperty(“java.vm.version”) 结果为 1.6.0

1
2
private static final boolean IS_VM_MULTIDEX_CAPABLE = 
  isVMMultidexCapable(System.getProperty("java.vm.version"));

3.2 install()

上文提到的 MultiDex.install(this); 调用以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static void install(Context context) {
    if (IS_VM_MULTIDEX_CAPABLE) {
        // 如果VM本身已经支持分包就不需要调用MultiDex,因为安装过程已完成相同操作
        Log.i("MultiDex", "VM has multidex support, MultiDex support library is disabled.");
    } else if (VERSION.SDK_INT < 4) {
        // 最低支持API4
        throw new RuntimeException("MultiDex installation failed. SDK " + VERSION.SDK_INT
                                   + " is unsupported. Min SDK version is " + 4 + ".");
    } else {
        try {
            // 获取应用的信息
            ApplicationInfo applicationInfo = getApplicationInfo(context);
            if (applicationInfo == null) {
                return;
            }
          
            // 开始装载操作
            doInstallation(context, 
                           new File(applicationInfo.sourceDir),
                           new File(applicationInfo.dataDir),
                           "secondary-dexes", "", true);
        } catch (Exception var2) {
            throw new RuntimeException("MultiDex installation failed (" + var2.getMessage() + ").");
        }
    }
}

3.3 doInstallation()

调用 doInstallation() 开始装载操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
private static void doInstallation(Context mainContext, File sourceApk, File dataDir,
                                   String secondaryFolderName, String prefsKeyPrefix,
                                   boolean reinstallOnPatchRecoverableException)
  throws IOException, IllegalArgumentException, IllegalAccessException,
         NoSuchFieldException, InvocationTargetException, NoSuchMethodException,
         SecurityException, ClassNotFoundException, InstantiationException {

    // 加锁保证安装线程安全
    synchronized(installedApk) {
        // 检查本应用apk文件是否已装载
        if (!installedApk.contains(sourceApk)) {
            installedApk.add(sourceApk);

            // 类加载器
            ClassLoader loader;
            try {
                // 获取类加载器,提取的Dex后续通过反射添加到类加载器
                loader = mainContext.getClassLoader();
            } catch (RuntimeException var25) {
                // 如果运行在测试模式,有可能出现获取ClassLoader失败
                return;
            }

            if (loader == null) {
                // 如果运行在测试模式,有可能出现获取ClassLoader为空
            } else {
                try {
                    clearOldDexDir(mainContext);
                } catch (Throwable var24) {
                    // Something went wrong when trying to clear old MultiDex extraction
                    // continuing without cleaning.
                }

                // dataDir: /data/data/com.phantomvk.playground
                // secondaryFolderName: secondary-dexes
                // dexDir: /data/data/com.phantomvk.playground/code_cache/secondary-dexes
                File dexDir = getDexDir(mainContext, dataDir, secondaryFolderName);

                // sourceApk: /data/app/com.phantomvk.playground-1.apk
                // dexDir: /data/data/com.phantomvk.playground/code_cache/secondary-dexes
                // 同时创建文件锁 MultiDex.lock
                MultiDexExtractor extractor = new MultiDexExtractor(sourceApk, dexDir);
                IOException closeException = null;

                try {
                    // 从MultiDexExtractor加载dexes获得zipList
                    List files = extractor.load(mainContext, prefsKeyPrefix, false);

                    try {
                        // 加载获取Dexes安装到ClassLoader
                        installSecondaryDexes(loader, dexDir, files);
                    } catch (IOException var26) {
                        if (!reinstallOnPatchRecoverableException) {
                            throw var26;
                        }

                        // 加载失败重试,从MultiDexExtractor加载dexes获得zips
                        files = extractor.load(mainContext, prefsKeyPrefix, true);

                        // 加载获取的Dexes安装到ClassLoader
                        installSecondaryDexes(loader, dexDir, files);
                    }
                } finally {
                    try {
                        // 解除文件锁
                        extractor.close();
                    } catch (IOException var23) {
                        closeException = var23;
                    }
                }

                if (closeException != null) {
                    throw closeException;
                }
            }
        }
    }
}

变量 dexDir 调试路径:

dexDir

进入 MultiDexExtractor.load 提取器获取列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
List<? extends File> load(Context context, String prefsKeyPrefix,
                          boolean forceReload) throws IOException {

    if (!this.cacheLock.isValid()) {
        throw new IllegalStateException("MultiDexExtractor was closed");
    } else {
        List files;
        // 如果不是'强制提取'和'文件已被修改'就尝试复用文件
        if (!forceReload && !isModified(context, this.sourceApk, this.sourceCrc, prefsKeyPrefix)) {
            try {
                // 复用缓存在dexDir的Dexes文件
                files = this.loadExistingExtractions(context, prefsKeyPrefix);
            } catch (IOException var6) {
                // 复用缓存出现错误,重新提取文件
                files = this.performExtractions();
                // 最后执行的数据会保存到SharedPreferences
                putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk),
                                 this.sourceCrc, files);
            }
        } else {
            // 更新App后sourceCrc改变会触发dex重新提取
            files = this.performExtractions();
            // 最后执行的数据会保存到SharedPreferences
            putStoredApkInfo(context, prefsKeyPrefix, getTimeStamp(this.sourceApk),
                             this.sourceCrc,files);
        }

        return files;
    }
}

3.4 MultiDexExtractor.performExtractions()

实际执行的提取操作方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
private List<MultiDexExtractor.ExtractedDex> performExtractions() throws IOException {
    // extractedFilePrefix: com.phantomvk.playground-1.apk.classes
    String extractedFilePrefix = this.sourceApk.getName() + ".classes";

    this.clearDexDir();
    List<MultiDexExtractor.ExtractedDex> files = new ArrayList();

    // APK本质是Zip,所以APK用Zip类型打开
    ZipFile apk = new ZipFile(this.sourceApk);

    try {
        // 名为classes.dex的主dex安装过程已完成提取
        // 运行时只从classes2.dex开始遍历,即secondaryNumber=2
        int secondaryNumber = 2;
        
        // apk作为zip文件打开后,可从zip文件列表中获取指定文件名dex
        // 从apk内逐个获取dex文件,把dex文件写为zip文件
        for(ZipEntry dexFile = apk.getEntry("classes" + secondaryNumber + ".dex");
            dexFile != null;
            dexFile = apk.getEntry("classes" + secondaryNumber + ".dex")) {
            
            // fileName: com.phantomvk.playground-1.apk.classes2.zip
            String fileName = extractedFilePrefix + secondaryNumber + ".zip";

            // MultiDexExtractor.ExtractedDex父类File,存放提取的dex文件
            // dexDir: /data/data/com.phantomvk.playground/code_cache/secondary-dexes/
            MultiDexExtractor.ExtractedDex extractedFile =
                new MultiDexExtractor.ExtractedDex(this.dexDir, fileName);
            files.add(extractedFile);

            // 每个dex提取失败累计重试最多3次
            int numAttempts = 0;
            boolean isExtractionSuccessful = false;

            while(numAttempts < 3 && !isExtractionSuccessful) {
                ++numAttempts;

                // 提取dexFile(如classes2.dex)为extractedFile
                // 意思就是读取dex并保存到zip里面
                extract(apk, dexFile, extractedFile, extractedFilePrefix);

                try {
                    // 记录提取后文件的CRC
                    extractedFile.crc = getZipCrc(extractedFile);
                    isExtractionSuccessful = true;
                } catch (IOException var18) {
                    isExtractionSuccessful = false;
                }

                // 提取失败则删除提取出来的文件
                if (!isExtractionSuccessful) {
                    extractedFile.delete();
                }
            }

            // 任意dex提取失败3次终止App启动流程
            if (!isExtractionSuccessful) {
                throw new IOException("Could not create zip file "
                    + extractedFile.getAbsolutePath()
                    + " for secondary dex (" + secondaryNumber + ")");
            }
            
            // 完成本次提取执行++secondaryNumber,继续遍历余下dex
            ++secondaryNumber;
        }
    } finally {
        try {
            apk.close();
        } catch (IOException var17) {
            Log.w("MultiDex", "Failed to close resource", var17);
        }

    }

    // 返回提取出来的zip列表
    return files;
}

流程概括:

  1. 用读取 zip 的方式读取 APK
  2. 提取除了 classes.dex 外的其他 classesN.dex 文件到 MultiDexExtractor.ExtractedDex
  3. 每个 classesN.dex 文件对应一个 MultiDexExtractor.ExtractedDex,后者类型为 zip
  4. 记录提取后 MultiDexExtractor.ExtractedDex 的 CRC;
  5. 返回 MultiDexExtractor.ExtractedDex 文件列表;

提取完成后的 files 结构如下,可见子dex总共有4个,文件类型为 zip,保存在 dexDir 文件夹下面:

filesAfter

3.5 MultiDexExtractor.extract()

这里介绍如何从 dex 转换为列表 files 里保存的 zip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// @param apk 安装包源文件apkName.apk
// @param dexFile 安装包源文件解压获得的classes2.dex、classes3.dex等等
// @param extractTo 提取某个dex保存到对应Zip文件
// @param extractedFilePrefix 提取出来文件前缀
private static void extract(ZipFile apk, ZipEntry dexFile, File extractTo, String extractedFilePrefix) throws IOException, FileNotFoundException {
    // 为dex文件创建InputStream
    InputStream in = apk.getInputStream(dexFile);
    ZipOutputStream out = null;

    // tmpFileName:   tmp-com.phantomvk.playground-1.apk.classes.zip
    // extractParent: /data/data/com.phantomvk.playground/code_cache/secondary-dexes/
    File tmp = File.createTempFile("tmp-" + extractedFilePrefix, ".zip", extractTo.getParentFile());

    try {
        // 包装temp文件创建ZipOutputStream
        out = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(tmp)));

        try {
            // 每个zip文件名不一样,但是zip里面的dex都命名为classes.dex
            ZipEntry classesDex = new ZipEntry("classes.dex");
            classesDex.setTime(dexFile.getTime());
            // 每个zip压缩包保存一个子文件dex
            out.putNextEntry(classesDex);
            byte[] buffer = new byte[16384]; // 16KB缓冲区

            // 从dex文件输入流in,写到zip文件输出流out
            for(int length = in.read(buffer); length != -1; length = in.read(buffer)) {
                out.write(buffer, 0, length);
            }

            out.closeEntry();
        } finally {
            out.close();
        }

        if (!tmp.setReadOnly()) {
            throw new IOException("Failed to mark readonly \"" + tmp.getAbsolutePath() + "\" (tmp of \"" + extractTo.getAbsolutePath() + "\")");
        }

        if (!tmp.renameTo(extractTo)) {
            throw new IOException("Failed to rename \"" + tmp.getAbsolutePath() + "\" to \"" + extractTo.getAbsolutePath() + "\"");
        }
    } finally {
        closeQuietly(in);
        tmp.delete();
    }
}

流程概括:

  1. 先创建临时文件 tmp-[包名]-1.apk.classes.zip
  2. 在上述临时 zip 压缩包内创建文件 classes.dex
  3. APK 复制 dex 并写入到 zipclasses.dex 中;
  4. 最后把临时 zip 文件重命名为 extractTo 文件的文件名 [包名]-1.apk.classesN.zip

四、装载

上文只完成从安装包获得dexes,逐一提取 zip 文件到磁盘的工作,提取完成的文件并还没有添加到 ClassLoader

4.1 installSecondaryDexes()

继续调用 installSecondaryDexes 进行装载,根据不同版本进入不同分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static void installSecondaryDexes(ClassLoader loader,
                                          File dexDir,
                                          List<? extends File> files)
  throws IllegalArgumentException, IllegalAccessException, NoSuchFieldException,
InvocationTargetException, NoSuchMethodException, IOException,
SecurityException, ClassNotFoundException, InstantiationException {

    // 提取zip文件列表不为空
    if (!files.isEmpty()) {
        if (VERSION.SDK_INT >= 19) {
            MultiDex.V19.install(loader, files, dexDir);
        } else if (VERSION.SDK_INT >= 14) {
            MultiDex.V14.install(loader, files);
        } else {
            MultiDex.V4.install(loader, files);
        }
    }
}

Android4.4 作为示例进入 V19

APK 每个 dex 已经逐个提取为 MultiDexExtractor.ExtractedDex 文件,合并为文件列表后传递给变量 additionalClassPathEntries

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// additionalClassPathEntries: MultiDexExtractor.ExtractedDex 文件列表
// optimizedDirectory: /data/data/[包名]/code_cache/secondary-dexes/,即dexDir
static void install(ClassLoader loader, List<? extends File> additionalClassPathEntries, File optimizedDirectory)
  throws IllegalArgumentException, IllegalAccessException,
         NoSuchFieldException, InvocationTargetException,
         NoSuchMethodException, IOException {

    // dalvik.system.DexPathList dalvik.system.BaseDexClassLoader.pathList
    Field pathListField = MultiDex.findField(loader, "pathList");
    Object dexPathList = pathListField.get(loader);

    ArrayList<IOException> suppressedExceptions = new ArrayList();

    // 1. makeDexElements()优化zip为Element对象;
    // 2. expandFieldArray()里把Element对象存入ClassLoader;
    MultiDex.expandFieldArray(dexPathList, "dexElements",
                              makeDexElements(dexPathList, new ArrayList(additionalClassPathEntries),
                                              optimizedDirectory, suppressedExceptions));

    // 下面是异常情况的处理,可以忽略
    if (suppressedExceptions.size() > 0) {
        Iterator var6 = suppressedExceptions.iterator();

        while(var6.hasNext()) {
            IOException e = (IOException)var6.next();
            Log.w("MultiDex", "Exception in makeDexElement", e);
        }

        Field suppressedExceptionsField = MultiDex.findField(dexPathList, "dexElementsSuppressedExceptions");
        IOException[] dexElementsSuppressedExceptions = (IOException[])((IOException[])suppressedExceptionsField.get(dexPathList));
        if (dexElementsSuppressedExceptions == null) {
            dexElementsSuppressedExceptions = (IOException[])suppressedExceptions.toArray(new IOException[suppressedExceptions.size()]);
        } else {
            IOException[] combined = new IOException[suppressedExceptions.size() + dexElementsSuppressedExceptions.length];
            suppressedExceptions.toArray(combined);
            System.arraycopy(dexElementsSuppressedExceptions, 0, combined,
                             suppressedExceptions.size(),
                             dexElementsSuppressedExceptions.length);
            dexElementsSuppressedExceptions = combined;
        }

        suppressedExceptionsField.set(dexPathList, dexElementsSuppressedExceptions);
        IOException exception = new IOException("I/O exception during makeDexElement");
        exception.initCause((Throwable)suppressedExceptions.get(0));
        throw exception;
    }
}

流程概括:

  1. 反射 ClassLoader 获取变量 pathList
  2. makeDexElements() 逐个优化包含 dexzip 文件,再封装到 Element 实例中;
  3. pathList 反射获取 dexElements[],读取原有文件;
  4. dexElements[] 已有文件和步骤2封装的 Element 文件合并为新 Elements[]
  5. MultiDex.expandFieldArray() 执行新 Elements[] 替换 dexElements[]

4.2 makeDexElements()

dexPathListoptimizedDirectory 作为参数调用 makeDexElements()DexPathList 声明在类 BaseDexClassLoader

1
2
3
4
public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;
    ....
}

dexPathList 变量运行时内存结构:

loader

optimizedDirectory 变量运行时内存结构:

optimizedDirectory

下面实例的这个是 V19.makeDexElements(),反射调用 DexPathList.makeDexElements()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static Object[] makeDexElements(Object dexPathList,
                                        ArrayList<File> files,
                                        File optimizedDirectory,
                                        ArrayList<IOException> suppressedExceptions)
  throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {

    // 反射获得DexPathList.makeDexElements()方法
    Method makeDexElements = MultiDex.findMethod(dexPathList, "makeDexElements",
                                                 ArrayList.class, File.class, ArrayList.class);

    // 调用上述反射获取的方法把zip列表转换为Element[]
    return (Object[])((Object[])makeDexElements.invoke(dexPathList, files,
                                                       optimizedDirectory,
                                                       suppressedExceptions));
}

makeDexElements(ArrayList, File, ArrayList) 位于类 DexPathList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Makes an array of dex/resource path elements, one per element of the given array.
private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory,
                                         ArrayList<IOException> suppressedExceptions) {
    ArrayList<Element> elements = new ArrayList<Element>();

    // 遍历files
    for (File file : files) {
        File zip = null;
        DexFile dex = null;
        String name = file.getName();

        if (name.endsWith(DEX_SUFFIX)) {
            // 原生dex文件且不是放在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)) {
            // 因为传入的文件都是zip后缀,所以进入这个分支
            zip = file;

            try {
                // 调用loadDexFile()方法,看下文
                dex = loadDexFile(file, optimizedDirectory);
            } 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 if (file.isDirectory()) {
            // We support directories for looking up resources.
            // This is only useful for running libcore tests.
            elements.add(new Element(file, true, null, null));
        } else {
            System.logW("Unknown file type for: " + file);
        }

        if ((zip != null) || (dex != null)) {
            // 优化后的DexFile封装到Element
            elements.add(new Element(file, false, zip, dex));
        }
    }

    // ArrayList to Array.
    return elements.toArray(new Element[elements.size()]);
}

zip 所在文件夹传到方法。zip 文件封装为 DexFile 过程会调用 原生代码 进行优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Constructs a {@code DexFile} instance, as appropriate depending
// on whether {@code optimizedDirectory} is {@code null}.
private static DexFile loadDexFile(File file, File optimizedDirectory)
        throws IOException {
  
    // 从上文可知optimizedDirectory非空
    if (optimizedDirectory == null) {
        return new DexFile(file);
    } else {
        // 传入zip所在文件夹路径,计算dex优化后的保存路径
        String optimizedPath = optimizedPathFor(file, optimizedDirectory);

        // DexFile构建过程会优化源码,优化后文件保存在optimizedPath
        return DexFile.loadDex(file.getPath(), optimizedPath, 0);
    }
}

zip 文件里面的 dex 提供优化后的保存路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Converts a zip file path of an extracted secondary dex to an output file path for an
// associated optimized dex file.
private static String optimizedPathFor(File path) {
    // Any reproducible name ending with ".dex" should do but lets keep the same name
    // as DexPathList.optimizedPathFor

    File optimizedDirectory = path.getParentFile();
    String fileName = path.getName();
    String optimizedFileName =
            fileName.substring(0, fileName.length() - EXTRACTED_SUFFIX_LENGTH)
            + MultiDexExtractor.DEX_SUFFIX;
    File result = new File(optimizedDirectory, optimizedFileName);
    return result.getPath();
}

流程概括:

  1. 已知每个 zip 包含一个 dex 文件,为每个 dex 计算优化后产物的路径;
  2. MultiDexExtractor.ExtractedDex 就是 zip
  3. 然后用 ExtractedDex 通过 DexFile 类交给 JNI 去优化,优化产物会保存在上述路径;
  4. 优化完成的 DexFile 后封装为 Element 对象,并收集到 Element[]
  5. Element[] 拓展上文说的 dexElements[]

4.3 expandFieldArray()

original[] 存放已经加载的主dex,extraElements[] 存放需要新增的子dex。

具体步骤:

  • 通过反射 dexElements[] 获取 original[]
  • 合成 original[] + extraElements[] 获得 combined[]
  • combined[] 反射替换 dexElements[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Replace the value of a field containing a non null array, by a new array containing the
// elements of the original array plus the elements of extraElements.
// @param instance the instance whose field is to be modified.
// @param fieldName the field to modify.
// @param extraElements elements to append at the end of the array.
private static void expandFieldArray(Object instance, String fieldName,
        Object[] extraElements) throws NoSuchFieldException, IllegalArgumentException,
        IllegalAccessException {
    // dexPathList获取makeDexElements的Field
    Field jlrField = findField(instance, fieldName);

    // 从实例获取Field的实际值original
    Object[] original = (Object[]) jlrField.get(instance);

    // 混合original[]和新的extraElements[]为combined[]
    Object[] combined = (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);

    // 用combined[]覆盖原original[]
    jlrField.set(instance, combined);
}

调用 expandFieldArray()dexElements 只有主dex:

beforeExpandFieldArray

调用 expandFieldArray() 后新增4个子dex:

beforeExpandFieldArray

提取工作完成后内存布局,optimizedDirectory 增加的几个 dex 文件是优化后的产物:

classload_done

4.4 putStoredApkInfo()

提取成功的dexes信息保存到 SharedPreferences 以便下次重用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 只能在获取文件锁LOCK_FILENAME之后才能调用
private static void putStoredApkInfo(Context context, String keyPrefix, long timeStamp,
        long crc, List<ExtractedDex> extractedDexes) {

    SharedPreferences prefs = getMultiDexPreferences(context);
    SharedPreferences.Editor edit = prefs.edit();

    edit.putLong(keyPrefix + KEY_TIME_STAMP, timeStamp); // 时间戳
    edit.putLong(keyPrefix + KEY_CRC, crc); // CRC检验码
    edit.putInt(keyPrefix + KEY_DEX_NUMBER, extractedDexes.size() + 1); // dex数量

    int extractedDexId = 2;
    for (ExtractedDex dex : extractedDexes) {
        // 记录每个子dex的CRC和最后修改时间
        edit.putLong(keyPrefix + KEY_DEX_CRC + extractedDexId, dex.crc);
        edit.putLong(keyPrefix + KEY_DEX_TIME + extractedDexId, dex.lastModified());
        extractedDexId++;
    }

    // 同步写入磁盘
    edit.commit();
}

五、总结

为规避首次启动解析dex时间过长的问题,个人有以下建议:

  • 尽可能提高混淆强度,减少代码和方法使用量;
  • 只使用一次的方法手动合并到调用点,相当于内联;
  • 尽可能把代码存放在主dex,让提取工作在安装过程完成;
  • 不常用功能使用动态加载,让安装时间和提取时间都减少;

虽然 Android5.0 及后期系统使用 ART 虚拟机,在安装过程会全部或部分优化dex,再也不会在应用启动时影响体验。但是减少代码量,即使只能降低安装时长也总归是好事。

六、资料

抖音BoostMultiDex优化实践:Android低版本上APP首次启动时间减少80%(一)

抖音BoostMultiDex优化实践:Android低版本上APP首次启动时间减少80%(二)