关于Android 64K引发的MultiDex你想知道的都在这里:一场由启动黑屏引发的惨案

4,402 阅读40分钟
原文链接: blog.csdn.net

如果你的应用不支持5.0以下的Dalvik VM 那么你没有必要看这篇文章
本文内容由个人按照个人理解,汇总自《参考文献》所示文章,有兴趣者可自行查看相关参考文献

Android分包方案Multidex的相关个人理解,不正之处欢迎指正。

口袋助理是一款移动办公OA,支持手机APP和网页版。目前超过200万家企业正在使用。手机考勤、销售人员外勤管理、客户CRM、销售项目管理、移动日志、流程审批、移动报销、任务管理、企业云盘、企业通讯录、在线聊天 等丰富的功能,帮助企业更好的管理公司业务和服务员工!

一、问题描述

  1. 第一次装包,Verify切换Splash出现先黑屏、后透明、再显示的状况,第二次启动却不会再次引发;
  2. 部分机型上,伴随着第一次装包启动超慢;部分机型上启动时间基本正常;

其实我们遇到的现象只是1所属文本,在测试1的场景中,部分同事又发现了2状况,,,之初,我们认为这是两个不同问题,不需要并案解决。

随后深入研究过程中,才发现两者的强关联性。 这也带给我们一个思考:研发人员应该注意任何细节问题,一旦忽视某个细微之处,就可能让整个的解决思路走入误区

话不多说,处理前和处理后的对比,看图说话:

这里写图片描述 这里写图片描述

二、测试1:耗时时间打印

  • I/yhf: —-super.attachBaseContext start1516240935233
  • I/yhf: —-super.attachBaseContext end1516240935233
  • ==I/yhf: —-MultiDex.install start1516240935233==
  • ==I/yhf: —-MultiDex.install end1516240948455==
  • I/yhf: —-Application.onCreate start1516240948485
  • I/yhf: —-Application.onCreate end1516240948655
  • I/yhf: —-VerifyActivity.onCreate start1516240948743
  • 进入 VerifyActivity 界面
  • I/yhf: —-SplashActivity.onCreate start1516240948969

通过实践我们可以得出这样的结论:启动超时是由于 android天生的64K问题所涉及的MultiDex导致的。

MultiDex方面的解决是比较高风险和困难耗时的,因此我们考虑:先把耗时问题搁置在这里,考虑是否能够单纯的通过解决黑屏问题来规避可视性bug,之后再进一步的对MultiDex耗时进行优化。

三、测试2:黑屏现象

V实验判断是否是VerifyActivity的问题; S实验判断是否是SplashActivity的问题;

3.1 初始状态

  1. VerifyActivity extends BaseActivity
  2. VerifyActivity 配置android:theme=”@style/SplashTheme”
  3. SplashActivity extends Activity
  4. SplashActivity 配置 android:theme=”@style/AppTheme.Search”

    现象:点击桌面图标后,即时出现VerifyActivity.Theme中设置的背景图,静待15s+后,SplashActivity显示; SplashActivity显示前先出现黑色,再出现透明色

3.2 测试S1:验证不是SplashActivity的JAVA逻辑导致

测试方法: VerifyActivity跳往一个简易的TestSplashActivity(和SplashActivity具有相同的AMF配置)

  1. VerifyActivity extends BaseActivity
  2. VerifyActivity 配置android:theme=”@style/SplashTheme”
  3. TestSplashActivity extends BaseActivity
  4. TestSplashActivity 配置 android:theme=”@style/AppTheme.Search”

    现象:与初始异常状况一样

3.3 测试S2:验证SplashActivity的AMF配置有影响,透明片段

  1. VerifyActivity extends BaseActivity
  2. VerifyActivity 配置android:theme=”@style/SplashTheme”
  3. SplashActivity extends BaseActivity
  4. SplashActivity 不配置 android:theme=”@style/AppTheme.Search”
    • == 配置”@style/AppTheme.Basic”(背景透明)
    • 现象:与初始异常状况一样
  5. SplashActivity 配置 android:theme=”@style/Activity.Basic”
    • 现象:点击桌面图标后即时出现Theme中设置的背景图,静待15s+后,SplashActivity显示; 显示前仅出现黑色
  6. SplashActivity 配置 android:theme=”@style/SplashTheme”
    • 现象:点击桌面图标后即时出现Theme中设置的背景图,静待15s+后,SplashActivity显示; 显示前仅出现黑色

对比”@style/AppTheme.Search”、”@style/SplashTheme”、”@style/Activity.Basic”可以发现:

  • AppTheme.Search的 @color/transparent
  • Activity.Basic的@color/activity_bg2
  • SplashTheme的@drawable/splash

自此确定,SplashActivity前显示透明色是由于主题设置了 窗口背景透明导致的

3.4 测试S3:验证不是SplashActivity的JAVA逻辑+AMF配置复合导致

  1. VerifyActivity extends BaseActivity
  2. VerifyActivity 配置android:theme=”@style/SplashTheme”
  3. TestSplashActivity extends BaseActivity
  4. TestSplashActivity 不配置 android:theme=”@style/AppTheme.Search”
    • == 配置”@style/AppTheme.Basic”(背景透明)
    • 现象:与初始异常状况一样
  5. TestSplashActivity 配置 android:theme=”@style/Activity.Basic”
    • 现象:点击桌面图标后即时出现Theme中设置的背景图,静待15s+后,SplashActivity显示; 显示前仅出现黑色
  6. TestSplashActivity 配置 android:theme=”@style/SplashTheme”
    • 现象:点击桌面图标后即时出现Theme中设置的背景图,静待15s+后,SplashActivity显示; 显示前仅出现黑色

3.5 测试VS1:验证不是VerifyActivity 和 SplashActivity的JAVA逻辑复合导致

  1. TestBlackActivity extends BaseActivity
  2. TestBlackActivity 配置android:theme=”@style/SplashTheme”
  3. TestSplashActivity extends Activity
  4. TestSplashActivity 配置 android:theme=”@style/AppTheme.Search”

现象:与初始异常状况一样

3.6 测试VS2:验证不是VerifyActivity 和 SplashActivity的AMF配置复合导致

  1. TestBlackActivity extends BaseActivity
  2. TestBlackActivity 配置android:theme=”@style/SplashTheme”
  3. TestSplashActivity extends BaseActivity
  4. TestSplashActivity 配置 android:theme=”@style/SplashTheme”
    现象:点击桌面图标后即时出现Theme中设置的背景图,静待15s+后,SplashActivity显示; 显示前仅出现黑色
  5. 都配置 android:theme=”@style/Activity.Basic”
    现象:点击桌面图标后即时出现Theme中设置的白色背景,静待15s+后,SplashActivity显示; 显示前仅出现黑色
  6. 都不配置 == 配置”@style/AppTheme.Basic”(背景透明)
    点击桌面图标后无响应,静待15s+后,SplashActivity显示。期间无黑色出现;

3.7 基本可以判定:黑色的显示不是由于配置导致的,似乎是Activity切换自动导致的,待进一步确认

3.8 测试V1:验证不是VerifyActivity的JAVA逻辑导致

测试方法: VerifyActivity 改为一个简易的TestBlackActivity(和VerifyActivity具有相同的AMF配置)

  1. TestBlackActivity extends BaseActivity
  2. TestBlackActivity 配置android:theme=”@style/SplashTheme”
  3. TestBlackActivity.onCreate中即时跳往SplashActivity

    现象:与初始状况一样

3.9 测试v2: 验证VerifyActivity的AMF配置有影响

  1. TestBlackActivity extends BaseActivity
  2. TestBlackActivity 不配置android:theme=”@style/SplashTheme”
    == 配置”@style/AppTheme.Basic”(背景透明)
  3. TestBlackActivity.onCreate中即时跳往SplashActivity

    现象:点击桌面图标后无响应,静待15s+后,SplashActivity显示。期间无黑色出现;

    现象分析: Applaction生命周期超时,导致Activity启动时,相关资源仍未加载完成。此时先显示PreWindows,我们自定义的windows背景为透明。 也就是说在我们看到无反应的期间,之所一直看到桌面,其实是有一层透明的窗口背景

    摘选我在秒开应用中的一段描述:Android启动优化之打造秒开应用

当打开一个Activity时,如果这个Activity所属Application还没有在运行,系统会为这个Activity的创建一个进程(每开启一个进程都会有一个Application,所以Application的onCreate()可能会被调用多次),但进程的创建与初始化都需要时间,在这个动作完成之前,如果初始化的时间过长,屏幕上可能没有任何动静,用户会以为没有点到按钮。

所以既不能停在原来的地方又没到显示新的界面,怎么办呢?这就有了StartingWindow(也称之为PreviewWindow)的出现,这样看起来就像Activity已经启动起来了,只是数据内容还没有初始化好。

StartingWindow一般出现在应用程序进程创建并初始化成功前,所以它是个临时窗口,对应的WindowType是TYPE_APPLICATION_STARTING。目的是告诉用户,系统已经接受到操作,正在响应,在程序初始化完成后实现目的UI,同时移除这个窗口.

一般情况下我们会对Application和Activity设置Theme,系统会根据设置的Theme初始化StartingWindow。Window布局的顶层是DecorView,StartingWindow显示一个空DecorView,但是会给这个DecorView应用这个Activity指定的Theme,如果这个Activity没有指定Theme就用Application的(Application系统要求必须设置Theme)

在Theme中可以指定窗口的背景,Activity的ICON,APP整体文字颜色等,如果说没有指定任何属性,就会用默认的属性,也就是上文中提到的空DecorView

3.10 解决手段尝试

  1. Android启动Activity中间黑屏
    • true
    • 全配:先无响应,后黑色,后显示界面
    • 单配Splash: 还是有黑色

3.11 结论

无法有效规避,我们在:

==首次启动加载dex导致的黑屏和ANR问题==

如果你使用multidex,你需要意识到它对app启动性能有影响。我们通过跟踪app的启动时间发现了这个问题-用户点击app图标到所有图片都下载完并显示给用户的这段时间。一旦multidex 启用,在所有运行Kitkat (4.4) 及以下的设备上我们的app启动时间就会大约增加15%。更多信息参考 Carlos Sessa的Lazy Loading Dex files 。

这是因为Android 5.0 以及更高版本使用了一个叫做ART的运行时,它天生就支持从应用的apk文件中加载multiple dex文件

由此看来,最终的解决还是需要通过解决根本问题来进行。

四、64K问题所引发的分包方案MultiDex

4.1 64K问题的由来

当一个app的功能越来越复杂,代码量越来越多,引入的jar第三方包越来越多,也许有一天便会突然遇到AndroidStudio构建失败了:

UNEXPECTED TOP-LEVEL EXCEPTION: java.lang.IllegalArgumentException: method ID not in [0, 0xffff]: 65536 
    at com.android.dx.merge.DexMerger$6.updateIndex(DexMerger.java:501) 
    at com.android.dx.merge.DexMerger$IdMerger.mergeSorted(DexMerger.java:276) 
    at com.android.dx.merge.DexMerger.mergeMethodIds(DexMerger.java:490) 
    at com.android.dx.merge.DexMerger.mergeDexes(DexMerger.java:167) 
    at com.android.dx.merge.DexMerger.merge(DexMerger.java:188) 
    at com.android.dx.command.dexer.Main.mergeLibraryDexBuffers(Main.java:439) 
    at com.android.dx.command.dexer.Main.runMonoDex(Main.java:287) 
    at com.android.dx.command.dexer.Main.run(Main.java:230) 
    at com.android.dx.command.dexer.Main.main(Main.java:199) 
    at com.android.dx.command.Main.main(Main.java:103):Derp:dexDerpDebug FAILED

这是Android早期研发人员的短视行为导致的,我们先来交代下android打包的背景知识:

  1. Android原生应用项目是由Java语法所写的.java文件构成的
  2. .java文件在编译期间会转换为jvm运行代码.class文件
  3. Android打包期间,.class文件(以及任何jar依赖)被编译成单个classes.dex文件
  4. 这个.dex文件和其他图片、xml等资源文件组合最终形成apk

更多内容参看着了How Android Apps are Built and Run

然而在早期的android系统开发过程中,在合成classes.dex文件阶段使用了短容量的变量控制,导致一个dex文件系统只允许最多有65k个方法(毕竟当时谁也没想到Android会有今日的容量和体积),如果你的源代码和狂拽炫酷叼炸天的三方库中方法超过了这个限制,就会导致打包失败

  1. 在安卓的早期,达到65k方法上限的应用解决这个问题的办法就是使用Proguard来减少无用的代码。但是,这个方法有局限,并且只是为生产app拖延了接近65k限制的时间

  2. 一种比较流行的方案是插件化方案,工作量太大;

  3. Google在推出MultiDex之前:

  4. Android官方为了弥补当时短视行为所造成的问题,给出了官方的补丁方案MultiDex

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

4.2 MultiDex分包方案

4.2.1 分包思路

简单地说就是:

  1. 先把你的app 的class拆分成主次两个dex。
  2. 安装的时候,处理主dex
  3. 你的程序运行起来后,自己把第二个dex给load进来

需要注意:
++这是对Dalvik VM过程的描述,这种动态加载dex的能力运用了能力归根结底还是因为java 的classloader类加载机制,然而对于ART VM而言,其实在安装阶段就会吧所有的dex合并起来,而不再等到程序的运行期++。先有个这样的认识,我们下文会讲到ART和Dalvik不同弄处理方式对MultiDex的影响

这是个动态加载模块的框架,Dalvik VM 的

沿着这条道走,Android模块动态化加载,包括dex级别和apk级别的动态化加载,各种玩法层出不穷,参见这里dynamic-load-apkandroid-pluginmgrAndroidDynamicLoaderApkplugDroidPlugin,有兴趣的自行阅读下,不在本篇研究范围内

Dex分包规则

Android sdk build tool中的mainDexClasses脚本会自动完成。

该脚本在版本21以上才会有,要求输入一个文件组(包含编译后的目录或jar包),然后分析文件组中的类并写入到–output所指定的文件中。使用方法非常很简单:

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

实现原理也不复杂,主要分为三步:

  1. 环境检查,包括传入参数合法性检查,路径检查以及proguard环境检测等。
  2. 使用mainDexClasses.rules规则,通过Proguard的shrink功能,裁剪无关类,生成一个tmp.jar包。
  3. 通过生成的tmp jar包,调用MainDexListBuilder类生成主dex的文件列表。

这里只是简单的得到所有入口类(即rules中的Instrumentation、application、Activity、Annotation等等)的直接引用类。

举个栗子:有MainActivity、DirectReferenceClass、InDirectReferenceClass三个类,其中DirectReferenceClass是MainActivity的直接引用类,InDirectReferenceClass是DirectReferenceClass的直接引用类。而InDirectReferenceClass是MainActivity的间接引用类(即直接引用类的所有直接引用类)

public class MainActivity extends Activity {
    protected void onCreate(Bundle savedInstanceState) {
        DirectReferenceClass test = new DirectReferenceClass();
    }
}

public class DirectReferenceClass {
    public DirectReferenceClass() {
        InDirectReferenceClass test = new InDirectReferenceClass();
    }
}

public class InDirectReferenceClass {
    public InDirectReferenceClass() {

    }
}

编译期间拆分Dex

Android dex分包方案

通过 gradle 编译生成 apk 的期间,可以通过 Gradle Console视图查看 gradle 执行任务的输出,期间跟 multidex 几个相关的任务如下:

:app:transformClassesWithJarMergingForDevDebug 
:app:collectDevDebugMultiDexComponents
:app:transformClassesWithMultidexlistForDevDebug 
:app:transformClassesWithDexForDevDebug 
  1. JarMergingTransform
    • JarMergingTransform 的主要作用是将所用到的 jar 转换至一个单一的 Jar 中
    • 具体输出的结果,可以在 build/intermediates/transforms/jarMerging 目录下,看到一个名称为 combined 的 jar 文件
  2. CreateManifestKeepList
    • CreateManifestKeepList 继承自 DefaultAndroidTask, 这一步会读取项目之前合并后的 manifest 文件,根据既定的规则,获取其中的 application、activity、service、provider、instrumentation 类,与 Mainifest 中的类组件进行比较来获取,最后会在 build/intermediates/multidex 下生成名为 manifest_keep.txt 的文件
    • 此任务设置 Filter类,支持对特定的类进行过滤,让指定的类保存在 maindex 中。但是此方法已被标记为 Deprecated, 可能会在后续的版本中废弃掉
  3. MultiDexTransform
    • MultiDexTransform 的主要任务是根据之前的 mainfest_keep 及一些 proguard 文件来生成 mainDex 中指定的类集合文件,对应生成的输出结果为 maindexlist.txt
    • mainDex 的生成规则,其是如何指定哪些类在 mainDexList 中
    • 查看源码可看到它把这部分工作交给类 ClassReferenceListBuilder。其又调用了类 MainDexListBuilder,后者对应着 build_tools 中的 mainClasses 工具中处理依赖关系所使用到的类。这里真正的依赖判端逻辑是在 ClassReferenceListBuilder中,所需要指定的两个参数 path和 jarOfRoots,前者表示的是所需要处理的所有类文件的路径(对应上文的 combined.jar),后者指定的是所需要在 mainDex 中的类(即处理依赖时的 root,获取 root 所依赖的类, 对应上述步骤中生成的 manifest_keep.txt 中的类)
    • 其生成规则是遍历 jarOfRoots中的 class 文件,将其对应程 DirectClassFile对象(包含 class 信息的相应对象),之后从其中获取常量池中的类型,判断是类、方法、字段,并添加其类型所包含的类型信息。若是方法的时候,则需要的是方法的返回值类型以及参数值的类型。这里类型信息进行获取的时候,会从类、超类、实现的接口列表三个角度进行判断获取相应的类型信息
  4. DexTransform
    • 它被 dexTask 所使用,相对应的调用程序为 build-tools 中的 dx 程序。在 DexTransform 中的参数 dexOptions、mainDexListFile 指定了 dx 命令执行过程中所需要的参数
    • 其主要的任务用来生成 apk 中的 dex 文件,若是指定了 multidex 为 true 时,则会根据 mainDexList 文件(指定哪些类会在 mainDex)来划分生成最后的多个 dex 文件
    • 这一步就是通过以上步骤的输出作为输入,进而执行 dx 命令的,来生成最终的 dex

安装或运行期加载Second Dex

  • ART

    • 安装时会把各个dex合成一个可执行oat文件,==不存在运行期加载==
  • Dalvik(对于5.0以下的系统,我们需要在启动时手动加载其他的dex)

    • 判断本地是否已经有可运行的mainodex,并使用(安装阶段,会dexopt对MainClass.dex转化为odex)
    • 触发Applaction生命周期
    • Applaction生命周期中,研发人员主动调用 MultiDex.install(this);进行合包
      • 解压APK,通过DexFile来加载Secondary等附属DEX,如果有对应odex则直接使用,没有的话,再次进行dexopt

事实上,若我们在attachBaseContext中调用Multidex.install,我们只需引入Application的直接引用类即可,但是MultiDex方案中的mainDexClasses将Activity、ContentProvider、Service等的直接引用类也引入,主要是满足需要在非attachBaseContent加载多dex的需求

需要注意的是,如果存在以下代码,将出现NoClassDefFoundError错误:

public class HelloMultiDexApplication extends Application {
    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        DirectReferenceClass test = new DirectReferenceClass();
        MultiDex.install(this);
    }
}

这是因为在实际运行过程中,DirectReferenceClass需要的InDirectReferenceClass并不一定在主dex。解决方法是手动将该类放于dx的-main-dex-list参数中

afterEvaluate {
    tasks.matching {
        it.name.startsWith('dex')
    }.each { dx ->
        if (dx.additionalParameters == null) {
            dx.additionalParameters = []
        }
        dx.additionalParameters += '--multi-dex'
        dx.additionalParameters += "--main-dex-list=$projectDir/<filename>".toString()
    }
}

下面代码片段是BaseDexClassLoader findClass的过程:

protected Class<?> findClass(String name) throws ClassNotFoundException {
    List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
    Class c = pathList.findClass(name, suppressedExceptions);
    if (c == null) {
        ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList); 
        for (Throwable t : suppressedExceptions) {
            cnfe.addSuppressed(t);
        }
        throw cnfe;
    }
    return c;
}

下面代码片段为怎么通过DexFile来加载Secondary DEX并放到BaseDexClassLoader的DexPathList中:

private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
                            File optimizedDirectory)
        throws IllegalArgumentException, IllegalAccessException,
        NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
    /* The patched class loader is expected to be a descendant of
     * dalvik.system.BaseDexClassLoader. We modify its
     * dalvik.system.DexPathList pathList field to append additional DEX
     * file entries.
     */
    Field pathListField = findField(loader, "pathList");
    Object dexPathList = pathListField.get(loader);
    ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
    expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
            new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
            suppressedExceptions));
    try {
        if (suppressedExceptions.size() > 0) {
            for (IOException e : suppressedExceptions) {
                //Log.w(TAG, "Exception in makeDexElement", e);
            }
            Field suppressedExceptionsField =
                    findField(loader, "dexElementsSuppressedExceptions");
            IOException[] dexElementsSuppressedExceptions =
                    (IOException[]) suppressedExceptionsField.get(loader);

            if (dexElementsSuppressedExceptions == null) {
                dexElementsSuppressedExceptions =
                        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(loader, dexElementsSuppressedExceptions);
        }
    } catch(Exception e) {
    }
}

4.2.2 配置方案

步骤1:官方建议在分包前规避 64K 限制

在将您的应用配置为支持使用 64K 或更多方法引用之前,您应该采取措施减少应用代码调用的引用总数,包括由您的应用代码或包含的库定义的方法。下列策略可帮助您避免达到 DEX 引用限制:

  1. 检查您的应用的直接和传递依赖项 - 确保您在应用中使用任何庞大依赖库所带来的好处大于为应用添加大量代码所带来的弊端。一种常见的反面模式是,仅仅为了使用几个实用方法就在应用中加入非常庞大的库。减少您的应用代码依赖项往往能够帮助您规避 dex 引用限制
  2. 通过 ProGuard 移除未使用的代码 - 为您的版本构建启用代码压缩以运行 ProGuard。启用压缩可确保您交付的 APK 不含有未使用的代码

使用这些技巧使您不必在应用中启用 Dalvik 可执行文件分包,同时还会减小 APK 的总体大小

4.2.2.2 步骤2:开启分包

如果项目的 minSdkVersion 设置为 21 或更高值,只需在模块级 build.gradle 文件中将 multiDexEnabled 设置为 true,如此处所示:

android {
    defaultConfig {
        ...
        minSdkVersion 21 
        targetSdkVersion 26
        multiDexEnabled true
    }
    ...
}

步骤3:低版本分包库依赖支持

  1. Android 5.0(API 级别 21)之前的平台版本使用 Dalvik 运行时来执行应用代码。默认情况下,Dalvik 限制应用的每个 APK 只能使用单个 classes.dex 字节码文件。要想绕过这一限制,您可以使用* Dalvik 可执行文件分包支持库*,它会成为您的应用主要 DEX 文件的一部分,然后管理对其他 DEX 文件及其所包含代码的访问

此库可以为使用多个 Dalvik Executable (DEX) 文件开发应用提供支持。引用超过 65536 个方法的应用须使用 Dalvik 可执行文件分包配置。如需了解有关使用 Dalvik 可执行文件分包的详细信息。此库的 Gradle 构建脚本依赖关系标识符如下所示:

//在app的gradle脚本里写上:
com.android.support:multidex:1.0.0
  1. Android 5.0(API 级别 21)及更高版本使用名为 ART 的运行时,后者原生支持从 APK 文件加载多个 DEX 文件。ART 在应用安装时执行预编译,扫描 classesN.dex 文件,并将它们编译成单个 .oat 文件,供 Android 设备执行。因此,如果您的 minSdkVersion 为 21 或更高值,则不需要 Dalvik 可执行文件分包支持库。

步骤4:替换 Application 类

  • 如果项目没有替换 Application 类,请编辑清单文件,按如下方式设置 标记中的 android:name
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.myapp">
    <application
            android:name="android.support.multidex.MultiDexApplication" >
        ...
    </application>
</manifest>
  • 如果项目已经替换了 Application 类,请按如下方式对其进行更改以扩展 MultiDexApplication(如果可能):
-public class MyApplication extends MultiDexApplication { ... }
  • 或者,如果项目已经替换了 Application 类,但无法更改基本类,则可以改为替换 attachBaseContext() 方法并调用 MultiDex.install(this) 来启用 Dalvik 可执行文件分包:
public class MyApplication extends SomeOtherApplication {
  @Override
  protected void attachBaseContext(Context base) {
     super.attachBaseContext(context);
     Multidex.install(this);
  }
}

这里写图片描述

步骤5:构建工具自动分包 和 运行时自动合包

构建应用后,Android 构建工具会根据需要构建主 DEX 文件 (classes.dex) 和辅助 DEX 文件(classes2.dex 和 classes3.dex 等)。然后,构建系统会将所有 DEX 文件打包到您的 APK 中。

运行时,Dalvik 可执行文件分包 API 使用特殊的类加载器来搜索适用于您的方法的所有 DEX 文件(而不是仅在主 classes.dex 文件中搜索)

4.2.3 自定义控制主 DEX 文件中需要的类

为 Dalvik 可执行文件分包构建每个 DEX 文件时,构建工具会自动的执行复杂的决策制定来确定主要 DEX 文件中需要的类,以便应用能够成功启动

如果启动期间需要的任何类未在主 DEX 文件中提供,那么您的应用将崩溃并出现错误 java.lang.NoClassDefFoundError。该情况不应出现在直接从应用代码访问的代码上,因为构建工具能识别这些代码路径,但可能在代码路径可见性较低(如使用的库具有复杂的依赖项)时出现。例如,如果代码使用自检机制或从原生代码调用 Java 方法,那么这些类可能不会被识别为主 DEX 文件中的必需项

因此,如果您收到 java.lang.NoClassDefFoundError,则必须使用构建类型中的 multiDexKeepFile 或 multiDexKeepProguard 属性声明它们,以手动将这些其他类指定为主 DEX 文件中的必需项。如果类在 multiDexKeepFile 或 multiDexKeepProguard 文件中匹配,则该类会添加至主 DEX 文件

multiDexKeepFile 属性

  1. 您在 multiDexKeepFile 中指定的文件应该每行包含一个类,并且采用 com/example/MyClass.class 的格式。例如,您可以创建一个名为 multidex-config.txt 的文件,如下所示:
com/example/MyClass.class
com/example/MyOtherClass.class
  1. 按以下方式针对构建类型声明该文件

Gradle 会读取相对于 build.gradle 文件的路径,因此如果 multidex-config.txt 与 build.gradle 文件在同一目录中,以上示例将有效

android {
    buildTypes {
        release {
            multiDexKeepFile file 'multidex-config.txt'
            ...
        }
    }
}

multiDexKeepProguard 属性

multiDexKeepProguard 文件使用与 Proguard 相同的格式,并且支持整个 Proguard 语法

  1. 您在 multiDexKeepProguard 中指定的文件应该在任何有效的 ProGuard 语法中包含 -keep 选项。例如,-keep com.example.MyClass.class。您可以创建一个名为 multidex-config.pro 的文件,如下所示:
-keep class com.example.MyClass
-keep class com.example.MyClassToo

//如果您想要指定包中的所有类,文件将如下所示:
-keep class com.example.** { *; } // All classes in the com.example package
  1. 按以下方式针对构建类型声明该文件:
android {
    buildTypes {
        release {
            multiDexKeepProguard 'multidex-config.pro'
            ...
        }
    }
}
优化开

4.2.4 构建不同版本以便于开发

MultiDex会大幅增加构建处理时间,因为构建系统必须就哪些类必须包括在主 DEX 文件中以及哪些类可以包括在辅助 DEX 文件中作出复杂的决策。这意味着使用 Dalvik 可执行文件分包的增量式构建通常耗时更长,可能会拖慢开发进度

为了缩短耗时更长的 Dalvik 可执行文件分包输出构建时间,请利用 productFlavors(一个开发定制和一个发布定制,具有不同的 minSdkVersion 值)创建两个构建变型:

  1. 对于开发定制,将 minSdkVersion 设置为 21。该设置将启用一个名为 pre-dexing 的构建功能,此功能使用仅适用于 Android 5.0(API 级别 21)和更高版本的 ART 格式更快生成 Dalvik 可执行文件分包输出。
  2. 对于发布定制,将 minSdkVersion 设置为适于您的实际最低支持级别。此设置生成的 Dalvik 可执行文件分包 APK 可兼容更多设备,但构建时间更长。
android {
    defaultConfig {
        ...
        multiDexEnabled true
    }
    productFlavors {
        dev {
            // Enable pre-dexing to produce an APK that can be tested on
            // Android 5.0+ without the time-consuming DEX build processes.
            minSdkVersion 21
        }
        prod {
            // The actual minSdkVersion for the production version.
            minSdkVersion 14
        }
    }
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'),
                                                 'proguard-rules.pro'
        }
    }
}
dependencies {
    compile 'com.android.support:multidex:1.0.1'
}

完成此配置变更后,可以为增量式构建使用应用的 devDebug 变体,后者集 dev 产品定制与 debug 构建类型的属性于一身。这将创建已启用 Dalvik 可执行文件分包且禁用 proguard 的可调试应用(因为 minifyEnabled 默认为 false)。这些设置会使适用于 Gradle 的 Android 插件执行以下操作:

  1. 执行 pre-dexing:将每个应用模块和每个依赖项构建为单独的 DEX 文件。
  2. 将每个 DEX 文件加入 APK,并且不做任何修改(不执行代码压缩)。
  3. 最重要的是,模块 DEX 文件不执行合并操作,因此可以避免为确定主 DEX 文件的内容而进行长时间的计算。
    4
    这些设置的好处是,可以进行快速的增量式构建,因为只有修改过的模块的 DEX 文件才会在后续构建期间重新计算并重新打包。但是,这些构建的 APK 只能用于在 Android 5.0 设备上进行测试。不过,由于是以定制形式实现配置,您保留了使用与发布相适的最低 API 级别和 ProGuard 代码压缩执行正常构建的能力。

您还可以构建其他变体,包括 prodDebug 变体构建,该变体虽然构建时间更长,但可用于开发以外的测试。在所示配置内,prodRelease 变体将是最终测试和发布版本。如需了解有关使用构建变体的详细信息,请参阅配置构建变体

五、分包方案MultiDex引发的新问题

官方文档中,已经承认这种分包方案具有一些已知的局限性:

  • 由于存在 Dalvik linearAlloc 错误(问题 22586),使用 Dalvik 可执行文件分包的应用可能无法在运行的平台版本早于 Android 4.0(API 级别 14)的设备上启动。如果您的目标 API 级别低于 14,请务必针对这些版本的平台进行测试,因为您的应用可能会在启动时或加载特定类群时出现问题。代码压缩可以减少甚至有可能消除这些潜在问题。

  • 由于存在 Dalvik linearAlloc 限制(问题 78035),因此,如果使用 Dalvik 可执行文件分包配置的应用发出非常庞大的内存分配请求,则可能会在运行期间发生崩溃。尽管 Android 4.0(API 级别 14)提高了分配限制,但在 Android 5.0(API 级别 21)之前的 Android 版本上,应用仍有可能遭遇这一限制

  • 启动期间在设备数据分区中安装 DEX 文件的过程相当复杂,如果辅助 DEX 文件较大,可能会导致首次加载时会出现明显的黑屏,甚至应用无响应 (ANR) 错误。在此情况下,您应该通过 ProGuard 应用代码压缩以尽量减小 DEX 文件的大小,并移除未使用的那部分代码

==在ART下MultiDex是不存在这些问题的==,这主要是因为ART下采用Ahead-of-time (AOT) compilation技术,系统在APK的==安装过程==中会使用自带的dex2oat工具对APK中可用的DEX文件进行编译并生成一个可在本地机器上运行的oat文件(5.0以下只能苦逼的启动时加载),这样能提高应用的启动速度,因为是在安装过程中进行了处理这样==会影响应用的安装速度==,对ART感兴趣的可以参考一下 ART和Dalvik的区别

5.1 问题1:安装失败INSTALL_FAILED_DEXOPT

前面说的Issue 22586问题:部分低端2.3机型安装失败,INSTALL_FAILED_DEXOPT

5.1.1 背景知识 dexOpt

apk是一个zip压缩包,dalvik每次加载apk都要从中解压出class.dex文件,加载过程还涉及到dex的classes需要的杂七杂八的依赖库的加载,这无疑是一个耗时操作。为了提高效率,Android进行了如下优化:

  1. 在app第一次安装到手机之后,系统运行dexopt程序对dex进行优化,只处理mainclass.dex,将dex的依赖库文件和一些辅助数据打包成odex文件
  2. 存放在cache/dalvik_cache目录下,保存格式为apk路径 @ apk名 @ classes.dex。
  3. dalvik vm运行期会把未加载的Second dex等调用dexopt进行再次转化和存储(MultiDex.install)。 Art VM在安装阶段就会合并,dexopt整体只触发一次。

这样以空间换时间大大缩短读取/加载dex文件的过程.

期间,dexopt程序的dalvik分配一块内存来统计你的app的dex里面的classes的信息,Android 2.2和2.3的缓冲区只有5MB,Android 4.x提高到了8MB或16MB。当方法数量过多导致超出缓冲区大小时(不再是dex65536),就会导致dexopt failed

5.1.2 解决方案

减小dex的大小

这个linearAlloc的限制不仅仅在安装时候的dexopt程序里 7,还在你的app的dalvik rumtime里

方案1:动态hack计算

FB工程师Read The Fucking Source Code提出了一个hack方案:这个linearAlloc的size定义在c层而且是一个全局变量,他们通过对结构体的size的计算成功覆盖了该值的内容

这里要特别感谢C语言的指针和内存的设计,然而这个Hack方的实现是比较困难的,同时dvk虚拟机c层代码在2.x 4.x 版本里有变更,找到那个内存地址太难,未必成功

我们有偷懒的解决方案,为了避免2.3机型runtime 的linearAlloclimit ,最好保持每一个dex体积<4M ,刚才的的value<=48000

方案2:设置固定值

android.applicationVariants.all {
    variant ->
        dex.doFirst{
            dex->
            if (dex.additionalParameters == null) {
                dex.additionalParameters = []
            }
                dex.additionalParameters += '--set-max-idx-number=48000'

       }
}

–set-max-idx-number= 用于控制每一个dex的最大方法个数,写小一点可以产生好几个dex

5.2 问题2:运行失败 NoClassDefFoundError

5.2.1 问题原因

出现这个错误时,解决办法是将异常中的这个类加至 mainDex 中。但是这个错误跟 NotClassFoundException的区别,可查阅 链接

其出现这个问题的说法,简单理解为Multidex默认的分dex实现保证了应用内四大组件的class都在主dex中,但仍然会有NoClassXXX类型的crash出现。因为Android 加载Dex files采用的是Lazy Load,这会导致虚拟机中即使已经加载了某个class,但如果这个class不在主dex的class列表中,则主dex有可能引用不到这个class,从而导致NoClassDefFoundError

5.2.2 思路

在 module 下创建 multidex.keep 文件,并在其中罗列出那些 class,以便让编译器知道在 main dex 文件中要保持哪些 class。

  1. 在工程目录中创建一个multidex.keep文件。
  2. 把java.lang.NoClassDefFoundError中报告的class列举到multidex.keep文件。(注意: 不要直接修改build目录里的maindexlist.txt ,这个文件每次在编译的时候都会生成)。
  3. 添加如下脚本到build.gradle。这个脚本将在编译项目的时候把multidex.keep 和 由Gradle生成的maindexlist.txt 结合在一起

如果你在本地的测试机上没有遇到这个问题,并不代表你的 APP 没有问题,通过查看友盟的崩溃记录和使用一些真机测试平台来进行检查,通常情况下会有所发现 1. 使用下述任意方式配置完成后,clean 然后 rebuild 项目,完成之后在 module 下的build/intermediates/multi-dex/xxx里找到 maindexlist.txt 文件(如果找不到相关目录,可能需要你同步后 rebuild 项目才能生成),复制里面的内容到 module 根目录下 multidex.keep 文件中(没有则先创建此文件)。

  1. 然后,比较重要的一步就是:通过友盟、测试记录、Bug记录等获取到 NoClassDefFoundError 错误对应的类,按照 maindexlist.txt 文件的方式添加这些类到 multidex.keep 文件中就可解决了

如果觉得一个一个添加NoClassDefFoundError异常类麻烦,可以一次性的找出在应用启动后,虚拟机中已经加载但不在主dex中的class列表的所有class,记录到一个multidex.keep的文本文件中。该查找方法可以通过在应用启动后一个合适的时机调用MultiDexUtils的getLoadedExternalDexClasses方法来手动收集:

/** 
 * Get all loaded external classes name in "classes2.dex", "classes3.dex" .... 
 * @param context 
 * @return get all loaded external classes 
 */  
public List<String> getLoadedExternalDexClasses(Context context) {  
    try {  
        final List<String> externalDexClasses = getExternalDexClasses(context);  
        if (externalDexClasses != null && !externalDexClasses.isEmpty()) {  
            final ArrayList<String> classList = new ArrayList<String>();  
            final java.lang.reflect.Method m = ClassLoader.class.getDeclaredMethod("findLoadedClass", new Class[]{String.class});  
            m.setAccessible(true);  
            final ClassLoader cl = context.getClassLoader();  
            for (String clazz : externalDexClasses) {  
                if (m.invoke(cl, clazz) != null) {  
                    classList.add(clazz.replaceAll("\\.", "/").replaceAll("$", ".class"));  
                }  
            }  
            return classList;  
        }  
    } catch (Exception e) {  
        e.printStackTrace();  
    }  
    return null;  
}  

手动获取了multidex.keep文件之后,接下来需要修改Gradle 编译脚本:在Gradle打包生成Dex文件之前将multidex.keep合并到主Dex中,从而保证主Dex的加载不会发生NoClassDefFoundError

在这里,我们不再讲解语法相关的,而是讨论下如何让Gradle自动把multidex.keep 和 由Gradle生成的maindexlist.txt 结合在一起

5.2.3 实施

方案1

apply plugin: 'com.android.application'
android {
  ...
}
dependencies {
  ...
}
//【1】Hook android gradle multidex list 相关 task:在createXXXMainDexClassList task之后插入一个自定义task
tasks.whenTaskAdded { task ->
    android.applicationVariants.all { variant ->
        if (task.name == "create${variant.name.capitalize()}MainDexClassList") {
            task.finalizedBy "fix${variant.name.capitalize()}MainDexClassList"
        }
    }
}
//【2】在构建变种variant中加入该自定义task的声明
android.applicationVariants.all { variant ->
    task "fix${variant.name.capitalize()}MainDexClassList" << {
        logger.info "Fixing main dex keep file for $variant.name"
        File keepFile = new File("$buildDir/intermediates/multi-dex/$variant.buildType.name/maindexlist.txt")
        keepFile.withWriterAppend { w ->
            // Get a reader for the input file
            w.append('\n')
            new File("${projectDir}/multidex.keep").withReader { r ->
                // And write data from the input into the output
                w << r << '\n'
            }
            logger.info "Updated main dex keep file for ${keepFile.getAbsolutePath()}\n$keepFile.text"
        }
    }
}
//【2】另一种写法
android.applicationVariants.all { variant ->  
    task "fix${variant.name.capitalize()}MainDexClassList" << {  
        println "Fixing main dex keep file for $variant.name, while the build type is release."  
        if (new File("${rootProject.projectDir}/buildsystem/multidex.keep").exists()  
                && variant.buildType.name == 'release'  
                && project.android.defaultConfig.multiDexEnabled) {  

            File keepFile = new File("$buildDir/intermediates/multi-dex/${variant.dirName}/maindexlist.txt")  


            // Step1 利用multidex.keep的列表找到混淆后的class name  
            // Read proguard  mapping file to find real class name in dex file  
            def mappingList = ["key":"value"];  
            File mapping = new File("$buildDir/outputs/mapping/${variant.dirName}/mapping.txt")  
            if (mapping.exists()) {  
                mapping.eachLine { line ->  
                    if (!line.startsWith(" ") && line.endsWith(":")) {  
                        String key = line.split("->")[0].trim();  
                        String value = line.split("->")[1].trim().split(":")[0].trim();  
                        mappingList.put(key, value);  
                    }  
                }  
            }  
            keepFile.withWriterAppend { w ->  
                // Get a reader for the input file  
                w.append('\n')  

                // Step2 将对应的class list插进入multidex的构建产物maindexlist.txt 。  
                new File("${rootProject.projectDir}/buildsystem/multidex.keep").withReader { r ->  
                    boolean hasFindMapping = false  
                    // And write data from the input into the output  
                    mappingList.each {  
                        if (it.key.equals(r)) {  
                            r = it.value;  
                            hasFindMapping = true  
                        }  
                    }  
                    w << r << '\n'  
                    w.flush()  
                }  
                println "Updated main dex keep file for ${keepFile.getAbsolutePath()}"  
            }  
        } else {  
            println 'There is no multidex.keep file in your project root dir or build type is debug or multidex not enabled.'  
        }  
    }  
}  

方案2

    afterEvaluate {
        project.tasks.each { task ->
            if (task.name.startsWith('collect') && task.name.endsWith('MultiDexComponents')) {
                println "main-dex-filter: found task $task.name"
                task.filter { name, attrs ->
                    def componentName = attrs.get('android:name')
                    if ('activity'.equals(name)) {
                        println "main-dex-filter: skipping, detected activity [$componentName]"
                        return false
                    } else {
                        println "main-dex-filter: keeping, detected $name [$componentName]"
                        return true
                    }
                }
            }
        }
    }

这一步对应 gradle 执行过程中的 CreateManifestKeepList,利用其提供的 filter,进行一些过滤操作,其中 name参数表示为节点类型,例如 activity、service、receiver 等; attrs参数表示相应的节点信息,它是一个 Map 类型的参数,可表示的值形如 [‘android:name’:’com.example.ActivityClass’]

这一步可对 mainDex 中的组件信息做一些过滤,而不是添加所有的组件信息。像上述代码的处理就很残暴,把所有的 activity 都过滤掉。

PS: 需要注意的是,在源码中的 setFilter 已经被标为废弃,可能会在后续的版本被替换掉,所以用这种方案需要所使用的 gradle plugin 版本注意一二

方案3

apply plugin: 'com.android.application'
android {
  ...
  afterEvaluate {
        tasks.matching {
            it.name.startsWith('dex')
        }.each { dx ->
            if (dx.additionalParameters == null) {
                dx.additionalParameters = []
            }
            //允许生成多个dex文件 
            dx.additionalParameters += '--multi-dex' // enable multidex

            // optional
            // 设置multidex.keep文件中class为第一个dex文件中包含的class,如果没有下一项设置此项无作用 
            dx.additionalParameters += "--main-dex-list=$projectDir/class-list.txt".toString() // enable the main-dex-list
            //此项添加后第一个classes.dex文件只能包含-main-dex-list列表中class 
            dx.additionalParameters += '--minimal-main-dex'
        }
    }
}
dependencies {
  ...
}

这一步直接对应 dx 最终的调用,即修改我们上文所提到的参数值,将其替换我们手动填充的值,但是这一步的 multidex.keep 文件就需要我们折腾一二了

不过针对这个方案,笔者是一直没有找到在 Task 中相对应的以 dex 开头的任务,所以这个方案没有生效。那为什么会有这种写法呢?笔者在 Project中的 Variant中相对应的 ApkVariant类中看到一点信息,此接口定义了 getDex()方法,对应实现在 ApkVariantImpl中如下:

    @Nullable 
    @Override 
    public Object getDex() { 
        throw new RuntimeException("Access to the dex task is now impossible, starting with 1.4.0/n" + "1.4.0 introduces a new Transform API allowing manipulation of the .class files./n" + "See more information: http://tools.android.com/tech-docs/new-build-system/transform-api"); 
    }

代码中返回的值就是这个方案中与 dx相对应的值。不过从异常信息中可以看到的是在 gradle plugin 1.4.0 的版本开始,此方法就已被废弃,而改为采用 transform 的实现

所以此方案只针对 gradle plugin 1.4.0 之前的版本

5.3 问题3:ClassNotFoundException

GitHub-https://github.com/casidiablo/multidex/issues/7

5.4 问题4: 首次启动失败ANR 和黑屏

不仅仅是2.3 的机型,还有一些中档配置的4.x系统的机型,第一次安装后,点击图标,1s,2s,3s… 5s后,程序没有任何反应就好像你没点图标一样,再然后程序ANR

5.4.1 背景知识 冷启动生命周期

dexopt一般仅需要触发一次,生成odex后存放在系统文件路径下;非首次启动则直接从cache中读取已经执行过dexopt的ODEX文件,这个过程对启动并无太大影响

  • 安装完app点击图标之后,触发绝对意义上第一次冷启动
    • 加载main odex,启动main dex中的mainActivity**(安装时已触发一次dexopt)(ART提前完成该步骤,不会阻塞)**
    • App 的laucherActivity准备启动 ,触发Application启动
    • Application的 onattach()方法调用
    • 这时候MultiDex.install()被调用,classes2.dex 被install,再次触发dexopt(ART提前完成该步骤,不会阻塞)
    • Applicaition onCreate()执行
    • launcher Activity启动

以上操作必须在5s内完成,否则导致UI线程阻塞,最终就导致ANR。一般情况下,往往是由于==第二个dex太大了导致MultiDex.install(Context context)的dexopt过程耗时过长==,可以简单将MultiDex.install理解为 “dexopt” + “加载odex”两个过程

5.4.2 解决方案的初步思考

我们来思考下解决方案:

主dex是无论如何都绕不过加载和dexopt的——如果主dex比较小的话可以节省时间—–但是主dex小就意味着后面的dex大,MultiDex.install是在主线程里做的,总时间又没有实质性改变—-不行

install能不能放到线程里做:开新线程加载,而主线程继续Application初始化—-如果异步化,multidex安装没有结束意味着dex还没加载进来,这时候如果进程需要seconday.dex里的classes信息不就悲剧了—-某些类强行使用就会报NoClassDefFoundError

还是看看大厂的吧:

  • 美团
    • 真的搞了个线程,但是主进程同步等待
  • 微信手Q
    • 将首次加载放于地球中,并用线程去加载(但是5.0之前加载dex时还是会挂起主线程)
    • 别问我,我也看不懂官方文档里的 地球是个啥玩意
  • FB && 进化版
    1. 在Application.attachBaseContext(Context base)中,判断是否初次启动,以及系统版本是否小于5.0,如果是,跳到2;否则,直接执行MultiDex.install(Context context)。
    2. 开启一个新进程,在这个进程中执行MultiDex.install(Context context)。执行完毕,唤醒主进程,自身结束。主进程在开启新进程后,自身是挂起的,直到被唤醒。
    3. 唤醒的主进程继续执行初始化操作

5.4.3 方案1:美团多dex拆包方案

思路

精简主dex+异步加载secondary.dex 。对异步化执行速度的不确定性,他们的解决方案是重写Instrumentation execStartActivity 方法,hook跳转Activity的总入口做判断,如果当前secondary.dex 还没有加载完成,就弹一个loading Activity等待加载完成,如果已经加载完成那最好不过了

在21版本之前的Dalvik的VM版本中,MultiDex的安装大概分为几步:

  1. 第一步打开apk这个zip包;
  2. 第二步把MultiDex的dex解压出来(除去Classes.dex之外的其他DEX,例如:classes2.dex, classes3.dex等等),因为android系统在启动app时只加载了第一个Classes.dex,其他的DEX需要我们人工进行安装;
  3. 第三步通过反射进行安装;

这三步其实都比较耗时,由此我们可以有这样的思路:
- 对于ANR问题
- 为了解决ANR问题考虑是否可以把DEX的加载放到一个异步线程中,这样冷启动速度能提高不少,同时能够减少冷启动过程中的ANR
- 对于Dalvik linearAlloc的一个缺陷(Issue 22586)和限制(Issue 78035)
- 考虑是否可以人工对DEX的拆分进行干预,使每个DEX的大小在一定的合理范围内,这样就减少触发Dalvik linearAlloc的缺陷和限制

也就是说,

为了实现这2个目的,我们需要解决下面三个问题:

  1. 在打包过程中如何产生多个的DEX包?
  2. 如果做到动态加载,怎么决定哪些DEX动态加载呢?
  3. 如果启动后在工作线程中做动态加载,如果没有加载完而用户进行页面操作需要使用到动态加载DEX中的class怎么办?

实现过程

【1】我们首先来分析如何解决第一个问题,在使用MultiDex方案时,我们知道BuildTool会自动把代码进行拆成多个DEX包,并且可以通过配置文件来控制哪些代码放到第一个DEX包中.

为了实现产生多个DEX包,我们可以在生成DEX文件的这一步中, 在Ant或gradle中自定义一个Task来干预DEX产生的过程,从而产生多个DEX,下图是在ant和gradle中干预产生DEX的自定task的截图:

tasks.whenTaskAdded { task ->
    if (task.name.startsWith('proguard') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
        task.doLast {
            makeDexFileAfterProguardJar();
        }
        task.doFirst {
            delete "${project.buildDir}/intermediates/classes-proguard";

            String flavor = task.name.substring('proguard'.length(), task.name.lastIndexOf(task.name.endsWith('Debug') ? "Debug" : "Release"));
            generateMainIndexKeepList(flavor.toLowerCase());
        }
    } else if (task.name.startsWith('zipalign') && (task.name.endsWith('Debug') || task.name.endsWith('Release'))) {
        task.doFirst {
            ensureMultiDexInApk();
        }
    }
}

【2】上一步解决了如何打包出多个DEX的问题了,那我们该怎么该根据什么来决定哪些class放到Main DEX,哪些放到Secondary DEX呢(这里的Main DEX是指在2.1版本的Dalvik VM之前由android系统在启动apk时自己主动加载的Classes.dex,而Secondary DEX是指需要我们自己安装进去的DEX,例如:Classes2.dex, Classes3.dex等)

这个需要分析出放到Main DEX中的class依赖,需要确保把Main DEX中class所有的依赖都要放进来,否则在启动时会发生ClassNotFoundException, 这里美团的方案是

  1. 把Service、Receiver、Provider涉及到的代码都放到Main DEX中
  2. 把Activity涉及到的代码进行了一定的拆分:
    1. 把首页Activity、Laucher Activity、欢迎页的Activity、城市列表页Activity等所依赖的class放到了Main DEX中
    2. 把二级、三级页面的Activity以及业务频道的代码放到了Secondary DEX中

为了减少人工分析class的依赖所带了的不可维护性和高风险性,美团编写了一个能够自动分析Class依赖的脚本, 从而能够保证Main DEX包含class以及他们所依赖的所有class都在其内,这样这个脚本就会在打包之前自动分析出启动到Main DEX所涉及的所有代码,保证Main DEX运行正常

【3】如果我们在后台加载Secondary DEX过程中,用户点击界面将要跳转到使用了在Secondary DEX中class的界面, 那此时必然发生ClassNotFoundException, 那怎么解决这个问题呢,在所有的Activity跳转代码处添加判断Secondary DEX是否加载完成?这个方法可行,但工作量非常大; 那有没有更好的解决方案呢?

我们通过分析Activity的启动过程,发现Activity是由ActivityThread 通过Instrumentation来启动的,我们是否可以在Instrumentation中做一定的手脚呢?

通过分析代码ActivityThread和Instrumentation发现,Instrumentation有关Activity启动相关的方法大概有:execStartActivity、newActivity等等,这样我们就可以在这些方法中添加代码逻辑进行判断这个Class是否加载了:

  1. 如果加载则直接启动这个Activity
  2. 如果没有加载完成则启动一个等待的Activity显示给用户,然后在这个Activity中等待后台Secondary DEX加载完成,完成后自动跳转到用户实际要跳转的Activity;

这样在代码充分解耦合,以及每个业务代码能够做到颗粒化的前提下,我们就做到Secondary DEX的按需加载了, 下面是Instrumentation添加的部分关键代码:

  public ActivityResult execStartActivity(Context who, IBinder contextThread, IBinder token, Activity target,
                                            Intent intent, int requestCode) {
        ActivityResult activityResult = null;
        String className;
        if (intent.getComponent() != null) {
            className = intent.getComponent().getClassName();
        } else {
            ResolveInfo resolveActivity = who.getPackageManager().resolveActivity(intent, 0);

            if (resolveActivity != null && resolveActivity.activityInfo != null) {
                className = resolveActivity.activityInfo.name;
            } else {
                className = null;
            }
        }

        if (!TextUtils.isEmpty(className)) {
            boolean shouldInterrupted = !MeituanApplication.isDexAvailable();
            if (MeituanApplication.sIsDexAvailable.get() || mByPassActivityClassNameList.contains(className)) {
                shouldInterrupted = false;
            }
            if (shouldInterrupted) {
                Intent interruptedIntent = new Intent(mContext, WaitingActivity.class);

                activityResult = execStartActivity(who, contextThread, token, target, interruptedIntent, requestCode);
            } else {
                activityResult = execStartActivity(who, contextThread, token, target, intent, requestCode);
            }
        } else {
            activityResult = execStartActivity(who, contextThread, token, target, intent, requestCode);
        }

        return activityResult;
    }

    public Activity newActivity(Class<?> clazz, Context context, IBinder token,
                                Application application, Intent intent, ActivityInfo info,
                                CharSequence title, Activity parent, String id, Object lastNonConfigurationInstance)
            throws InstantiationException, IllegalAccessException {

        String className = "";
        Activity newActivity = null;
        if (intent.getComponent() != null) {
            className = intent.getComponent().getClassName();
        }

        boolean shouldInterrupted = !MeituanApplication.isDexAvailable();
        if (MeituanApplication.sIsDexAvailable.get() || mByPassActivityClassNameList.contains(className)) {
            shouldInterrupted = false;
        }
        if (shouldInterrupted) {
            intent = new Intent(mContext, WaitingActivity.class);
            newActivity = mBase.newActivity(clazz, context, token,
                    application, intent, info, title, parent, id,
                    lastNonConfigurationInstance);
        } else {
            newActivity = mBase.newActivity(clazz, context, token,
                    application, intent, info, title, parent, id,
                    lastNonConfigurationInstance);
        }
        return newActivity;
    }

存在的问题

  1. 分析主dex需要的classes这个脚本比较难写。

    • Google文档说过这个问题比较复杂, 而且buildTools 不是已经帮我们搞定了,看文档怎么说
      1. dex的时候,先依据manifest里注册的组件生成一个 main-list,然后把这list里的classes所依赖的classes找出来,把他们打成classes.dex就是主dex
      2. 剩下的classes都放clsses2.dex(如果使用参数限制dex大小的话可能会有classe3.ex 等等)
      3. 主dex至少含有main-list 的classes + 直接依赖classes ,使用mini-main-list参数可以仅仅包含刚才说的classes
    • 写分析脚本的思路是:
      1. 直接使用mini-main-list参数获取build目录下的main-list文件,这样manifest声明的类和他们的直接依赖类搞定的了
      2. 后者的直接依赖类,以个思路是解析class文件获得该class的依赖类。还一个思路是自己使用Dexclassloader 加载dex,然后hook getClass()方法,调用一次就记录一个。
      3. 都挺折腾的
  2. 由于历史原因,项目维护的App的manifest注册的组件的那些类,承载业务太多,依赖很多三方jar,导致直接依赖类非常多,而且短时间内无法梳理精简,没办法mini化主dex

  3. Application的启动入口太多。Appication初始化未必是由launcher Activity的启动触发,还有可能是因为Service ,Receiver ,ContentProvider 的启动。 靠拦截重写Instrumentation execStartActivity 解决不了问题。要为 Service ,Receiver ,ContentProvider 分别写基类,然后在oncreate()里判断是否要异步加载secondary.dex。如果需要,弹出Loading Acitvity?用户看到这个会感觉比较怪异

结合自身App的实际情况来看美团的拆包方案虽然很美好然但是不能照搬啊

5.4.4 方案2:微信手Q多dex分包方案

对于微信来说,一共有111052个方法。以线性内存3355444(限制5m,给系统预留部分)、方法数64K为限制,即当满足任意一个条件时,将拆分dex。由此微信将得到一个主dex,两个子dex,若微信采用Android原生的MultiDex方案,在首次启动时将长期无响应(没有出现黑屏时因为默认皮肤的原因)

微信与手Q的方案是类似的,将首次加载放于地球中,并用线程去加载(但是5.0之前加载dex时还是会挂起主线程)

思路

  • Android拆分与加载Dex的多种方案对比

  • Dex形式

    • 暂时微信还是放于assets下,以assets/secondary-program-dex-jars/secondary-N.dex.jar命名。
    • 为什么不以classes(..N).dex?
    • 这是因为一来觉得以Android的推广速度,5.0用户增长应该是遥遥无期的
    • 二来加载Dex的代码,传进去的是zip,在加载前需要验证MD5,确保所加载的Dex没有被篡改(Android官方没有验证,主要是只有root才能更改吧)
  • Dex类分包的规则

    • 分包规则即将所有Application、ContentProvider以及所有export的Activity、Service、Receiver的间接依赖集都必须放在主dex。
    • 对于微信现在来说,这部分大约有41306个方法,每次通过扫描AndroidMifest计算耗时大约为20s不到。
    • 怎么计算?可以参考buck或者mainDexClasses的做法。
public MainDexListBuilder(String rootJar, String pathString) throws IOException {   
       path = new Path(pathString);
       ClassReferenceListBuilder mainListBuilder=new ClassReferenceListBuilder(path);

}       
  • 加载Dex的方式
    • 加载逻辑这边主要判断是否已经dexopt,若已经dexopt,即放在attachBaseContext加载,反之放于地球中用线程加载。
    • 只需简单判断两个目录dex名称、数量是否与配置文件的一致
(name md5 校验是否加载成功类)
secondary-1.dex.jar 63e5240eac9bdb5101fc35bd40a98679 secondary.dex01.Canary
secondary-2.dex.jar e7d2a4a181f579784a4286193feaf457 secondary.dex02.Canary 

存在的问题

总的来说,这种方案用户体验较好,缺点在于太过复杂,每次都需重新扫描依赖集,而且使用的是比较大的间接依赖集(要真正运行到,直接依赖集是不行的)。当前微信必要的依赖集已经41306个方法,说不定哪一天就爆了

5.4.5 方案3:FaceBook多dex分包方案

安装完成之后第一次启动时,是secondary.dex的dexopt花费了更多的时间,认识到这点非常重要,使得问题转化为:在不阻塞UI线程的前提下,完成dexopt,以后都不需要再次dexopt,所以可以在UI线程install dex了

我们现在想做到的是:既希望在Application的attachContext()方法里同步加载secondary.dex,又不希望卡住UI线程

FB的方案就是:

  1. 让Launcher Activity在另外一个进程启动,但是Multidex.install还是在Main Process中开启,虽然逻辑上已经不承担dexopt的任务
  2. 这个Launcher Activity就是用来异步触发dexopt的 ,load完成就启动Main Activity;如果已经loaded,则直接启动Main Process
  3. Multidex.install所引发的合并耗时操作,是在前台进程的异步任务中执行的,所以没有anr的风险

思路

Facebook方案在于多起一个nodex进程作为 fake Main Process,在node进程中先显示一个简单页面并判断是否已经加载过dex形成odex,如果是则正常启动real Main界面并唤醒Real Main Process,如果不是则需要在Activity中开启一个异步任务开始加载合并Dex,加载完成后显式启动real Main界面并唤醒Real Main Process

  1. facebook将加载Dex的逻辑放于单独的nodex进程,这是一个非常简单、轻量级的进程。它没有任何的ContentProvider,只有有限的几个Activity、Service。这就实现了一个非常轻量级的依赖集方案
    • 主要为Splash相关的
    • 所以依赖集为Application、NodexSplashActivity的间接依赖集即可,而且这部分逻辑应该相对稳定,我们无须做动态扫描
<activity android:exported="false"  android:process=":nodex"
 android:name="com.facebook.nodex.startup.splashscreen.NodexSplashActivity">
  1. 加载dex逻辑也非常简单,由于NodexSplashActivity的intent-filter指定为Main与LAUNCHER,所以首先拉起nodex进程,然后初始化NodexSplashActivity,若此时Dex已经初始化过,即直接跳转到主页面;未初始化则异步初始化,完成后跳转。
    • 判断是否dexopt完成:加载进程创建的同时,创建一个临时文件,结束加载退出前,删除这个文件。主进程轮询访问文件的存在性即可

这里写图片描述
【图2】

存在的问题

这种方式好处在于依赖集非常简单,同时首次加载Dex时也不会卡死。

但是它的缺点也很明显,即每次启动主进程时,都需先启动nodex进程。尽管nodex进程逻辑非常简单,这也需100ms以上。

若对启动时间非常敏感,很难会去直接采用这个方案

5.4.6 方案4:FaceBook多dex分包方案进化版

Facebook的缺陷在于每次都要多起一个nodex进程,无论怎么规避,这个进程启动的耗时都无法避免,因此进一步的优化方案,只能是在主进程做到同样的效果

思路

  1. 主Dex应该保证简单,即类似Facebook,只需要少量与Dex加载相关的类即可,并且这部分代码是相对稳定,也无须去更改任何非加载相关的代。即不会像微信/手Q方案,我们需要修改BaseExportActivity、BaseExportServer、BaseExportBroadcast等代码

这里写图片描述 这里写图片描述
【图3】【图4】

  1. 首先若是点击图标,我们的确无须再起一个进程,是可行。
    • 但是问题就在于在Application初始化时,或是在attachBaseContext时,我们无法确保即将进入的是主界面Activity。可能系统要起的是某一个Service或Receiver,这种跳转方式是不行的。例如上图中的红色部分,我们无法知道将跳转到哪里
    • 换种思路:
      1. 假设发现Dex没有初始化,在attachBaseContext的时候后阻塞主进程,然后起另外一个loaddex进程作为前台进程。
      2. 在显示的Activity中异步加载 Multidex.install;同时,主进程监听是否加载完成;
      3. 显示的Activity加载完成后自我销毁;
      4. 主进程轮训到loaddex加载完成,则继续往下走

这里写图片描述
【图5】

新的思路虽然也会唤起新进程,但是该进程只会触发一次,可以接受。现在转化为两个问题:

  1. 通过何种方式挂起主进程?

    • 进程同步可以使用pthread_mutex_xxx、 pthread_cond_xxx,但是mutex或cond要放于共享内存中,过于复杂。
    • 或者由于在主进程访问远端Service,也是同步的,这应该也是一种不错的方法。
    • 但是我最后测试时采用的是一个最简单的方法,即检测到没有加载dex时,会在com.tencent.mm下新建一个临时文件,每隔100ms去询问文件是否存在。而在loaddex结束后,即主动的删除该文件
  2. 挂住主进程过程中,是否会产生ANR?

    • 事实上是不会的,因为我们拉起Loaddex进城后,主进程已经不是前台进程了,经过测试在attachBaseContext,无论将要启动的Activity、Broadcast还是Service,尽管卡住100s,也不会出现ANR(回想ANR的几个原因,按键消息、Broadcast onReceiver或者Service)

这里写图片描述
【图6】

实现

这里写图片描述
【图7】


import android.app.ActivityManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.support.multidex.MultiDex;

import com.xx.xx.common.BaseFunctionConfig;
import com.xx.xx.log.LogCore;
import com.xx.xx.login.activity.LoadResActivity;
import com.xx.xx.utils.StringUtils;

import java.util.Map;
import java.util.jar.Attributes;
import java.util.jar.JarFile;

/**
 * 类描述:
 * <p>
 * Created by yhf on 2018/1/19.
 */
public class BaseMultiDexApplication extends BaseMoaApplication {

    public static final String TAG = "BaseMultiDexApplication";

    public static final String KEY_DEX2_SHA1 = "dex2-SHA1-Digest";

    @Override
    protected void attachBaseContext(Context base) {
        super .attachBaseContext(base);
        LogCore.i( TAG, "App attachBaseContext ");

        //是<5.0的系统 && 是主进程(不是loaddex进程)  则进入异步加载方案
        if (!isLoadDexProcess() && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {

            if (needWait(base)){//判断dexopt未执行过
                waitForDexopt(base);
            }

            //主进程加载dex,
            //此时odex已经产生(dexopt操作已经在 loaddexActivity中执行过了,或者不是第一次打开应用),所以不会有耗时问题
            MultiDex.install (this );
        } else {
            //>=5.0的系统默认对dex进行oat优化,不需要MultiDex.install (this );
            return;
        }
    }

    @Override
    public void onCreate() {
        super .onCreate();
        if (isLoadDexProcess()) {
            return;
        }
    }

    /*****************************判断是否是 loaddex进程********************************/

    public boolean isLoadDexProcess() {
        if (StringUtils.containsIgnoreCase( getCurProcessName(this), ":mini")) {
            LogCore.i( TAG, ":mini start!");
            return true;
        }
        return false ;
    }

    /*****************************判断dexopt是否已经执行过********************************/
    /**
     * 判断dexopt是否已经执行过
     * 通过校验本地存储的md5记录和apk里的classes2.dex是否一致
     * neead wait for dexopt ?
     */
    private boolean needWait(Context context){
        String flag = get2thDexSHA1(context);
        LogCore.i( TAG, "dex2-sha1 "+flag);
        SharedPreferences sp = context.getSharedPreferences(
                getPackageInfo(context).versionName, MODE_MULTI_PROCESS);
        String saveValue = sp.getString(KEY_DEX2_SHA1, "");
        return !StringUtils.equals(flag,saveValue);
    }

    //Get classes.dex file signature
    private String get2thDexSHA1(Context context) {
        ApplicationInfo ai = context.getApplicationInfo();
        String source = ai.sourceDir;
        try {
            JarFile jar = new JarFile(source);
            java.util.jar.Manifest mf = jar.getManifest();
            Map<String, Attributes> map = mf.getEntries();
            Attributes a = map.get("classes2.dex");
            return a.getValue("SHA1-Digest");
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null ;
    }


    /*****************************阻塞等待********************************/
    /**
     * 1. 启动 异步dexopt的跨进程Activity
     * 2. 阻塞当前主进程
     * 3. 200ms间隔轮训dexopt是否完成,超时或者已完成,则唤醒主进程
     *    3.1 在attachContext中的MultixDex.install,如果超时,则主进程自己再次同步执行dexopt,相当于恢复到官方默认方案;
     *    3.2 在attachContext中的MultixDex.install,如果已完成,则主进程不再执行的dexopt,单纯加载odex,提升速度
     */
    public void waitForDexopt(Context base) {
        Intent intent = new Intent();
        ComponentName componentName = new
                ComponentName(BaseFunctionConfig.PACKAGE_NAME, LoadResActivity.class.getName());
        intent.setComponent(componentName);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        base.startActivity(intent);
        long startWait = System.currentTimeMillis ();
        long waitTime = 10 * 1000 ;
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB_MR1 ) {
            waitTime = 20 * 1000 ;//实测发现某些场景下有些2.3版本有可能10s都不能完成optdex
        }
        while (needWait(base)) {
            //application启动了LoadDexActivity之后,自身不再是前台进程所以怎么hold 线程都不会ANR
            try {
                long nowWait = System.currentTimeMillis() - startWait;
                LogCore.i( TAG, "wait ms :" + nowWait);
                if (nowWait >= waitTime) {
                    return;
                }
                Thread.sleep(200 );
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    /*****************************utils********************************/
    //LoadResActivity 中被调用
    public void installFinish(Context context){
        SharedPreferences sp = context.getSharedPreferences(
                getPackageInfo(context).versionName, MODE_MULTI_PROCESS);
        sp.edit().putString(KEY_DEX2_SHA1,get2thDexSHA1(context)).commit();
    }

    public static PackageInfo getPackageInfo(Context context){
        PackageManager pm = context.getPackageManager();
        try {
            return pm.getPackageInfo(context.getPackageName(), 0);
        } catch (PackageManager.NameNotFoundException e) {
            LogCore.i(TAG, e.getLocalizedMessage());
        }
        return  new PackageInfo();
    }

    public static String getCurProcessName(Context context) {
        try {
            int pid = android.os.Process.myPid();
            ActivityManager mActivityManager = (ActivityManager) context
                    .getSystemService(Context. ACTIVITY_SERVICE);
            for (ActivityManager.RunningAppProcessInfo appProcess : mActivityManager
                    .getRunningAppProcesses()) {
                if (appProcess.pid == pid) {
                    return appProcess. processName;
                }
            }
        } catch (Exception e) {
            // ignore
        }
        return null ;
    }
}

这里使用了原生MultiDex方案的classes(N).dex的方式保存了后面的dex而不是像微信目前的做法放到assest文件夹。前面有说到ART模式会将多个dex优化合并成oat文件。如果放置在asset里面就没有这个好处了

  • Launcher Activity 依然是原来的代码里的WelcomeActivity
  • 在Application启动的时候会检测dexopt是否已经完成过,(检测方式是查看sp文件是否有dex文件的SHA1-Digest记录,这里要两个进程读取该sp,读取模式是MODE_MULTI_PROCESS)
    • 如果没有就启动LoadDexActivity(属于:mini进程) 。
    • 否则就直接install dex !通过日志发现,已经dexopt的dex文件再次install的时候 只耗费几十毫秒
  • LoadDexActivity 的逻辑比较简单,启动AsyncTask 来install dex 这时候会触发dexopt
public class LoadResActivity extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        super .onCreate(savedInstanceState);
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN , WindowManager.LayoutParams.FLAG_FULLSCREEN );
        overridePendingTransition(R.anim.null_anim, R.anim.null_anim);
        setContentView(R.layout.activity_verify);      
        new LoadDexTask().execute();
    }
    class LoadDexTask extends AsyncTask {
        @Override
        protected Object doInBackground(Object[] params) {
            try {
                MultiDex.install(getApplication());
                LogUtils.d("loadDex" , "install finish" );
                ((App) getApplication()).installFinish(getApplication());
            } catch (Exception e) {
                LogUtils.e("loadDex" , e.getLocalizedMessage());
            }
            return null;
        }
        @Override
        protected void onPostExecute(Object o) {
            LogUtils.d( "loadDex", "get install finish");
            finish();
            System.exit( 0);
        }
    }
    @Override
    public void onBackPressed() {
        //cannot backpress
    }
  • Manifest.xml 里面
<activity
    android:name= "com.zongwu.LoadResActivity"
    android:launchMode= "singleTask"
    android:process= ":mini"
    android:alwaysRetainTaskState= "false"
    android:excludeFromRecents= "true"
    android:screenOrientation= "portrait" />

<activity
    android:name= "com.zongwu.WelcomeActivity"
    android:launchMode= "singleTop"
    android:screenOrientation= "portrait">
    <intent-filter >
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter >
</activity>
  • 替换Activity默认的出现动画 R.anim.null_anim 文件的定义
-<set xmlns:android="http://schemas.android.com/apk/res/android">
    <alpha
        android:fromAlpha="1.0"
        android:toAlpha="1.0"
        android:duration="550"/>
</set>
  • Application具有继承结构的注意,在子类中判断
  if (isLoadDexProcess()) {
            return;
        }
  • 因为有大量代码在MultiDex.install之前执行,因此必须把涉及到的class以显示声明的方式声明放到主dex中
//app/build.gradle
android {

    //...

    defaultConfig {
        //...

        //定义main dex中必须保留的类
        multiDexKeepProguard file('mainDexClasses.pro')
    }
}

//app/mainDexClasses.pro
-keep class android.content.Intent { *; }
-keep interface android.content.SharedPreferences { *; }
//...自己看引用

存在的问题

这种方式好处在于依赖集非常简单,同时它的集成方式也是非常简单,我们无须去修改与加载无关的代码

  1. 对现有代码改动量最小。
  2. 该方案不关注Application被哪个组件启动。Activity ,Service ,Receiver ,ContentProvider 都满足。(有个问题要说明:如细心网友指出的那样,新安装还未启动但是收到Receiver的场景下,会导致Load界面出现。这个场景实际出现几率比较少,且仅出现一次。可以接受。)
  3. 该方案不限制 Application ,Activity ,Service ,Receiver ,ContentProvider 继续新增业务。

但是:使用“使用MODE_MULTI_PROCESS标记使得SharedPreference得以进程间共享,主进程轮询sp文件”来判断是否dexopt完成的手段,在6.0上因为google废除了该标记导致行为准确性的难以保证

5.4.7 方案5:FaceBook多dex分包方案二次进化版

思路

使用“使用MODE_MULTI_PROCESS标记使得SharedPreference得以进程间共享,主进程轮询sp文件”来判断是否dexopt完成的手段,在6.0上因为google废除了该标记导致行为准确性的难以保证

针对该环节进行优化,通过 跨进程通讯的Messenger来实现

实现

  • BaseApplication
public abstract class BaseApplication extends Application {

    @SuppressWarnings("MismatchedReadAndWriteOfArray")
    private static final byte[] lock = new byte[0];

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        //是<5.0的系统 && 是主进程(不是loaddex进程)  则进入异步加载方案
        if (!isLoadDexProcess() && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            //判断dexopt未执行过
            if (needWait(base)) {

                DexInstallDeamonThread thread = new DexInstallDeamonThread(this, base);
                thread.start();

                //阻塞等待:async_launch完成加载
                synchronized (lock) {
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }

                thread.exit();
                Log.d("BaseApplication", "dexopt finished. alloc MultiDex.install()");
            } else {
                //主进程加载dex,
                //此时odex已经产生(dexopt操作已经在 loaddexActivity中执行过了,或者不是第一次打开应用),所以不会有耗时问题
                MultiDex.install(this);
            }
        }else{
            //>=5.0的系统默认对dex进行oat优化,不需要MultiDex.install (this );
            return;
        }
    }
    /*****************************判断是否是 loaddex进程********************************/
    public boolean isLoadDexProcess() {
        String processName = getCurProcessName(this);
        return processName != null && processName.contains(":mini");
    }
    /****************************判断dexopt是否已经执行过********************************/
    /**
     * 另一种手段判断是否dexopt,将其换转为 判断是否是首次启动
     * 这个标记应当随着版本升级而重置
     * @param context
     * @return
     */
    public final static String IS_FIRST_LAUNCH = "";
    @SuppressWarnings("deprecation")
    private boolean needWait(Context context) {
        //这里实现不唯一,读取一个全局的标记,判断是否初次启动APP
        SharedPreferences sp = context.getSharedPreferences(
                getPackageInfo(context).versionName, MODE_MULTI_PROCESS);
        return sp.getBoolean(IS_FIRST_LAUNCH, true);
    }

    /*****************************阻塞等待********************************/
    /**
     * 基于Messenger的跨进程通讯方式
     * 1. 启动 异步dexopt 的跨进程Activity
     * 2. 阻塞当前主进程
     * 3. 锁机制等待dexopt是否完成
     */
    private static class DexInstallDeamonThread extends Thread {

        private Handler handler;

        private Context application;

        private Context base;

        private Looper looper;

        public DexInstallDeamonThread(Context application, Context base) {
            this.application = application;
            this.base = base;
        }

        @SuppressLint("HandlerLeak")
        @Override
        public void run() {
            Looper.prepare();
            looper = Looper.myLooper();
            handler = new Handler() {

                @SuppressWarnings("deprecation")
                @Override
                public void handleMessage(Message msg) {
                    synchronized (lock) {
                        lock.notify();
                    }
                    SPUtils
                            .getVersionSharedPreferences(application)
                            .edit()
                            .putBoolean(IS_FIRST_LAUNCH, false)
                            .apply();
                }
            };

            Messenger messenger = new Messenger(handler);
            Intent intent = new Intent(base, LoadResActivity.class);
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            intent.putExtra("MESSENGER", messenger);
            base.startActivity(intent);
            Looper.loop();
        }

        public void exit() {
            if (looper != null) looper.quit();
        }
    }
    /*****************************utils********************************/
    public static String getCurProcessName(Context context) {
        try {
            int pid = android.os.Process.myPid();
            ActivityManager mActivityManager = (ActivityManager) context
                    .getSystemService(Context. ACTIVITY_SERVICE);
            for (ActivityManager.RunningAppProcessInfo appProcess : mActivityManager
                    .getRunningAppProcesses()) {
                if (appProcess.pid == pid) {
                    return appProcess. processName;
                }
            }
        } catch (Exception e) {
            // ignore
        }
        return null ;
    }

    public static PackageInfo getPackageInfo(Context context){
        PackageManager pm = context.getPackageManager();
        try {
            return pm.getPackageInfo(context.getPackageName(), 0);
        } catch (PackageManager.NameNotFoundException e) {
            Log.i("getPackaigeInfo", e.getLocalizedMessage());
        }
        return  new PackageInfo();
    }
}
  • LoadResActivity
public class LoadResActivity extends AppCompatActivity {

    private Messenger messenger;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        super.onCreate(savedInstanceState);
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
        overridePendingTransition(R.anim.null_anim, R.anim.null_anim);
        setContentView(R.layout.activity_load_res);

        Log.d("LoadResActivity", "start install");
        Intent from = getIntent();
        messenger = from.getParcelableExtra("MESSENGER");

        LoadDexTask dexTask = new LoadDexTask();
        dexTask.execute();
    }

    class LoadDexTask extends AsyncTask<Void, Void, Void> {

        @Override
        protected Void doInBackground(Void... params) {
            try {
                MultiDex.install(getApplication());
                Log.d("LoadResActivity", "finish install");
                messenger.send(new Message());
            } catch (Exception e) {
                e.printStackTrace();
            }
            return null;
        }

        @Override
        protected void onPostExecute(Void o) {
            finish();
            System.exit(0);
        }
    }

    @Override
    public void onBackPressed() {
        //无法退出
    }
}
  • app/AndroidManifest.xml加入:
<activity
    android:name="com.synaric.dex.LoadResActivity"
    android:launchMode= "singleTask"
    android:alwaysRetainTaskState= "false"
    android:excludeFromRecents= "true"
    android:screenOrientation= "portrait"
    android:process=":async_launch"/>
  • 因为有大量代码在MultiDex.install之前执行,因此必须把涉及到的class以显示声明的方式声明放到主dex中
//app/build.gradle
android {

    //...

    defaultConfig {
        //...

        //定义main dex中必须保留的类
        multiDexKeepProguard file('mainDexClasses.pro')
    }
}

//app/mainDexClasses.pro
-keep public class * extends java.lang.Thread { *; }
-keep interface android.content.SharedPreferences { *; }
-keep class android.os.Handler { *; }
-keep class com.synaric.common.BaseSPKey { *; }
-keep class android.os.Messenger { *; }
-keep class android.content.Intent { *; }
//...自己看引用

5.5 问题5:app启动性能问题

如果你使用multidex,你需要意识到它对app启动性能有影响。我们通过跟踪app的启动时间发现了这个问题-用户点击app图标到所有图片都下载完并显示给用户的这段时间(假设说你的首屏需要下载图片并显示)。一旦multidex 启用,在所有运行Kitkat (4.4) 及以下的设备上我们的app启动时间就会大约增加15%。更多信息参考 Carlos Sessa的Lazy Loading Dex files 。

这是因为Android 5.0 以及更高版本使用了一个叫做ART的运行时,它天生就支持从应用的apk文件中加载multiple dex文件

5.5.1 背景

之所以会出现启动耗时,不可否认是由于MultiDex.install引发的dexopt过程导致,但是dexopt的过程中,界面也会做一些处理,因此如果启动界面所涉及到的所有Class如果被放置到Main dex可以在一定程度上加快Ui显示过程。

现在的问题是,我们如何才能知道在app启动期间什么样的calss被加载了呢?

幸运的是,在 ClassLoader中我们有 findLoadedClass 方法。我们的办法就是在app启动结束的时候做一次运行时检查。如果第二个dex 文件中存有任何在app启动期间加载的class,那么就通过添加calss name 到multidex.keep文件中的方式来把它们移到main dex文件中

5.5.2 解决方案1

处理方式与《问题2:运行失败 NoClassDefFoundError》类似,在这里给出另外一种获得List的方法

  • 在你认为app启动结束的地方运行下面util类中的getLoadedExternalDexClasses 把上面这个方法返回的列表添加到你的 multidex.keep 文件然后重新编译

  • 把上面这个方法返回的列表添加到你的 multidex.keep 文件然后重新编译。

public class MultiDexUtils {
    private static final String EXTRACTED_NAME_EXT = ".classes";
    private static final String EXTRACTED_SUFFIX = ".zip";

    private static final String SECONDARY_FOLDER_NAME = "code_cache" + File.separator +
            "secondary-dexes";

    private static final String PREFS_FILE = "multidex.version";
    private static final String KEY_DEX_NUMBER = "dex.number";

    private SharedPreferences getMultiDexPreferences(Context context) {
        return context.getSharedPreferences(PREFS_FILE,
                Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB
                        ? Context.MODE_PRIVATE
                        : Context.MODE_PRIVATE | Context.MODE_MULTI_PROCESS);
    }

    /**
     * get all the dex path
     *
     * @param context the application context
     * @return all the dex path
     * @throws PackageManager.NameNotFoundException
     * @throws IOException
     */
    public List<String> getSourcePaths(Context context) throws PackageManager.NameNotFoundException, IOException {
        final ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
        final File sourceApk = new File(applicationInfo.sourceDir);
        final File dexDir = new File(applicationInfo.dataDir, SECONDARY_FOLDER_NAME);

        final List<String> sourcePaths = new ArrayList<>();
        sourcePaths.add(applicationInfo.sourceDir); //add the default apk path

        //the prefix of extracted file, ie: test.classes
        final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
        //the total dex numbers
        final int totalDexNumber = getMultiDexPreferences(context).getInt(KEY_DEX_NUMBER, 1);

        for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) {
            //for each dex file, ie: test.classes2.zip, test.classes3.zip...
            final String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
            final File extractedFile = new File(dexDir, fileName);
            if (extractedFile.isFile()) {
                sourcePaths.add(extractedFile.getAbsolutePath());
                //we ignore the verify zip part
            } else {
                throw new IOException("Missing extracted secondary dex file '" +
                        extractedFile.getPath() + "'");
            }
        }

        return sourcePaths;
    }

    /**
     * get all the external classes name in "classes2.dex", "classes3.dex" ....
     *
     * @param context the application context
     * @return all the classes name in the external dex
     * @throws PackageManager.NameNotFoundException
     * @throws IOException
     */
    public List<String> getExternalDexClasses(Context context) throws PackageManager.NameNotFoundException, IOException {
        final List<String> paths = getSourcePaths(context);
        if(paths.size() <= 1) {
            // no external dex
            return null;
        }
        // the first element is the main dex, remove it.
        paths.remove(0);
        final List<String> classNames = new ArrayList<>();
        for (String path : paths) {
            try {
                DexFile dexfile = null;
                if (path.endsWith(EXTRACTED_SUFFIX)) {
                    //NOT use new DexFile(path), because it will throw "permission error in /data/dalvik-cache"
                    dexfile = DexFile.loadDex(path, path + ".tmp", 0);
                } else {
                    dexfile = new DexFile(path);
                }
                final Enumeration<String> dexEntries = dexfile.entries();
                while (dexEntries.hasMoreElements()) {
                    classNames.add(dexEntries.nextElement());
                }
            } catch (IOException e) {
                throw new IOException("Error at loading dex file '" +
                        path + "'");
            }
        }
        return classNames;
    }

    /**
     * Get all loaded external classes name in "classes2.dex", "classes3.dex" ....
     * @param context
     * @return get all loaded external classes
     */
    public List<String> getLoadedExternalDexClasses(Context context) {
        try {
            final List<String> externalDexClasses = getExternalDexClasses(context);
            if (externalDexClasses != null && !externalDexClasses.isEmpty()) {
                final ArrayList<String> classList = new ArrayList<>();
                final java.lang.reflect.Method m = ClassLoader.class.getDeclaredMethod("findLoadedClass", new Class[]{String.class});
                m.setAccessible(true);
                final ClassLoader cl = context.getClassLoader();
                for (String clazz : externalDexClasses) {
                    if (m.invoke(cl, clazz) != null) {
                        classList.add(clazz.replaceAll("\\.", "/").replaceAll("$", ".class"));
                    }
                }
                return classList;
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

5.6 问题6:OutOfMemoryError: Java heap space

当运行时如果看到如下错误:

UNEXPECTED TOP-LEVEL ERROR:  
java.lang.OutOfMemoryError: Java heap space  

在dexOptions中有一个字段用来增加java堆内存大小:

android {  
    // ...  
    dexOptions {  
        javaMaxHeapSize "2g"  
    }  
}      

5.7 问题7: DexException: Library dex files are not supported in multi-dex mode

你可能会见到如下的错误:

Error:Execution failed for task ':app:dexDebug'.  
> com.android.ide.common.internal.LoggedErrorException: Failed to run command:  
    $ANDROID_SDK/build-tools/android-4.4W/dx --dex --num-threads=4 --multi-dex  
    ...  
  Error Code:  
    2  
  Output:  
    UNEXPECTED TOP-LEVEL EXCEPTION:  
    com.android.dex.DexException: Library dex files are not supported in multi-dex mode  
        at com.android.dx.command.dexer.Main.runMultiDex(Main.java:322)  
        at com.android.dx.command.dexer.Main.run(Main.java:228)  
        at com.android.dx.command.dexer.Main.main(Main.java:199)  
        at com.android.dx.command.Main.main(Main.java:103)  

对于dex 的–multi-dex 选项设置与预编译的library工程有冲突,因此如果你的应用中包含引用的lirary工程,需要将预编译设置为false:

android {  
    // ...  
    dexOptions {  
        preDexLibraries = false  
    }  
}  

参考文献