Android · 2015年12月2日 0

Dex分包变形记

2015-11-26 李金涛 腾讯Bugly

一、背景

就在项目灰度测试前不久,爆出了在 Android 3.0以下手机上安装时出现 INSTALL _ FAILED_DEXOPT,导致安装失败。这一问题意味着项目将不能在 Android 3.0以下的手机上安装使用,对项目的发布有比较大的影响,所以必须尽快解决。

INSTAL L_FAILED_DEXOPT导致无法安装的问题,从根本上来说,可能是两个原因造成的:

(1) 单个 dex 文件方法总数65K 的限制。

(2) Dexopt 的 LinearAlloc 限制。

当 Android 系统安装一个应用的时候,有一步是对 Dex 进行优化,这个过程有一个专门的工具来处理,叫 DexOpt。DexOpt 是在第一次加载 Dex 文件的时候执行的。这个过程会生成一个 ODEX 文件,即 Optimised Dex。执行 ODEX 的效率会比直接执行 Dex 文件的效率要高很多。

但是在早期的 Android 系统中,DexOpt 有两个问题。(一):DexOpt 会把每一个类的方法 id 检索起来,存在一个链表结构里面,但是这个链表的长度是用一个 short 类型来保存的,导致了方法 id 的数目不能够超过65536个。当一个项目足够大的时候,显然这个方法数的上限是不够的。(二):Dexopt 使用 LinearAlloc 来存储应用的方法信息。Dalvik LinearAlloc 是一个固定大小的缓冲区。在Android 版本的历史上,LinearAlloc 分别经历了4M/5M/8M/16M限制。Android 2.2和2.3的缓冲区只有5MB,Android 4.x提高到了8MB 或16MB。当方法数量过多导致超出缓冲区大小时,也会造成dexopt崩溃。

尽管在新版本的 Android 系统中,DexOpt 修复了方法数65K的限制问题,并且扩大了 LinearAlloc 限制,但是我们仍然需要对低版本的 Android 系统做兼容。

回头说项目。由于项目新版本新增功能点和代码较多,在方法数减无可减的时候,仍然不能解决INSTALL FAILED DEXOPT的问题。所以,最终我们采用了 dex 分包的方案,来避开了 Android 3.0以下平台的方法数和 LinearAlloc 限制。

简单的说,分包就是在打包时将应用的代码分成多个 dex,使得主 dex 的方法数和所需的 LinearAlloc 不超过系统限制。在应用启动或运行过程中,首先是主 dex 启动运行后,再加载从 dex,这样就绕开了这两个限制。

这样,我们的分包方案就要解决两个问题:一是如何对 dex 进行拆分,二是如何加载从 dex。


二、Google 官方方案

1.Dex 拆分

首先,我们需要解决如何对dex进行拆分?

通过学习资料,我们知道,对于方法数超过65K 的问题,Google 官方从 Android Build tools 21.1就开始着手解决了。

先看官方网站提供的配置。Google MultiDex 官方文档是针对 Gradle 进行配置的,如下:

android {
compileSdkVersion 21
buildToolsVersion "21.1.0"

defaultConfig {
...
minSdkVersion 14
targetSdkVersion 21
...

// Enabling multidex support.
multiDexEnabled true
}
...
}

dependencies {
  compile 'com.android.support:multidex:1.0.0'
}

那么,是不是按 Google 官方文档配置一下就 OK 了呢?不管怎样,这是官方提供的方案,而且是最直接的做法,所以我们应该先试一试。

因为我们项目的 RDM 构建环境采用的是 ant 脚本编译,所以首先要想办法把 Google 官方编译配置改造成 ant 脚本。

官方文档上只提供了如何使用 MultiDex,没有说明构建时如何打包出多个 dex。其实是因为如果用了这种 Gradle来构建,当应用构建时,构建工具会自动分析哪些类必须放在第一个 DEX 文件(主 dex),哪些类可以放在附加的 DEX 文件(从 dex)中,并将分析结果输出到 dx 进行后续打包。当它创建了主 dex 文件(classes.dex)后,如果有必要会继续创建从 DEX 文件,如 classes2.dex, classes3.dex。这种方法优点是配置比较简单,但是最大的缺点是不能指定哪些类必须包含在主 dex 中,容易导致应用启动时某些类找不到,出现 Class Not Found Exception。

我们把上述 Gradle 的配置改成 ant 脚本时,就不能简单套用了。通过查看 dx 工具的用法:

参数说明:

—multi-dex:多 dex 打包的开关。

—main-dex-list=:参数是一个类列表的文件,在该文件中的类会被打包在第一个 dex 中。

—minimal-main-dex:只有在—main-dex-list 文件中指定的类被打包在第一个 dex,其余的都在第二个 dex 文件中。

因为后两个参数是 optional 参数,所以理论上只需给 dx 加上“—multi-dex”参数即可生成出 classes.dex、classes2.dex、classes3.dex、…。

在 Gradle 中可以做如下的配置:

afterEvaluate { 

    tasks.matching { 

       it.name.startsWith('dex') 

    }.each { dx -> 

       if (dx.additionalParameters == null) { 

          dx.additionalParameters = ['--multi-dex'] 

       } else { 

          dx.additionalParameters += '--multi-dex' 

       } 

    } 

}

好了,这样我们就可以改造我们的 ant 脚本了。

改造的方法是在项目打包的 ant 脚本中引入 Android build Tools 21.1.2,并把用 dx 生成 dex 的部分改造成下面的样子:

编译、打包,并没有像预期那样生成多个 dex,而是只生成了一个 classes.dex:

生成的 apk 包跟 dex 分包前一样。为什么会这样?

再看 dx 的参数,main-dex-list 和 minimal-main-dex 只会影响到主 dex 中包含的文件,不会影响到从 dex 是否生成,所以应该是其他原因造成的。

查不到资料,分析源代码就是解决问题的不二法门。于是我把 dx.jar 反编译了一下,通过分析,找到了下面的几行关键代码:

显然,dx 进行多 dex 打包时,默认每个 dex 中方法数最大为65536。而查看当前应用 dex 的方法数,一共只有51392(方法数没超标,主要是 LinearAlloc 超标),没有达到65536,所以打包的时候只有一个 dex。

再继续分析代码,发现下面一段关键代码:

这说明 dx 有一个隐藏的参数:—set-max-idx-number,这个参数可以用于设置 dx 打包时每个 dex 最大的方法数,但是此参数并未在 dx 的 Usage 中列出(坑爹啊!)。

我们在 ant 脚本中把这个参数设置上,暂时设置每个 dex 的方法数最大为48000:

重新打包,结果如下:

果然,第二个 dex 出现了!

可是,观察一下 res 目录,这里出现了一个新的问题,drawable 密度后缀的资源目录都多了一个 v4:

为什么这几个目录会带 v4后缀呢?原来这是 R6以上的 Android SDK Tools 自动打包工具新加的一个处理,即为这些在 Android 1.0 时不存在的密度后缀命名的资源路径名称后面自动添加一个适合的版本后缀,以确保老版本不使用这些资源(只有 API level 4以及更高版本支持后缀),v4 就表示使用在 Android 1.6 或更高版本。

上述的 Dex 拆分过程采用的就是 Google 官方的方案。Dex 拆分已经完成,如何加载呢?

2.Dex加载

因为 Android 系统在启动应用时只加载了主 dex(Classes.dex),其他的 dex 需要我们在应用启动后进行动态加载安装。

Google 官方方案是如何加载的呢?

Google 官方支持 Multidex 的 jar 包是 android-support-multidex.jar,该 jar 包从 build tools 21.1 开始支持。这个 jar 加载 apk 中的从 dex 流程如下:

此处主要的工作就是从 apk 中提取出所有的从 dex(classes2.dex,classes3.dex,…),然后通过反射依次安装加载从 dex 并合并 DexPathList 的 Element 数组。

如果引用这个 jar 包,MultiDexApplication 的 Java Doc 提供了三种方式来加载从 dex:

1)在 AndroidManifest.xml 中,把 application 定义为 android.support.multidex.MultiDexApplication。

2)用自定义的 Application 类继承 android.support.multidex.MultiDexApplication,再配置 application 为自定义的类。

3)如果之前自定义的 Application 类已经继承了其他 Application 类,而且不想改变,那么可以重写自定义 Application 类的 attachBaseContext() 或者 onCreate() 方法,并添加语句 MultiDex.install(this)。

为了使改动最小,我们采用上述3)中的调用方式:

到此为止,用 Google 官方方案进行 dex 拆分和加载就已经完成了。安装运行一下试试!

3.安装运行

我们把分包后的 apk 在 Android 4.3的手机上进行安装。没有问题,顺利安装上了!

没想到的是,启动时没出现任何页面,直接 crash。Crash 的 log 如下:

从 log 上看,项目在启动闪屏页面时无法实例化 com.example.AppService.AstApp,因为找不到 com.example.AppService.AstApp 这个类。既然 Application 类都找不到,那么我们在 Application 中加载从 dex 更加没有执行到了。

反编译一下 classes.dex 和 classes2.dex,果然 com.example.AppService.AstApp 是在classes2.dex,所以刚启动时在主 dex(classes.dex) 中找不到 com.example.AppService.AstApp(Application 类)。

理论上,启动必需的代码应该放在主 dex 中,这些代码包括 Application、BaseActivity 等代码以及继承自它们的代码的一个依赖集。但是我们看到,单纯依赖于构建工具自动进行 dex 拆分时,我们无法决定或干预哪些类应该放在主 dex,哪些类应该放在从 dex,这就可能导致启动时往往会有类库找不到。

接下来,我们就得想办法来自主定制主、从 dex 包含的文件,使它们完全可控。

4.Google 官方方案的小结

采用 Google 官方的拆包方案走到现在,我们需要再梳理一下思路了。

到现在为止,已经解决的问题是:

1)能正常打出多个 dex;

2)可以指定每个 dex 的大小;

3)可以加载多个 dex。

尚未解决的问题是:如何指定哪些类应该放到主 dex,哪些类应该放到从 dex?

关于这个问题,从前面 dx 工具的用法中可得知,我们可以在 dx 的参数中加入—main-dex-list,指定哪些类应该放在主 dex 中(也可同时配合使用参数—minimal-main-dex,指定主 dex 中只包含在—main-dex-list 文件中指定的类)。

可是问题又来了,怎么得到 main-dex-list 文件?在大的工程开发中,手动添加文件列表显然不现实。

同时,在前面研究和验证 Google 官方方案的过程中,也有几个不得不提的问题:

1)需要高版本的 build Tools、SDK Tools 编译打包;

2)编译打包 apk 后生成的 drawable 密度后缀目录被添加了 v4 后缀;

3)Google 的 MultiDex 方案在运行中需要比较大的 LinearAlloc,但是由于 Android 4.0 (API level 14) 以下的机器上 Dalvik LinearAlloc 的一个缺陷 (Issue 22586) 和限制 (Issue 78035),可能导致运行时无法满足 LinearAlloc 的需求而造成 DexOpt 失败或者 Dalvik 虚拟机崩溃;

4)从 dex 不能太大,否则在运行时安装加载从 dex 的过程比较复杂和耗时,可能会导致应用程序无响应 (ANR) 的错误。

由于项目是首次做分包,安装包改动已经比较大了,如果再将一直使用且没有问题的 build Tools、SDK Tools 冒然升级以及 drawable 密度后缀目录改变,那么无论怎样,它们所带来的风险和挑战都是比较大的,也会带来后期测试和维护的工作量。所以,我们的方案一定要做到尽量减少这些改变。而对于后面两点,我们就应该考虑对 dex 的拆分进行干预,使每个 dex 的大小在一定的合理范围内,消除或减少触发 Dalvik LinearAlloc 缺陷和限制的概率以及分包引起的 ANR。

综合以上几点,我们就需要在对官方方案透彻研究的基础上,自己实现工具脚本来进行 dex 的自主拆分、加载,便于灵活的适应低版本 Android SDK tools 以及 Android 平台。


三、DEX 自动拆包和动态加载方案

1.Dex 拆分

根据前面对官方方案的研究总结,我们可以很快梳理出下面几个dex拆分步骤:

1)自动扫描整个工程代码得到 main-dex-list;

2)根据 main-dex-list 对整个工程编译后的所有 class 进行拆分,将主、从 dex 的 class 文件分开;

3)用 dx 工具对主、从 dex 的 class 文件分别打包成 .dex 文件,并放在 apk 的合适目录。

怎么自动生成 main-dex-list?

Android SDK 从 build tools 21 开始提供了 mainDexClasses 脚本来生成主 dex 的文件列表。查看这个脚本的源码,可以看到它主要做了下面两件事情:

1)调用 proguard 的 shrink 操作来生成一个临时 jar 包;

2)将生成的临时 jar 包和输入的文件集合作为参数,然后调用com.android.multidex.MainDexListBuilder 来生成主 dex 文件列表。

Proguard的官网执行步骤如下:

在 shrink 这一步,proguard 会根据 keep 规则保留需要的类和类成员,并丢弃不需要的类和类成员。也就是说,上面 shrink 步骤生成的临时 jar 包里面保留了符合 keep 规则的类,这些类是需要放在主 dex 中的入口类。

但是仅有这些入口类放在主 dex 还不够,还要找出入口类引用的其他类,不然仍然会在启动时出现 NoClassDefFoundError。而找出这些引用类,就是调用的 com.android.multidex.MainDexListBuilder,它的部分核心代码如下:

在调用 com.android.multidex.MainDexListBuilder 之后,符合 keep 规则的主 dex 文件列表就生成了。

既然 Android SDK 已经提供了这样一种比较方便的工具,我们就不再重复发明轮子了。所以我们首先把 mainDexClasses 脚本进行了一些适当的改造,然后移植到 RDM 构建环境下,然后根据项目代码的实际情况将主要的基础类、common 类、wakeup 类做为补充规则加入扫描规则中,再加上基本规则 Application、Activity、Service、Provider、Receiver 等类,就组成了项目的主 dex 扫描规则。

这时,新的问题是,由于项目编译打包时有代码混淆的步骤,那我们扫描主 dex 文件列表时到底是在代码混淆之前还是之后?理论上,混淆前后都可以扫描,但是混淆之后扫描时主要的问题是:在制定 keep 规则时,最合理的方式是采用包路径来制定规则,而混淆后的代码中大部分包路径被混淆了,我们无法根据混淆后的包路径来制定 keep 规则,也就无法完全指定哪些文件应该放在主 dex 中。所以,结论就是,我们必须在代码混淆之前扫描生成主 dex 文件列表。

再往下做时,问题又出现了,我们是在扫描生成主 dex 文件列表后就立刻将主、从 dex 的 class 文件拆分到不同目录,然后各自进行代码混淆呢还是统一混淆后再进行 class 文件的拆分呢?答案是,我们需要统一混淆后再做拆分。因为如果拆分后各自混淆,则必然会造成混淆后主、从 dex 引用类名的不一致,从而导致应用无法正常运行。

但是,这样又有了新的问题,我们是在代码混淆之前扫描生成的主 dex 文件列表,当代码混淆之后,大部分类名称和路径都改变了,我们又如何根据主 dex 文件列表做拆分呢?答案是,因为 proguard 做代码混淆时生成了一个混淆前后代码之间的 mapping 关系文件,我们只需要根据这个 mapping 文件进行映射,即可得到混淆后的主 dex 文件列表。

到此为止,思路已经梳理得比较清楚了。

按照这个思路,很快就实现了工具脚本,完成了对主、从 dex 的拆分。这样就实现了主、从 dex 的灵活的生成和定制,不仅解决了前面 Google 官方方案存在的问题,而且也为将来从 dex 的异步加载、按需加载提供了比较好的基础。

最后,项目的从 dex 是打成 jar 包放在 assets 目录,如下图所示:

2.Dex加载

Google 官方提供的 android-support-multidex.jar 可以用来加载官方方案打包的 dex,也完全可以用于加载我们自己的方案打包的 dex,但是这种方式有下面几个不利的地方:

1)灵活性不够,需要所有的从 dex 跟主 dex 在同一级目录,即都在 apk 的根目录,而且从 dex 的命名要符合 classes2.dex、classes3.dex、…、classes(N).dex。

2)该 jar 包提供的是同步加载方式,而且是启动时一次性加载所有的从 dex,但是从项目分包的需求以及其他产品的经验来看,加载接口提供异步加载和按需加载的能力是很有必要的。

因此,我们的加载方案需要有比较好的灵活性以及提供同步加载、异步加载、按需加载的能力。根据这些要求,我们研究了网上一些开源的代码(也包括 Google 官方 android-support-multidex.jar 的代码),然后经过改造和验证,实现了一种比较灵活的加载方案。

跟 Google 官方加载方案一样,这个方案采用的也是运行时动态加载的方式,利用了 Dalvik 虚拟机的类加载器。

我们知道,在 Java 虚拟机里动态加载用的是 ClassLoader。但是在 Dalvik 虚拟机里,却不是 ClassLoader,Android 为我们从 ClassLoader 派生出了两个类:DexClassLoader 和 PathClassLoader。这两者的区别就是 PathClassLoader 不能主动从 zip 包中释放出 dex,因此只支持直接操作 dex 格式文件,或者已经安装的 apk(因为已经安装的 apk 在 cache 中存在缓存的 dex 文件);而 DexClassLoader 可以支持 .apk、.jar 和 .dex文件,并且会在指定的 outpath 路径释放出 dex 文件。

由于前面说了,在安装包里有多个 dex 时,应用安装时不会主动释放从 dex,所以我们需要用 DexClassLoader 来释放加载从 dex。当需要加载从 dex 时,加载逻辑会先从 apk 相应的目录释放出所需加载的从 dex,然后执行加载。

加载过程的部分核心代码如下:

上述代码是通过反射获取 PathClassLoader 中的 DexPathList 中的 Element 数组(加载主 dex 后的 Element 数组)和 DexClassLoader 中的 DexPathList 中的 Element 数组(加载从 dex 后的 Element 数组),然后将两个 Element 数组合并之后,再将其赋值给 PathClassLoader 的 Element 数组。这样就将主、从 dex 中类的访问方式进行了统一,所以也称为 dex 的注入。

那么什么时候加载从 dex 呢?这个问题也就是从 dex 的加载时机。

如果是启动时同步加载,一般可以在 Application 的 onCreate 或 attachBaseContext 中执行加载,两者区别不大。不过,由于 Application 的 onCreate 调用是在 ContentProvider 的 OnCreate 调用之后,而 attachBaseContext 的调用是在 ContentProvider 的 OnCreate 调用之前,所以当 app 有注册 ContentProvider 的时候,就必须在 attachBaseContext 中加载从 dex。

如果是按需加载,则在代码充分解耦后,只要在从 dex 中的代码调用之前执行加载,都是可以的。

3.安装运行

Dex 拆分脚本和加载代码都完成了,打一个包,然后在 Android 2.3 系统的手机上安装运行试试吧。一切顺利,终于出现了久违的闪屏页!

4.小结

上面就是项目 dex 分包方案的研究经过,主要是把 Google 的方案研究清楚以后,又参考了网上的一些开源代码,从而实现了自己的 DEX 自动拆包和动态加载方案。在我们的方案中,可以通过脚本工具来完全定制拆分过程和主、从 dex 文件内容,在运行时也能比较自由、灵活的动态加载从 dex。


四、性能影响

Dex 分包后,如果是启动时同步加载,对应用的启动速度会有一定的影响,但是主要影响的是安装后首次启动。这是因为安装后首次启动时,Android 系统会对加载的从 dex 做 Dexopt 并生成 ODEX,而 Dexopt 是比较耗时的操作,所以对安装后首次启动速度影响较大。在非安装后首次启动时,应用只需加载 ODEX,这个过程速度很快,对启动速度影响不大。同时,从 dex 的大小也直接影响启动速度,即从dex 越小则启动越快。

目前项目的从 dex 的原始大小在 1M 左右。经过测试,安装后首次启动时,在 GT-I8160(Android 2.3) 上加载耗时大约 1200ms,在 N i9250(Android 4.3) 上加载耗时大约 1000ms;非安装后首次启动时,在这两台测试手机上的加载速度分别为约 10ms 和 4ms。


五、后续

分包方案落地后,我们又解决了覆盖安装和 MD5 校验的问题。不过后续还有不少可优化的点如下:

(1) 应用启动性能的优化。如添加启动页、提前做 DexOpt 等;

(2) 编译脚本性能优化。由于分包是一个比较复杂和耗时的过程,开始时分包脚本的性能并不理想,后来经过我们两次优化,将打包过程中的分包时间从7分多钟优化到10秒以内;

(3) 研究未来可能的按需加载或异步加载从 dex 的问题。

 

Share this: